using System.Collections.Generic; using System.Linq; using System.Windows; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using ytLive.Models; namespace ytLive.Services; /// /// The native alert box (OBS-style celebration zone, no third party). Owns the /// event queue and the animation clock: events from /// the chat feed are enqueued one-at-a-time and played through their own unique /// animation (), frames served to the stream compositor /// via and mirrored to the preview via /// . Idle = transparent (null frame); playback is /// advanced by a 33ms dispatch ticker. The clock is exposed as /// so tests drive playback deterministically without the /// timer. Ordinary chat rows () never enqueue — /// a subscriber mention is a chat row, not an alert. /// public sealed class AlertOverlayLayer : IDisposable { private readonly AlertRenderer _renderer; private readonly Queue _queue = new(); private readonly DispatcherTimer? _ticker; private ChatMessage? _current; private double _elapsed; private IEnumerable? _scenes; private int _contentVersion; private int _frameVersion = -1; private string? _frameKey; private VideoFrame? _cachedFrame; private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33); public AlertOverlayLayer(AlertRenderer renderer) { _renderer = renderer; if (Application.Current != null) { _ticker = new DispatcherTimer { Interval = TickInterval }; _ticker.Tick += (_, _) => { Advance(TickInterval.TotalSeconds); RefreshAlertPreviews(); }; } } /// Production entry from the chat feed: enqueue one event. The queue /// lives on the UI thread (the poller marshals here), so the ticker's own /// thread expectations hold for preview writes. public void OnMessageReceived(ChatMessage message, IEnumerable scenes) { _scenes = scenes; Enqueue(message); _ticker?.Start(); RefreshAlertPreviews(); } /// Queue an event (pure — also the test seam). Chat rows carry /// and are ignored: no alert for a mention. public void Enqueue(ChatMessage message) { if (message.Kind == ChatEventKind.None) return; if (_queue.Count >= 10) return; // back-to-back floods drop the tail, never stall playback _queue.Enqueue(message); if (_current == null) { _current = _queue.Dequeue(); _elapsed = 0; } _contentVersion++; } /// Advance the playback clock (the ticker's beat — and the test seam; /// tests call this directly and never start the timer). Draining the queue to /// nothing stops the ticker and leaves the box transparent. public void Advance(double deltaSeconds) { if (_current == null) { if (_queue.Count == 0) return; _current = _queue.Dequeue(); _elapsed = 0; _contentVersion++; return; } _elapsed += deltaSeconds; if (_elapsed >= DurationOf(_current)) { _current = null; _elapsed = 0; if (_queue.Count > 0) { _current = _queue.Dequeue(); _elapsed = 0; } else { _ticker?.Stop(); } } _contentVersion++; } /// Whether an alert is currently playing or queued. public bool IsPlaying => _current != null || _queue.Count > 0; /// The live-output frame for one AlertBox element: the current /// animation frame while playing, null (transparent) when idle. Mirrors the /// chat cache pattern — the raster is WPF/UI-thread, so only the cache miss /// marshals to the dispatcher and re-validates there. public VideoFrame? RenderFrame(Source alertBox) { if (_current == null) { _frameKey = null; return null; } var width = (int)Math.Max(1, alertBox.Width); var height = (int)Math.Max(1, alertBox.Height); var key = $"{width}x{height}|{_current.Id}|{Progress()}"; if (_frameKey == key && _frameVersion == _contentVersion) return _cachedFrame; var app = Application.Current; if (app != null && !app.Dispatcher.CheckAccess()) { var capturedKey = key; var capturedVersion = _contentVersion; return app.Dispatcher.Invoke(() => { if (_frameKey == capturedKey && _frameVersion == capturedVersion) return _cachedFrame; return RenderCore(alertBox, width, height, capturedKey, capturedVersion); }); } return RenderCore(alertBox, width, height, key, _contentVersion); } private VideoFrame? RenderCore(Source alertBox, int width, int height, string key, int version) { _frameKey = key; _frameVersion = version; if (_current == null) return _cachedFrame = null; return _cachedFrame = _renderer.Render(_current, Progress(), width, height); } private double Progress() { if (_current == null) return 0; return Math.Clamp(_elapsed / DurationOf(_current), 0.0, 1.0); } private static double DurationOf(ChatMessage m) => m.Kind switch { ChatEventKind.SuperChat => 4.6, ChatEventKind.SuperSticker => 4.2, ChatEventKind.NewMember => 4.0, ChatEventKind.MemberGift => 5.0, ChatEventKind.GiftReceived => 4.4, ChatEventKind.MemberMilestone => 4.8, _ => 4.0, }; /// Render the current state onto one AlertBox preview (mirrors chat): /// the playing frame, or null when idle so the source shows nothing until an /// event actually fires. public void UpdatePreview(Source alertBox) { try { var frame = RenderFrame(alertBox); if (frame == null) { alertBox.VideoImageSource = null; return; } var width = (int)Math.Max(1, alertBox.Width); var height = (int)Math.Max(1, alertBox.Height); var bitmap = new WriteableBitmap(width, height, 96, 96, PixelFormats.Bgra32, null); bitmap.WritePixels(new Int32Rect(0, 0, width, height), frame.BgraPixels, frame.Stride, 0); alertBox.VideoImageSource = bitmap; } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Alert preview failed: {ex.Message}"); } } /// Wire property-change subscriptions for all AlertBox sources across /// every scene (geometry changes re-render), and clear/reset their previews. public void UpdateConfiguredBoxes(IEnumerable scenes) { _scenes = scenes; foreach (var scene in scenes) foreach (var alertBox in scene.Elements.OfType().Where(s => s.Type == SourceType.AlertBox)) { alertBox.PropertyChanged += (_, e) => { if (e.PropertyName is nameof(Source.Width) or nameof(Source.Height)) UpdatePreview(alertBox); }; UpdatePreview(alertBox); } } private void RefreshAlertPreviews() { if (_scenes == null) return; foreach (var alertBox in _scenes.SelectMany(s => s.Elements).OfType() .Where(s => s.Type == SourceType.AlertBox)) UpdatePreview(alertBox); } public void Dispose() { _ticker?.Stop(); _queue.Clear(); _current = null; } }