Creator design (2026-11-05). A stack of red rows at the top of the left panel, above both mode grids so it shows while editing and while live, pushing the panel's content down. - Row 1 is the **Steam licence indicator** and nothing displaces it. It reads as status, not a warning: "Entitlement 30 fps · Maximum 60 fps", both numbers derived from QualityOptions and HasProOutput. While higher tiers are unavailable it shows a **pin** (standing indicator, no close control); once ownership is verified the pin becomes a **close control** and the dismissal is persisted (`Steam.LicenceIndicatorDismissed`), permanent while ownership holds. Losing ownership restores it as pinned — a change of state, not a preference. Click → About. - **Event rows stack beneath, newest first**, one row per episode (an episode advancing its state updates its own row rather than adding another). Each is dismissible. Click → the Logs tab opens with that event's sequence range painted red (`HighlightedLogFrom/To` + `LogInRangeConverter`; two numbers rather than a collection because the log list is virtualised and realised rows do not re-evaluate on membership changes). - Replaces the old red panel under the quality picker, which is removed. - The Steam diagnostic text loses "Contact the creator or support" for a neutral "Steam licensing is not configured in this build." Tests: `AttentionAlertStackTests` — pinned first with the status text; owned → closable and the close survives a reload but ownership loss restores it pinned; pinned cannot be dismissed; event rows LIFO with in-place episode updates; clicking an event opens Logs and sets its range. AboutHub + Entitlement tests updated for the removed panel and the new wording. FAST gate: 532 passed, 0 failed, 0 warnings.
158 lines
5.2 KiB
C#
158 lines
5.2 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Linq;
|
|
using System.Windows.Threading;
|
|
using ytLive.Helpers;
|
|
|
|
namespace ytLive.ViewModels;
|
|
|
|
public partial class MainViewModel
|
|
{
|
|
private const int VisibleLogEntryLimit = 500;
|
|
private readonly ObservableCollection<AppLogEntry> _currentLaunchLogEntries = new();
|
|
private readonly ObservableCollection<AppLogEntry> _pinnedLogEntries = new();
|
|
private DispatcherTimer? _currentLaunchLogTimer;
|
|
private long _lastCurrentLaunchLogSequence;
|
|
private bool _isLogsTabSelected;
|
|
|
|
public ObservableCollection<AppLogEntry> CurrentLaunchLogEntries => _currentLaunchLogEntries;
|
|
public ObservableCollection<AppLogEntry> PinnedLogEntries => _pinnedLogEntries;
|
|
|
|
// The event range a clicked alert points at. A range rather than a collection: the log list is
|
|
// virtualised, and a membership change would not re-evaluate realised rows — two numbers do.
|
|
private long _highlightedLogFrom;
|
|
private long _highlightedLogTo;
|
|
public long HighlightedLogFrom => _highlightedLogFrom;
|
|
public long HighlightedLogTo => _highlightedLogTo;
|
|
|
|
private void HighlightLogRange(long from, long to)
|
|
{
|
|
SetProperty(ref _highlightedLogFrom, from, nameof(HighlightedLogFrom));
|
|
SetProperty(ref _highlightedLogTo, to, nameof(HighlightedLogTo));
|
|
}
|
|
public bool HasPinnedLogEntries => _pinnedLogEntries.Count > 0;
|
|
public RelayCommand<AppLogEntry> AcknowledgeLogEntryCommand => new(AcknowledgeLogEntry);
|
|
|
|
public bool IsLiveChatTabSelected
|
|
{
|
|
get => !_isLogsTabSelected;
|
|
set
|
|
{
|
|
if (value)
|
|
SelectLogsTab(false);
|
|
else if (!_isLogsTabSelected)
|
|
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
|
}
|
|
}
|
|
|
|
public bool IsLogsTabSelected
|
|
{
|
|
get => _isLogsTabSelected;
|
|
set
|
|
{
|
|
if (value)
|
|
SelectLogsTab(true);
|
|
else if (_isLogsTabSelected)
|
|
OnPropertyChanged(nameof(IsLogsTabSelected));
|
|
}
|
|
}
|
|
|
|
private void InitializeCurrentLaunchLogs()
|
|
{
|
|
var entries = AppLog.SnapshotCurrentLaunch()
|
|
.OrderByDescending(entry => entry.Sequence)
|
|
.Take(VisibleLogEntryLimit)
|
|
.ToArray();
|
|
foreach (var entry in entries)
|
|
{
|
|
if (entry.ErrorClass.HasValue)
|
|
InsertPinnedLogEntry(entry);
|
|
else
|
|
_currentLaunchLogEntries.Add(entry);
|
|
}
|
|
|
|
_lastCurrentLaunchLogSequence = entries.FirstOrDefault()?.Sequence ?? 0;
|
|
_currentLaunchLogTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(250) };
|
|
_currentLaunchLogTimer.Tick += (_, _) => RefreshCurrentLaunchLogs();
|
|
}
|
|
|
|
private void SelectLogsTab(bool selected)
|
|
{
|
|
if (_isLogsTabSelected == selected)
|
|
{
|
|
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
|
OnPropertyChanged(nameof(IsLogsTabSelected));
|
|
return;
|
|
}
|
|
|
|
_isLogsTabSelected = selected;
|
|
OnPropertyChanged(nameof(IsLiveChatTabSelected));
|
|
OnPropertyChanged(nameof(IsLogsTabSelected));
|
|
if (selected)
|
|
{
|
|
RefreshCurrentLaunchLogs();
|
|
_currentLaunchLogTimer?.Start();
|
|
}
|
|
else
|
|
{
|
|
_currentLaunchLogTimer?.Stop();
|
|
}
|
|
}
|
|
|
|
internal void RefreshCurrentLaunchLogs()
|
|
{
|
|
var newEntries = AppLog.SnapshotCurrentLaunch()
|
|
.Where(entry => entry.Sequence > _lastCurrentLaunchLogSequence)
|
|
.OrderBy(entry => entry.Sequence)
|
|
.ToArray();
|
|
|
|
foreach (var entry in newEntries)
|
|
{
|
|
if (entry.ErrorClass.HasValue)
|
|
InsertPinnedLogEntry(entry);
|
|
else
|
|
InsertChronologicalLogEntry(entry);
|
|
_lastCurrentLaunchLogSequence = entry.Sequence;
|
|
}
|
|
}
|
|
|
|
private void InsertPinnedLogEntry(AppLogEntry entry)
|
|
{
|
|
var index = 0;
|
|
while (index < _pinnedLogEntries.Count)
|
|
{
|
|
var current = _pinnedLogEntries[index];
|
|
var currentClass = (int)current.ErrorClass!.Value;
|
|
var entryClass = (int)entry.ErrorClass!.Value;
|
|
if (currentClass < entryClass
|
|
|| (currentClass == entryClass && current.Sequence > entry.Sequence))
|
|
index++;
|
|
else
|
|
break;
|
|
}
|
|
|
|
_pinnedLogEntries.Insert(index, entry);
|
|
OnPropertyChanged(nameof(HasPinnedLogEntries));
|
|
}
|
|
|
|
private void AcknowledgeLogEntry(AppLogEntry? entry)
|
|
{
|
|
if (entry?.ErrorClass == null || !_pinnedLogEntries.Remove(entry)) return;
|
|
InsertChronologicalLogEntry(entry);
|
|
OnPropertyChanged(nameof(HasPinnedLogEntries));
|
|
}
|
|
|
|
private void InsertChronologicalLogEntry(AppLogEntry entry)
|
|
{
|
|
var index = 0;
|
|
while (index < _currentLaunchLogEntries.Count
|
|
&& _currentLaunchLogEntries[index].Sequence > entry.Sequence)
|
|
index++;
|
|
_currentLaunchLogEntries.Insert(index, entry);
|
|
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
|
|
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
|
|
}
|
|
|
|
private void StopCurrentLaunchLogFeed()
|
|
=> _currentLaunchLogTimer?.Stop();
|
|
}
|