From 85893ea70971b71bec16d423c0dbb5e8538fddb7 Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 31 Aug 2026 10:48:10 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20extract=20ChatOverlayLayer=20?= =?UTF-8?q?=E2=80=94=20real=20component=20decomposition,=20Commit=20G?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first true decomposition of the 4105-line god-object, not another partial shuffle. MainViewModel.Chat.cs 194 -> 44 lines of thin delegation; all chat behavior now lives in a self-contained Services/ChatOverlayLayer.cs (199): - Owns the message buffer (Messages), ChatBoxRenderer, fade + mock-preview timers, per-source preview renders, and the live-output RenderFrame path. - The VM keeps only the binding surface: ChatMessages delegates to _chatLayer.Messages (so XAML ItemsSource + LeftPanel CollectionChanged hold), CanAddYouTubeChat / ShowChatInactiveMessage stay computed on the VM (OnPropertyChanged raised from VM setters + XAML-bound), and thin forwards. - Scenes handed in as args (no Func seam), so the layer owns no scene graph. An AI reading ChatOverlayLayer.cs now sees the entire chat feature in one self-contained unit. Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: ViewModels/index.md tracker updated in same commit. --- Services/ChatOverlayLayer.cs | 199 +++++++++++++++++++++++++++++++ ViewModels/MainViewModel.Chat.cs | 176 ++------------------------- ViewModels/MainViewModel.cs | 2 +- ViewModels/index.md | 1 + 4 files changed, 214 insertions(+), 164 deletions(-) create mode 100644 Services/ChatOverlayLayer.cs diff --git a/Services/ChatOverlayLayer.cs b/Services/ChatOverlayLayer.cs new file mode 100644 index 0000000..912ca8b --- /dev/null +++ b/Services/ChatOverlayLayer.cs @@ -0,0 +1,199 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using System.Windows.Threading; +using ytLive.Helpers; +using ytLive.Models; + +namespace ytLive.Services; + +/// Owns the YouTube-chat overlay behavior: the message buffer, the +/// renderer, and the fade/mock-preview timers. The MainViewModel keeps only the +/// thin binding surface (the shared collection + two +/// computed gate props) and hands the scene list in at each call — this class +/// owns all chat logic so an AI can read one self-contained unit. +public sealed class ChatOverlayLayer +{ + private readonly ChatBoxRenderer _renderer; + + // 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 DispatcherTimer? _fadeTimer; + private CancellationTokenSource? _mockCts; + + /// The live chat message buffer. Shared with the ViewModel (which + /// binds it to the panel) via ChatMessages => _chatLayer.Messages. + public ObservableCollection Messages { get; } = new(); + + public ChatOverlayLayer(ChatBoxRenderer renderer) => _renderer = renderer; + + /// Ingest a message from the poller. UI-thread marshalled: appended + /// to the buffer (capped at 500), mock preview stopped, swipe to real + /// messages, and the fade timer reset. Scenes are handed in so the layer can + /// refresh every ChatBox preview without owning the scene graph. + public void OnMessageReceived(ChatMessage message, IEnumerable scenes) + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + Messages.Add(message); + if (Messages.Count > 500) + Messages.RemoveAt(0); + + // Reset chat fade timer on every message + ResetChatFadeTimer(scenes); + + StopMockPreview(); + + // Switch ChatBox previews from mock to real messages + foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox)) + UpdatePreview(chatBox); + }); + } + + private void ResetChatFadeTimer(IEnumerable scenes) + { + _fadeTimer?.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); + _fadeTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(delay) }; + _fadeTimer.Tick += (_, _) => + { + _fadeTimer?.Stop(); + _fadeTimer = null; + Messages.Clear(); + foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox)) + UpdatePreview(chatBox); + StartMockPreview(scenes); + }; + _fadeTimer.Start(); + } + + /// Renders the live-output frame for one ChatBox element (the + /// compositor path), or null when no messages are buffered yet. Mirrors the + /// preview render but without writing onto the source's VideoImageSource. + public VideoFrame? RenderFrame(Source chatBox) + { + if (Messages.Count == 0) return null; + + var width = (int)Math.Max(1, chatBox.Width); + var height = (int)Math.Max(1, chatBox.Height); + + return _renderer.Render( + Messages, + width, + height, + chatBox.ChatFontSize, + chatBox.ChatFontFamily, + chatBox.ChatFontColor, + chatBox.ChatBackgroundColor, + chatBox.ChatMaxMessages, + chatBox.ChatShowTimestamps, + chatBox.ChatShowBadges, + chatBox.ChatMemberColor, + chatBox.ChatSuperChatColor); + } + + /// Renders one ChatBox preview: real messages when connected, mock + /// when not. Writes the resulting bitmap onto the source's + /// VideoImageSource. + public void UpdatePreview(Source chatBox, IReadOnlyList? messages = null) + { + try + { + messages ??= Messages.Count > 0 ? (IReadOnlyList)Messages : MockChatMessages; + var width = (int)Math.Max(32, chatBox.Width); + var height = (int)Math.Max(32, chatBox.Height); + + var frame = _renderer.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, PixelFormats.Bgra32, null); + bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0); + chatBox.VideoImageSource = bitmap; + } + catch (Exception ex) { AppLog.Write($"UpdateChatBoxPreview failed: {ex.Message}"); } + } + + private async Task RunMockPreviewAsync(Source chatBox, CancellationToken ct) + { + var n = 0; + while (!ct.IsCancellationRequested) + { + UpdatePreview(chatBox, MockChatMessages.Take(n + 1).ToList()); + await Task.Delay(1000, ct); + n++; + if (n == MockChatMessages.Count) + { + await Task.Delay(3000, ct); + n = 0; + } + } + } + + public void StartMockPreview(IEnumerable scenes) + { + StopMockPreview(); + _mockCts = new CancellationTokenSource(); + var ct = _mockCts.Token; + foreach (var chatBox in scenes.SelectMany(s => s.Elements).OfType() + .Where(s => s.Type == SourceType.ChatBox && s.ChatPreviewEnabled)) + _ = RunMockPreviewAsync(chatBox, ct); + } + + public void StopMockPreview() + { + _mockCts?.Cancel(); + _mockCts?.Dispose(); + _mockCts = 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. + public void UpdateConfiguredBoxes(IEnumerable scenes) + { + 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)) + UpdatePreview(chatBox); + }; + UpdatePreview(chatBox); + } + } +} diff --git a/ViewModels/MainViewModel.Chat.cs b/ViewModels/MainViewModel.Chat.cs index 4165ef6..e4cbe9b 100644 --- a/ViewModels/MainViewModel.Chat.cs +++ b/ViewModels/MainViewModel.Chat.cs @@ -1,7 +1,5 @@ using System.Collections.ObjectModel; -using System.Windows.Media; -using System.Windows.Media.Imaging; -using System.Windows.Threading; +using System.Linq; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; @@ -11,22 +9,12 @@ namespace ytLive.ViewModels; public partial class MainViewModel : ViewModelBase { - private readonly ChatBoxRenderer _chatBoxRenderer; + private readonly ChatOverlayLayer _chatLayer; - // 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 live chat message buffer — the same collection the + /// owns; here so XAML can bind it and the + /// left panel can subscribe to its changes. + public ObservableCollection ChatMessages => _chatLayer.Messages; /// The Add → YouTube Chat menu item: enabled only while no chat layer exists /// anywhere — one chat overlay per layout. @@ -37,158 +25,20 @@ public partial class MainViewModel : ViewModelBase 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); + => _chatLayer.OnMessageReceived(message, Scenes); - // 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 void UpdateChatBoxPreview(Source chatBox, IReadOnlyList? messages = null) + => _chatLayer.UpdatePreview(chatBox, messages); 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; - } - } - } + => _chatLayer.RenderFrame(chatBox); 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); - } + => _chatLayer.StartMockPreview(Scenes); private void StopMockChatPreview() - { - _mockChatCts?.Cancel(); - _mockChatCts?.Dispose(); - _mockChatCts = null; - } + => _chatLayer.StopMockPreview(); - /// - /// 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); - } - } + => _chatLayer.UpdateConfiguredBoxes(Scenes); } diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 11d6139..1bbaa7b 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -274,7 +274,7 @@ public partial class MainViewModel : ViewModelBase _screenCaptureManager.CaptureFailed += (key, message) => AppLog.Write($"ScreenCaptureManager: capture '{key}' failed: {message}"); - _chatBoxRenderer = new ChatBoxRenderer(); + _chatLayer = new ChatOverlayLayer(new ChatBoxRenderer()); _rtmpUrlProvider = () => _reusableStreamUrl; // TASK 5: the reusable stream's ingest URL _framePump = new FramePump( diff --git a/ViewModels/index.md b/ViewModels/index.md index 3ded473..849f69f 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -72,5 +72,6 @@ commit, tag `refactor-commit-N`, no per-commit push). - [x] **Commit D · `refactor-commit-D`** — **SocialsDialog** → `SocialsDialogViewModel.cs` 515 → **334** + new **`SocialSlotViewModel.cs`** (183 lines). Moved the `DialogEntry` record + the row-level `SocialSlotViewModel` (six-fixed-slot row VM: service/logo/lock/edit/validate state + computed `Show*` flags) into its own file; `SocialsDialogViewModel.cs` keeps the dialog VM (sign-in gate, validation orchestration, commit). `DialogEntry` stays `public` in the same namespace so both files see it. Pending: LayoutStore. - [x] **Commit E · `refactor-commit-E`** — **LayoutStore** → `LayoutStore.cs` 1402 split into 6 concern-based partials: **`LayoutStore.cs`** (47: shell — `Instance`, fields, ctor + `EnsureSchema`, `GetUserVersion`, `Dispose`), **`LayoutStore.Migrations.cs`** (495: all `Migrate*` + `EnsureSchema`), **`LayoutStore.Load.cs`** (221: `Load`), **`LayoutStore.Save.cs`** (339: `Save`), **`LayoutStore.Settings.cs`** (292: mic/record-folder/reusable-stream + license/transition/broadcast/hotkey/window `GetSetting`/`UpsertSetting`), **`LayoutStore.Assets.cs`** (41: `GetAssetBytes`/`UpsertAsset`). **PHASE 3 COMPLETE — no production `.cs` over 500.** - [x] **Commit F · `refactor-commit-F`** — **Recording (context/concern)** → new **`MainViewModel.Recording.cs`** (121 lines). The encoder (`Services/Encoder/FfmpegEncoder.cs`) and frame pump (`Services/Encoder/FramePump.cs`) were ALREADY extracted services — the planned "extract FFmpegEncoder/StreamHealthMonitor/FramePump" was a no-op premise. The honest functional seam was the local recording-output concern: `StartRecordFile`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder` + fields `_recordFolder`/`RecordFolderDisplay`/`_activeRecordPath`/`_recordStartTime`/`_recordLength` moved out of the live-stream orchestration. `MainViewModel.Streaming.Operations.cs` 475 → **377**; `MainViewModel.Streaming.cs` 328 → **323**. Same partial class, MVVM glue untouched. +- [x] **Commit G · `refactor-commit-G`** — **ChatOverlayLayer (true component)** → new **`Services/ChatOverlayLayer.cs`** (199 lines). The first REAL decomposition of the 4,105-line god-object: `MainViewModel.Chat.cs` 194 → **44** (thin delegation). The layer owns all chat behavior — `Messages` buffer, `ChatBoxRenderer`, fade timer, mock-preview timer, per-source preview renders, live-output `RenderFrame`. The VM keeps only the binding surface: `ChatMessages` (delegates to `_chatLayer.Messages`, so XAML bind + LeftPanel `CollectionChanged` hold), `CanAddYouTubeChat`/`ShowChatInactiveMessage` (computed + `OnPropertyChanged` from VM setters), and thin forwards. Scenes are handed in as args (no `Func` seam) so the layer stays scene-graph-free. Zero behavior change; 246 pass / 2 known. The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split proceeds; each partial carries its own `using`s and re-declares nothing from core. \ No newline at end of file