d4aa588da0
Replace the external StreamElements feed with NATIVE YouTube events per the creator's 2026-09-23 question. Chat parity half: - YouTubeChatService decodes ALL SIX liveChat/messages event types into ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/ giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay rows are real now — and re-arms its one-shot poll on the server's pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms, maxResults=2000). ParsePage internal static seam + ChatPage record for deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage (TASK 41) preserved. Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no menus/polls, sub mention = chat row only, NO viewer count): - New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate mirroring chat; idle = transparent). - AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/ count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/ chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card drawing the 'made with LlamaCasty!' brand line. - AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview, Advance(double) as the deterministic test clock; ChatEventKind.None rows never enqueue. - Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews, Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged (member->NewMember, superchat->SuperChat). Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) — ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing; six events play pairwise-distinct moving frames then drain to null. Gate: clean build 0 warnings; full suite 316/317, the one failure (LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the known environmental class (real-mouse-drag no-ops with a game/fullscreen window focused). References (OBS/overlay ecosystem): - streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList - OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
231 lines
8.0 KiB
C#
231 lines
8.0 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The native alert box (OBS-style celebration zone, no third party). Owns the
|
|
/// event queue and the animation clock: <see cref="ChatEventKind"/> events from
|
|
/// the chat feed are enqueued one-at-a-time and played through their own unique
|
|
/// animation (<see cref="AlertRenderer"/>), frames served to the stream compositor
|
|
/// via <see cref="RenderFrame"/> and mirrored to the preview via
|
|
/// <see cref="UpdatePreview"/>. Idle = transparent (null frame); playback is
|
|
/// advanced by a 33ms dispatch ticker. The clock is exposed as
|
|
/// <see cref="Advance"/> so tests drive playback deterministically without the
|
|
/// timer. Ordinary chat rows (<see cref="ChatEventKind.None"/>) never enqueue —
|
|
/// a subscriber mention is a chat row, not an alert.
|
|
/// </summary>
|
|
public sealed class AlertOverlayLayer : IDisposable
|
|
{
|
|
private readonly AlertRenderer _renderer;
|
|
private readonly Queue<ChatMessage> _queue = new();
|
|
private readonly DispatcherTimer? _ticker;
|
|
|
|
private ChatMessage? _current;
|
|
private double _elapsed;
|
|
private IEnumerable<Scene>? _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();
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public void OnMessageReceived(ChatMessage message, IEnumerable<Scene> scenes)
|
|
{
|
|
_scenes = scenes;
|
|
Enqueue(message);
|
|
_ticker?.Start();
|
|
RefreshAlertPreviews();
|
|
}
|
|
|
|
/// <summary>Queue an event (pure — also the test seam). Chat rows carry
|
|
/// <see cref="ChatEventKind.None"/> and are ignored: no alert for a mention.</summary>
|
|
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++;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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++;
|
|
}
|
|
|
|
/// <summary>Whether an alert is currently playing or queued.</summary>
|
|
public bool IsPlaying => _current != null || _queue.Count > 0;
|
|
|
|
/// <summary>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.</summary>
|
|
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,
|
|
};
|
|
|
|
/// <summary>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.</summary>
|
|
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}");
|
|
}
|
|
}
|
|
|
|
/// <summary>Wire property-change subscriptions for all AlertBox sources across
|
|
/// every scene (geometry changes re-render), and clear/reset their previews.</summary>
|
|
public void UpdateConfiguredBoxes(IEnumerable<Scene> scenes)
|
|
{
|
|
_scenes = scenes;
|
|
foreach (var scene in scenes)
|
|
foreach (var alertBox in scene.Elements.OfType<Source>().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<Source>()
|
|
.Where(s => s.Type == SourceType.AlertBox))
|
|
UpdatePreview(alertBox);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_ticker?.Stop();
|
|
_queue.Clear();
|
|
_current = null;
|
|
}
|
|
} |