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