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(); // Output-frame cache (take-5 starvation fix): the pump called RenderFrame // every tick and the renderer did a FULL WPF raster (FormattedText + // RenderTargetBitmap + CopyPixels + channel swap) each time — 15-25ms of a // 25.5ms render. Established answer (OBS text sources): raster on change, // blit the cache every tick. _contentVersion bumps on every buffer mutation // (CollectionChanged — covers adds, the 500-cap removal, and the fade // Clear from any caller); the config key covers the box geometry/appearance. // The cached VideoFrame is treated as immutable: the compositor only reads. private int _contentVersion; private int _frameVersion = -1; private string? _frameKey; private VideoFrame? _cachedFrame; public ChatOverlayLayer(ChatBoxRenderer renderer) { _renderer = renderer; Messages.CollectionChanged += (_, _) => _contentVersion++; } /// 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) { var width = (int)Math.Max(1, chatBox.Width); var height = (int)Math.Max(1, chatBox.Height); var key = $"{width}x{height}|{chatBox.ChatFontSize}|{chatBox.ChatFontFamily}|{chatBox.ChatFontColor}" + $"|{chatBox.ChatBackgroundColor}|{chatBox.ChatMaxMessages}|{chatBox.ChatShowTimestamps}" + $"|{chatBox.ChatShowBadges}|{chatBox.ChatMemberColor}|{chatBox.ChatSuperChatColor}"; if (_frameKey == key && _frameVersion == _contentVersion) return _cachedFrame; // same content + same config — the cached raster stands _frameKey = key; _frameVersion = _contentVersion; if (Messages.Count == 0) return _cachedFrame = null; return _cachedFrame = _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); } } }