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; using ytLive.Services.Audio; using ytLive.Services.Compositor; 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 a celebration /// animation () OR — when the alert-box video is /// configured and resolves — through a decoded clip. Frames are 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. /// /// TASK 47 (Stream Alerts video): a video-backed alert replaces the animation /// while a clip decodes (, one per play). The /// clip plays at its own media pace; the layer applies a ~300ms fade-in/out /// alpha envelope to the frames AND the audio samples (freeze-frame fade-out on /// the tail), clips the alert to the clip length, and forwards the scaled audio /// to the mixer's alert ring via (no duck). The /// per-source config (path/default/ticker/volume) is resolved through /// ; the ticker strip is served via /// . /// public sealed class AlertOverlayLayer : IDisposable { /// The fade-in/out ramp length (video alpha AND audio gain). Measured /// against a real clip in the AlertLayerVideoTests unit; keep the ramp short /// enough to read as a "pop-in" on stream (see ai.md). internal const double FadeDurationSeconds = 0.30; private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(33); private readonly AlertRenderer _renderer; private readonly Func? _clipDecoderFactory; private readonly Func? _clipPathResolver; private readonly Action? _audioSink; private readonly Queue _queue = new(); private readonly DispatcherTimer? _ticker; private ChatMessage? _current; private Source? _currentBox; private IAlertClipDecoder? _clip; private VideoFrame? _latestClipFrame; private bool _clipEof; private double _clipEofElapsed; private int _clipWidth; private int _clipHeight; private string? _tickerText; private double _elapsed; private IEnumerable? _scenes; private int _contentVersion; private int _frameVersion = -1; private string? _frameKey; private VideoFrame? _cachedFrame; public AlertOverlayLayer( AlertRenderer renderer, Func? clipDecoderFactory = null, Func? clipPathResolver = null, Action? audioSink = null) { _renderer = renderer; _clipDecoderFactory = clipDecoderFactory; _clipPathResolver = clipPathResolver; _audioSink = audioSink; if (Application.Current != null) { _ticker = new DispatcherTimer { Interval = TickInterval }; _ticker.Tick += (_, _) => { Advance(TickInterval.TotalSeconds); RefreshAlertPreviews(); }; } } /// Production entry from the chat feed: enqueue one event. Messages /// arrive on the chat poller thread, so this marshals to the UI thread (same /// rule as ) — the preview /// writes create WPF instances, and creating a /// DependencySource off the UI thread crashes the WPF binding engine /// (ArgumentException "Must create DependencySource on same Thread"). public void OnMessageReceived(ChatMessage message, IEnumerable scenes) { var app = Application.Current; if (app != null && !app.Dispatcher.CheckAccess()) { app.Dispatcher.Invoke(() => Ingest(message, scenes)); return; } Ingest(message, scenes); } private void Ingest(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; BeginClip(); } _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) { // Video-backed alert: duration is clip-driven. The clip plays at media // pace; once it EOFs the last frame sweeps through the fade-out before // the alert moves on (a hard cut at EOF would pop, not fade). if (_clip != null) { _elapsed += deltaSeconds; if (_clipEof && _elapsed - _clipEofElapsed >= FadeDurationSeconds) { _clipEof = false; StopClip(); _current = null; _elapsed = 0; AdvanceToNext(); } _contentVersion++; return; } if (_current == null) { if (_queue.Count == 0) return; _current = _queue.Dequeue(); _elapsed = 0; BeginClip(); _contentVersion++; return; } _elapsed += deltaSeconds; if (_elapsed >= DurationOf(_current)) { _current = null; _elapsed = 0; AdvanceToNext(); } _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 decoded clip /// frame (faded) while a clip is active, else the animation frame, null /// (transparent) when idle. public VideoFrame? RenderFrame(Source alertBox) { if (_current == null) { _frameKey = null; return null; } if (_clip != null) return RenderClipFrame(alertBox); 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); } /// The global ticker strip for the stream output (top edge, full /// master width): a marquee of "viewer — event" while an alert is playing /// and the config has the ticker on, else null. Pure byte-math per call so /// the frame pump can read it on any thread (the text strip is rasterized /// once on the UI thread and cached). public VideoFrame? AlertTickerFrame { get { if (_current == null || string.IsNullOrWhiteSpace(_tickerText)) return null; return AlertTickerRenderer.Render(_tickerText!, _elapsed); } } private VideoFrame? RenderClipFrame(Source alertBox) { var frame = _latestClipFrame; if (frame == null) return null; var w = (int)Math.Max(1, alertBox.Width); var h = (int)Math.Max(1, alertBox.Height); if (frame.Width != w || frame.Height != h) frame = StretchMath.BilinearScale(frame, w, h); var fade = FadeAt(); if (fade >= 1.0) return frame; // Straight-alpha source-over on the compositor side: scale the source // alpha by the envelope so the clip blends out (a 680x200 copy per fade // tick is trivial; outside the ramps fade==1 returns the raw frame). var copy = new byte[frame.BgraPixels.Length]; Buffer.BlockCopy(frame.BgraPixels, 0, copy, 0, copy.Length); var a = (byte)(fade * 255 + 0.5); for (var i = 3; i < copy.Length; i += 4) copy[i] = (byte)((copy[i] * a) >> 8); return new VideoFrame(frame.Width, frame.Height, copy); } private double FadeAt() { if (_clip == null) return 1.0; if (_clipEof) return Math.Clamp(1.0 - (_elapsed - _clipEofElapsed) / FadeDurationSeconds, 0.0, 1.0); return Math.Clamp(_elapsed / FadeDurationSeconds, 0.0, 1.0); } private void BeginClip() { StopClip(); _tickerText = null; if (_current == null) return; var box = _scenes?.SelectMany(s => s.Elements).OfType() .FirstOrDefault(s => s.Type == SourceType.AlertBox); _currentBox = box; if (box != null && box.AlertShowTicker) _tickerText = ComposeTicker(_current); if (box == null || _clipDecoderFactory == null) return; var path = _clipPathResolver?.Invoke(box); if (string.IsNullOrWhiteSpace(path)) return; _clipWidth = (int)Math.Max(1, box.Width); _clipHeight = (int)Math.Max(1, box.Height); try { _clip = _clipDecoderFactory(path, _clipWidth, _clipHeight); _clip.FrameAvailable += OnClipFrame; _clip.AudioReady += OnClipAudio; _clip.Completed += OnClipCompleted; _clip.Start(); } catch { StopClip(); // decode setup failed (bad path) — fall back to animations } } private void StopClip() { var clip = _clip; _clip = null; _latestClipFrame = null; _clipEof = false; if (clip == null) return; clip.FrameAvailable -= OnClipFrame; clip.AudioReady -= OnClipAudio; clip.Completed -= OnClipCompleted; try { clip.Stop(); } catch { /* already stopped */ } clip.Dispose(); } private void OnClipFrame(VideoFrame frame) => _latestClipFrame = frame; private void OnClipCompleted() { _clipEof = true; _clipEofElapsed = _elapsed; } private void OnClipAudio(AudioSample sample) { if (_audioSink == null) return; var volume = (float)Math.Clamp(_currentBox?.AlertVideoVolume ?? 1.0, 0.0, 1.0); if (volume <= 0f) return; var fade = FadeAt(); if (fade <= 0.0) return; var data = sample.Samples; if (fade < 1.0 || Math.Abs(volume - 1f) > 0.001f) { data = new float[data.Length]; var gain = volume * (float)fade; for (var i = 0; i < sample.Samples.Length; i++) data[i] = sample.Samples[i] * gain; } _audioSink(new AudioSample(data, sample.SampleRate, sample.Channels)); } private void AdvanceToNext() { if (_queue.Count > 0) { _current = _queue.Dequeue(); _elapsed = 0; BeginClip(); } else { _ticker?.Stop(); } } 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, }; private static string ComposeTicker(ChatMessage m) { var label = m.Kind switch { ChatEventKind.SuperChat => "Super Chat", ChatEventKind.SuperSticker => "Super Sticker", ChatEventKind.NewMember => "New Member", ChatEventKind.MemberGift => "Membership Gift", ChatEventKind.GiftReceived => "Gifted Membership", ChatEventKind.MemberMilestone => "Member Milestone", _ => "Stream Alert", }; var amount = m.Kind is ChatEventKind.SuperChat or ChatEventKind.SuperSticker && m.SuperChatDisplayString is { Length: > 0 } s ? $" · {s}" : ""; return $"{m.AuthorName} — {label}{amount}"; } /// 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; StopClip(); } }