From 3b77a132bee59b781087530e177d31b1ea61d1c2 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 30 Aug 2026 08:48:35 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20Chat=20out=20of=20MainViewM?= =?UTF-8?q?odel=20=E2=80=94=20Commit=2010/12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainViewModel.Chat.cs (194 lines) takes the YouTube chat overlay: ChatMessages (+500 cap, clear-on-fade), OnChatMessageReceived (dispatcher marshal, mock→real preview switch), chat fade timer (ResetChatFadeTimer), the ChatBox render paths (RenderChatBox for the live compositor, UpdateChatBoxPreview for the editor preview), the mock preview loop (MockChatMessages/StartMockChatPreview/ StopMockChatPreview/RunMockChatPreviewAsync/_mockChatCts), UpdateActiveChatBoxPreviews, CanAddYouTubeChat (one-per-layout gate) and ShowChatInactiveMessage. Core drops 1119 -> 940 lines. Ctor wiring (MessageReceived hookup, ChatBoxRenderer ctor, LoadLayout preview start, dispose) stays in core; ResolveOutputFrame (core) still calls RenderChatBox cross-partial. Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: index.md Chat row added + core row trimmed in the same commit. --- ViewModels/MainViewModel.Chat.cs | 194 +++++++++++++++++++++++++++++++ ViewModels/MainViewModel.cs | 179 ---------------------------- ViewModels/index.md | 5 +- 3 files changed, 197 insertions(+), 181 deletions(-) create mode 100644 ViewModels/MainViewModel.Chat.cs diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs new file mode 100644 index 0000000..4165ef6 --- /dev/null +++ b/ViewModels/MainViewModel.Chat.cs @@ -0,0 +1,194 @@ +using System.Collections.ObjectModel; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using ytLive.Helpers; +using ytLive.Models; +using ytLive.Services; + +namespace ytLive.ViewModels; + +public partial class MainViewModel : ViewModelBase +{ + + private readonly ChatBoxRenderer _chatBoxRenderer; + + // Mock chat messages for the ChatBox preview — branded placeholders so the + // creator sees where the overlay lands before going live. + private static readonly IReadOnlyList MockChatMessages = new[] + { + new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true }, + new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true }, + new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) }, + new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true }, + new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true }, + }; + // Chat fade timer: clears chat overlay after N seconds of inactivity. + private System.Windows.Threading.DispatcherTimer? _chatFadeTimer; + + public ObservableCollection ChatMessages { get; } = new(); + + /// The Add → YouTube Chat menu item: enabled only while no chat layer exists + /// anywhere — one chat overlay per layout. + public bool CanAddYouTubeChat + => StagedScene != null + && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); + + public bool ShowChatInactiveMessage => !IsLive; + + private void OnChatMessageReceived(ChatMessage message) + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + ChatMessages.Add(message); + if (ChatMessages.Count > 500) + ChatMessages.RemoveAt(0); + + // Reset chat fade timer on every message + ResetChatFadeTimer(); + + StopMockChatPreview(); + + // Switch ChatBox previews from mock to real messages + if (StagedScene != null) + foreach (var chatBox in StagedScene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) + UpdateChatBoxPreview(chatBox); + }); + } + + private void ResetChatFadeTimer() + { + _chatFadeTimer?.Stop(); + var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList(); + if (chatBoxes.Count == 0) return; + + var delay = chatBoxes.Min(s => s.ChatFadeSeconds); + _chatFadeTimer = new System.Windows.Threading.DispatcherTimer + { Interval = TimeSpan.FromSeconds(delay) }; + _chatFadeTimer.Tick += (_, _) => + { + _chatFadeTimer?.Stop(); + _chatFadeTimer = null; + ChatMessages.Clear(); + foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox)) + UpdateChatBoxPreview(chatBox); + StartMockChatPreview(); + }; + _chatFadeTimer.Start(); + } + + private VideoFrame? RenderChatBox(Source chatBox) + { + if (ChatMessages.Count == 0) return null; + + var width = (int)Math.Max(1, chatBox.Width); + var height = (int)Math.Max(1, chatBox.Height); + + return _chatBoxRenderer.Render( + ChatMessages, + width, + height, + chatBox.ChatFontSize, + chatBox.ChatFontFamily, + chatBox.ChatFontColor, + chatBox.ChatBackgroundColor, + chatBox.ChatMaxMessages, + chatBox.ChatShowTimestamps, + chatBox.ChatShowBadges, + chatBox.ChatMemberColor, + chatBox.ChatSuperChatColor); + } + + /// + /// Renders the ChatBox preview: real messages when connected, mock when not. + /// + private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList? messages = null) + { + try + { + messages ??= ChatMessages.Count > 0 ? (IReadOnlyList)ChatMessages : MockChatMessages; + var width = (int)Math.Max(32, chatBox.Width); + var height = (int)Math.Max(32, chatBox.Height); + + var frame = _chatBoxRenderer.Render( + messages, + width, + height, + chatBox.ChatFontSize, + chatBox.ChatFontFamily, + chatBox.ChatFontColor, + chatBox.ChatBackgroundColor, + chatBox.ChatMaxMessages, + chatBox.ChatShowTimestamps, + chatBox.ChatShowBadges, + chatBox.ChatMemberColor, + chatBox.ChatSuperChatColor); + + var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null); + bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0); + chatBox.VideoImageSource = bitmap; + } + catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); } + } + + private CancellationTokenSource? _mockChatCts; + + private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct) + { + var n = 0; + while (!ct.IsCancellationRequested) + { + UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList()); + await Task.Delay(1000, ct); + n++; + if (n == MockChatMessages.Count) + { + await Task.Delay(3000, ct); + n = 0; + } + } + } + + private void StartMockChatPreview() + { + StopMockChatPreview(); + _mockChatCts = new CancellationTokenSource(); + var ct = _mockChatCts.Token; + foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled)) + _ = RunMockChatPreviewAsync(chatBox, ct); + } + + private void StopMockChatPreview() + { + _mockChatCts?.Cancel(); + _mockChatCts?.Dispose(); + _mockChatCts = null; + } + + /// + /// Render previews + wire property-change subscriptions for all ChatBox + /// sources across every scene. Called after layout load so existing ChatBox + /// sources show mock content and respond to config changes. + /// + private void UpdateActiveChatBoxPreviews() + { + foreach (var scene in Scenes) + foreach (var chatBox in scene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) + { + chatBox.PropertyChanged += (_, e) => + { + if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor) + or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity) + or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps) + or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor) + or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily) + or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height)) + UpdateChatBoxPreview(chatBox); + }; + UpdateChatBoxPreview(chatBox); + } + } +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 63346b8..db98863 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -55,22 +55,8 @@ public partial class MainViewModel : ViewModelBase private readonly Func _rtmpUrlProvider; private readonly TransitionService _transition = new(); - private readonly ChatBoxRenderer _chatBoxRenderer; private WebView2Manager? _webView2Manager; - // Mock chat messages for the ChatBox preview — branded placeholders so the - // creator sees where the overlay lands before going live. - private static readonly IReadOnlyList MockChatMessages = new[] - { - new ChatMessage { AuthorName = "LlamaCasty", Message = "welcome to the stream!", Timestamp = DateTime.Now.AddMinutes(-4), IsMember = true }, - new ChatMessage { AuthorName = "LlamaCastyFan", Message = "$10 — LlamaCasty is the best!", Timestamp = DateTime.Now.AddMinutes(-3), IsSuperChat = true }, - new ChatMessage { AuthorName = "GamerDude", Message = "what streaming software is this?", Timestamp = DateTime.Now.AddMinutes(-2) }, - new ChatMessage { AuthorName = "LlamaCasty", Message = "thanks for watching! llamacasty.com", Timestamp = DateTime.Now.AddMinutes(-1), IsMember = true }, - new ChatMessage { AuthorName = "LlamaDog", Message = "Subscribe so they can buy the subscription at llamacasty.com!", Timestamp = DateTime.Now, IsMember = true }, - }; - // Chat fade timer: clears chat overlay after N seconds of inactivity. - private System.Windows.Threading.DispatcherTimer? _chatFadeTimer; - // Branding flash (monetization): a full-frame "made with LlamaCasty!" shown // for ~1s every 300s on the live output, ~25% opacity. Paid unlock sets // BrandFlashEnabled = false. See ai.md "Monetization". @@ -96,8 +82,6 @@ public partial class MainViewModel : ViewModelBase /// shipped THIRD-PARTY-NOTICES.txt (never opens the OS viewer). public string LicensesText { get; private set; } = string.Empty; - public ObservableCollection ChatMessages { get; } = new(); - public SceneElement? SelectedElement { get => _selectedElement; @@ -125,12 +109,6 @@ public partial class MainViewModel : ViewModelBase } } - /// The Add → YouTube Chat menu item: enabled only while no chat layer exists - /// anywhere — one chat overlay per layout. - public bool CanAddYouTubeChat - => StagedScene != null - && !Scenes.Any(s => s.Elements.OfType().Any(e => e.Type == SourceType.ChatBox)); - private static readonly SolidColorBrush BarOnBrush = CreateBrush("#2ecc71"); private static readonly SolidColorBrush BarOffBrush = CreateBrush("#e94560"); private static SolidColorBrush CreateBrush(string hex) @@ -141,7 +119,6 @@ public partial class MainViewModel : ViewModelBase && !el.IsDraggable && !el.IsWebcam && el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox }; - public bool ShowChatInactiveMessage => !IsLive; public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null; // Paid unlock flips this off (see ai.md "Monetization"). When disabled the @@ -903,49 +880,6 @@ public partial class MainViewModel : ViewModelBase Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }); } - private void OnChatMessageReceived(ChatMessage message) - { - System.Windows.Application.Current.Dispatcher.Invoke(() => - { - ChatMessages.Add(message); - if (ChatMessages.Count > 500) - ChatMessages.RemoveAt(0); - - // Reset chat fade timer on every message - ResetChatFadeTimer(); - - StopMockChatPreview(); - - // Switch ChatBox previews from mock to real messages - if (StagedScene != null) - foreach (var chatBox in StagedScene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) - UpdateChatBoxPreview(chatBox); - }); - } - - private void ResetChatFadeTimer() - { - _chatFadeTimer?.Stop(); - var chatBoxes = Scenes.SelectMany(s => s.Elements).OfType() - .Where(s => s.Type == SourceType.ChatBox && s.ChatFadeSeconds > 0).ToList(); - if (chatBoxes.Count == 0) return; - - var delay = chatBoxes.Min(s => s.ChatFadeSeconds); - _chatFadeTimer = new System.Windows.Threading.DispatcherTimer - { Interval = TimeSpan.FromSeconds(delay) }; - _chatFadeTimer.Tick += (_, _) => - { - _chatFadeTimer?.Stop(); - _chatFadeTimer = null; - ChatMessages.Clear(); - foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() - .Where(s => s.Type == SourceType.ChatBox)) - UpdateChatBoxPreview(chatBox); - StartMockChatPreview(); - }; - _chatFadeTimer.Start(); - } - // Scene-element → latest frame, for the live compositor. The map mirrors the // preview: webcam by DeviceId, live captures (the background) by CaptureKey, // images/background by AssetId. A null frame leaves the element transparent. @@ -962,119 +896,6 @@ public partial class MainViewModel : ViewModelBase }; } - private VideoFrame? RenderChatBox(Source chatBox) - { - if (ChatMessages.Count == 0) return null; - - var width = (int)Math.Max(1, chatBox.Width); - var height = (int)Math.Max(1, chatBox.Height); - - return _chatBoxRenderer.Render( - ChatMessages, - width, - height, - chatBox.ChatFontSize, - chatBox.ChatFontFamily, - chatBox.ChatFontColor, - chatBox.ChatBackgroundColor, - chatBox.ChatMaxMessages, - chatBox.ChatShowTimestamps, - chatBox.ChatShowBadges, - chatBox.ChatMemberColor, - chatBox.ChatSuperChatColor); - } - - /// - /// Renders the ChatBox preview: real messages when connected, mock when not. - /// - private void UpdateChatBoxPreview(Source chatBox, IReadOnlyList? messages = null) - { - try - { - messages ??= ChatMessages.Count > 0 ? (IReadOnlyList)ChatMessages : MockChatMessages; - var width = (int)Math.Max(32, chatBox.Width); - var height = (int)Math.Max(32, chatBox.Height); - - var frame = _chatBoxRenderer.Render( - messages, - width, - height, - chatBox.ChatFontSize, - chatBox.ChatFontFamily, - chatBox.ChatFontColor, - chatBox.ChatBackgroundColor, - chatBox.ChatMaxMessages, - chatBox.ChatShowTimestamps, - chatBox.ChatShowBadges, - chatBox.ChatMemberColor, - chatBox.ChatSuperChatColor); - - var bitmap = new WriteableBitmap(width, height, 96, 96, System.Windows.Media.PixelFormats.Bgra32, null); - bitmap.WritePixels(new System.Windows.Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0); - chatBox.VideoImageSource = bitmap; - } - catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); } - } - - private CancellationTokenSource? _mockChatCts; - - private async Task RunMockChatPreviewAsync(Source chatBox, CancellationToken ct) - { - var n = 0; - while (!ct.IsCancellationRequested) - { - UpdateChatBoxPreview(chatBox, MockChatMessages.Take(n + 1).ToList()); - await Task.Delay(1000, ct); - n++; - if (n == MockChatMessages.Count) - { - await Task.Delay(3000, ct); - n = 0; - } - } - } - - private void StartMockChatPreview() - { - StopMockChatPreview(); - _mockChatCts = new CancellationTokenSource(); - var ct = _mockChatCts.Token; - foreach (var chatBox in Scenes.SelectMany(s => s.Elements).OfType() - .Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled)) - _ = RunMockChatPreviewAsync(chatBox, ct); - } - - private void StopMockChatPreview() - { - _mockChatCts?.Cancel(); - _mockChatCts?.Dispose(); - _mockChatCts = null; - } - - /// - /// Render previews + wire property-change subscriptions for all ChatBox - /// sources across every scene. Called after layout load so existing ChatBox - /// sources show mock content and respond to config changes. - /// - private void UpdateActiveChatBoxPreviews() - { - foreach (var scene in Scenes) - foreach (var chatBox in scene.Elements.OfType().Where(s => s.Type == SourceType.ChatBox)) - { - chatBox.PropertyChanged += (_, e) => - { - if (e.PropertyName is nameof(Source.ChatFontSize) or nameof(Source.ChatFontColor) - or nameof(Source.ChatBackgroundColor) or nameof(Source.ChatBackgroundOpacity) - or nameof(Source.ChatMaxMessages) or nameof(Source.ChatShowTimestamps) - or nameof(Source.ChatShowBadges) or nameof(Source.ChatMemberColor) - or nameof(Source.ChatSuperChatColor) or nameof(Source.ChatFontFamily) - or nameof(Source.ChatFadeSeconds) or nameof(Source.Width) or nameof(Source.Height)) - UpdateChatBoxPreview(chatBox); - }; - UpdateChatBoxPreview(chatBox); - } - } - // The tier's crop rect over the 1920x1080 master, integer-aligned (the VM's // OutputRect* are doubles — the vertical 607.5 half-pixel crop rounds to 608). private CompositorOptions BuildCompositorOptions() diff --git a/ViewModels/index.md b/ViewModels/index.md index 65e56b4..6b75b56 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read. | File | Purpose | |------|---------| -| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`/`ShowChatInactiveMessage`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`) — the pump's stream-state consumers live in the Streaming partial. | +| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`, plus `ResolveOutputFrame` → `RenderChatBox` for chat layers) — the pump's stream-state consumers live in the Streaming partial. | | `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). | | `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. | | `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). | @@ -18,6 +18,7 @@ areas' docs in their own rows keeps this index small to read. | `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). | | `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). | | `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:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, 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. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. | +| `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), 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`). `_chatBoxRenderer` field. Ctor wiring (`MessageReceived` hookup, `new ChatBoxRenderer()`, `LoadLayout` preview start, `_youtubeChat.Dispose`) stays in core. | | `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`)** | 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 | @@ -47,7 +48,7 @@ Progress (Commit N/12 · tag): - [x] **Commit 7/12 · `refactor-commit-7`** — **Trax** → `MainViewModel.Trax.cs` - [x] **Commit 8/12 · `refactor-commit-8`** — **Socials** → `MainViewModel.Socials.cs` - [x] **Commit 9/12 · `refactor-commit-9`** — **Streaming** → `MainViewModel.Streaming.cs` -- [ ] **Commit 10/12** — **Chat** (YouTube chat + mock preview + chatbox render) +- [x] **Commit 10/12 · `refactor-commit-10`** — **Chat** → `MainViewModel.Chat.cs` - [ ] **Commit 11/12** — **Overlays** (settings/bug/feature/about panels + license entry) The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split