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);
}
}
}