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