feat(ui): add current-launch logs tab

Add Live Chat and Logs tabs to the non-edit left panel. Expose a bounded process-local AppLog feed so the newest messages appear first without importing earlier startup.log contents or changing file logging.

Ref: https://obsproject.com/forum/resources/understanding-obs-studio-log.473
This commit is contained in:
2026-11-02 11:06:43 -08:00
parent 20f059422b
commit 9fa06c67c2
11 changed files with 305 additions and 77 deletions
+95
View File
@@ -0,0 +1,95 @@
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 DispatcherTimer? _currentLaunchLogTimer;
private long _lastCurrentLaunchLogSequence;
private bool _isLogsTabSelected;
public ObservableCollection<AppLogEntry> CurrentLaunchLogEntries => _currentLaunchLogEntries;
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()
{
foreach (var entry in AppLog.SnapshotCurrentLaunch()
.OrderByDescending(entry => entry.Sequence)
.Take(VisibleLogEntryLimit))
_currentLaunchLogEntries.Add(entry);
_lastCurrentLaunchLogSequence = _currentLaunchLogEntries.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)
{
_currentLaunchLogEntries.Insert(0, entry);
if (_currentLaunchLogEntries.Count > VisibleLogEntryLimit)
_currentLaunchLogEntries.RemoveAt(_currentLaunchLogEntries.Count - 1);
_lastCurrentLaunchLogSequence = entry.Sequence;
}
}
private void StopCurrentLaunchLogFeed()
=> _currentLaunchLogTimer?.Stop();
}
+2
View File
@@ -192,6 +192,7 @@ public partial class MainViewModel : ViewModelBase
public MainViewModel()
{
AppLog.Write("MainViewModel ctor begin");
InitializeCurrentLaunchLogs();
IsAppOwned = _entitlement.IsAppOwned;
_youtubeAuth = new YouTubeAuthService(OAuthCredentials.ClientId, OAuthCredentials.ClientSecret,
sessionChanged: ch => TokenStore.Save(ch));
@@ -524,6 +525,7 @@ public partial class MainViewModel : ViewModelBase
public void Shutdown()
{
StopCurrentLaunchLogFeed();
_saveDebounce?.Stop();
SaveLayoutNow();
_fullScreenDetector.StopWatching();
+1
View File
@@ -21,6 +21,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** mode switch `RecordPillOn`/`OnAirPillOn`, top-bar worlds (`ShowIdleWorld`/`ShowRecordAction`/`ShowStreamActions`/`ShowSignInButton`/`ShowAvatarButton`, reality line `RunningVisible`/`RunningText`/`RunningDotBrush`, Test gate `ShowTestButton`/`CanStartTest` — `RaiseTopBarModes()`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master; **TASK 57:** the tier change also resolves every scene's camera placement (the vertical tier brings the vertical placements in, 16:9 restores the desktop layout — see `MainViewModel.Webcam.cs`). **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. **TASK 57 (Unit 2):** the tier change also raises `CanToggleClipShape` (the circle option disappears in the vertical Chat layout). |
| `MainViewModel.Overlays.cs` | Settings/bug/feature/about panels + creator-hub links: panel state `IsSettingsOpen`/`IsBugOpen`/`IsFeatureOpen`/`IsAboutOpen` (via `SetPanel` → `IsAnyOverlayOpen`), `IsLicensesOpen` (flips `IsHubVisible`), `OverlayTitle`, `ShowOverlay` (one-panel exclusivity), `ShowAbout` (returns to the About hub) + `ShowLicenses` (loads shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` in-app); bug/feature report `BugReportText`/`BugReportEmail`/`FeatureRequestText`/`FeatureRequestEmail` + `SubmitBug`/`SubmitFeature`/`ComposeEmail`/`OpenUrl`; About hub consts `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` + `PremiumUrl` (`https://llamacasty.com`, opens only when explicitly clicked) + `IsPremiumAvailable` + `SupportEmail` + `AppVersionLabel`; commands `OpenSettingsCommand`/`OpenBugCommand`/`OpenFeatureCommand`/`OpenAboutCommand`/`CloseOverlayCommand`/`SubmitBugCommand`/`SubmitFeatureCommand`/`OpenChannelCommand`/`OpenMastodonCommand`/`OpenCoffeeCommand`/`OpenPremiumCommand`/`OpenLicensesCommand`. A locked quality selection opens About without changing the tier. Ctor command assignments stay in core. |
| `MainViewModel.Chat.cs` | Chat overlay + preview pipeline: `ChatMessages` (500-message cap; clear-on-fade), `OnChatMessageReceived` (marshal `_youtubeChat.MessageReceived` onto the dispatcher, switch previews mock→real, then feed **both** component layers), fade `_chatFadeTimer`/`ResetChatFadeTimer` (shortest `ChatFadeSeconds` across chat layers); live render `RenderChatBox` (called from `ResolveOutputFrame`, stays in core) + preview render `UpdateChatBoxPreview` (bitmap onto `chatBox.VideoImageSource`); mock preview `MockChatMessages`/`StartMockChatPreview`/`StopMockChatPreview`/`RunMockChatPreviewAsync`/`_mockChatCts`; `UpdateActiveChatBoxPreviews` (wires Chat* property-change subscriptions + first render); `CanAddYouTubeChat` (one-per-layout gate), `ShowChatInactiveMessage` (`!IsLive`). **Alerts (TASK 43):** same facade for the native alert box — `_alertLayer` (`AlertOverlayLayer`), `RenderAlertBox`/`UpdateAlertBoxPreview`, `UpdateActiveAlertBoxPreviews`, `CanAddAlerts` (one alert zone per layout). Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `new AlertRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose` + `_alertLayer.Dispose`) stays in core. |
| `MainViewModel.Logs.cs` | Non-edit left-panel tab state and the bounded current-launch log collection; refreshes the process-local AppLog snapshot while Logs is selected and stops its dispatcher timer on shutdown. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel.Overlays.cs`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |