Files
gramps d4aa588da0 feat(alerts): TASK 43 — native events & alerts (six unique animations) + chat parity
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
2026-09-24 08:27:00 -07:00

389 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Globalization;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Renders one event's celebration animation into a BGRA8 frame for the AlertBox.
/// Six event kinds, six UNIQUE animations — each a distinct gesture, palette and
/// layout (the creator's ruling: these are the monetization shout-outs and free
/// branded product placement; a one-template recoloring loses all interest in two
/// iterations). Each card carries the bottom-right brand line so every broadcast
/// of an event is an in-video ad placement.
///
/// <paramref name="t01"/> walks 0→1 over the whole animation; each kind splits it
/// into enter / hold / exit phases with its own easing.
/// </summary>
public sealed class AlertRenderer
{
private const double EnterEnd = 0.16;
private const double ExitStart = 0.80;
private const string Brand = "made with LlamaCasty!";
private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture;
private static readonly Color White = Colors.White;
private static readonly Color[] ConfettiPalette =
{
Color.FromRgb(0xf4, 0x2f, 0x5f), Color.FromRgb(0xff, 0xd1, 0x66),
Color.FromRgb(0x38, 0xb6, 0xf8), Color.FromRgb(0xa7, 0x8b, 0xfa),
Color.FromRgb(0x39, 0xd3, 0x53),
};
public VideoFrame Render(ChatMessage e, double t01, int width, int height)
{
return e.Kind switch
{
ChatEventKind.SuperChat => SuperChat(e, t01, width, height),
ChatEventKind.SuperSticker => SuperSticker(e, t01, width, height),
ChatEventKind.NewMember => NewMember(e, t01, width, height),
ChatEventKind.MemberGift => MemberGift(e, t01, width, height),
ChatEventKind.GiftReceived => GiftReceived(e, t01, width, height),
ChatEventKind.MemberMilestone => Milestone(e, t01, width, height),
_ => SuperChat(e, t01, width, height),
};
}
private static VideoFrame SuperChat(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.03;
var cardX = margin;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = cardX + cardW / 2;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var exitEase = EaseInCubic(exit);
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, exitEase);
DrawCard(ctx, cardX, cardY, cardW, cardH, "#0d3b2b", "#14532d", 14);
DrawBrand(ctx, cardX, cardY, cardW, cardH);
var shownAmount = e.SuperChatDisplayString
?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : $"${e.SuperChatAmount:0.00}");
var fontSize = cardH * 0.24;
DrawCentered(ctx, shownAmount, cardCX, cardY + cardH * 0.24, fontSize, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW);
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.56, cardH * 0.13, FontWeights.SemiBold, White, cardW);
DrawCentered(ctx, e.Message, cardCX, cardY + cardH * 0.74, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xdd, 0xff, 0xf0), cardW * 0.92);
if (t >= EnterEnd && t <= ExitStart)
{
var sweep = (t - EnterEnd) / (ExitStart - EnterEnd);
var shineX = cardX + (sweep * 2 - 0.5) * cardW;
ctx.DrawRectangle(
new LinearGradientBrush(
Colors.Transparent,
Color.FromArgb(0x30, 0xff, 0xff, 0xff),
new Point(0, 0),
new Point(1, 0)),
null,
new Rect(shineX - cardW * 0.25, cardY, cardW * 0.5, cardH));
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame SuperSticker(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = EaseOutBack(enter) * (0.85 + 0.15 * (1 - exit));
if (t > EnterEnd && t <= ExitStart)
scale = 1.0 + 0.02 * Math.Sin(t * 26.0);
var alpha = 1.0 - exit;
ctx.PushOpacity(alpha);
var slick = new RotateTransform(-6, w / 2.0, h / 2.0);
ctx.PushTransform(slick);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#4a044e", "#7e22ce", 20);
DrawBrand(ctx, margin, margin, cardW, cardH);
var desc = string.IsNullOrWhiteSpace(e.SuperStickerDescription) ? "SUPER STICKER" : e.SuperStickerDescription.ToUpperInvariant();
DrawCentered(ctx, desc, cardCX, h / 2.0 - cardH * 0.12, cardH * 0.17, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, e.AuthorName, cardCX, h / 2.0 + cardH * 0.16, cardH * 0.12, FontWeights.SemiBold, Color.FromRgb(0xff, 0xb3, 0xd9), cardW * 0.9);
var tier = e.SuperChatDisplayString ?? (e.SuperChatCurrency is { Length: > 0 } cur ? $"{cur}{e.SuperChatAmount:0.00}" : null);
if (!string.IsNullOrWhiteSpace(tier))
DrawCentered(ctx, tier, cardCX, h / 2.0 + cardH * 0.32, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
ctx.Pop();
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame NewMember(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(-cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, h, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#2e1065", "#5b21b6", 16);
DrawBrand(ctx, margin, cardY, cardW, cardH);
if (t <= ExitStart)
{
var bandP = EaseOutCubic(Clamp01(t / 0.22));
ctx.DrawRectangle(
new LinearGradientBrush(Color.FromArgb(0, 0xff, 0xff, 0xff), Colors.Transparent, new Point(0, 0), new Point(0, 1)),
null,
new Rect(margin, cardY + cardH * 0.30, cardW * bandP, cardH * 0.18));
}
DrawCentered(ctx, e.AuthorName, cardCX, cardY + cardH * 0.36, cardH * 0.22, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "JOINED THE MEMBERSHIP", cardCX, cardY + cardH * 0.62, cardH * 0.11, FontWeights.SemiBold, Color.FromRgb(0xf5, 0xd0, 0xfe), cardW * 0.9);
var level = string.IsNullOrWhiteSpace(e.MembershipLevel) ? "MEMBER" : e.MembershipLevel.ToUpperInvariant();
DrawCentered(ctx, level, cardCX, cardY + cardH * 0.80, cardH * 0.10, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame MemberGift(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.035;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardX = Lerp(-cardW, margin, EaseOutCubic(enter));
cardX = Lerp(cardX, -cardW, EaseInCubic(exit));
DrawCard(ctx, cardX, margin, cardW, cardH, "#422006", "#a16207", 16);
DrawBrand(ctx, cardX, margin, cardW, cardH);
var count = Math.Max(1, e.GiftNewMembersCount);
var spread = 1.0 - exit;
var hero = count > 1 ? $"×{count}" : "MEMBERSHIP";
DrawCentered(ctx, hero, cardCX, margin + cardH * 0.24, cardH * 0.26, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.8);
DrawCentered(ctx, $"{e.AuthorName} is gifting", cardCX, margin + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
var chipCount = Math.Min(count, 6);
for (var i = 0; i < chipCount; i++)
{
var slot = (chipCount - 1) * 0.5;
var gap = cardH * 0.13 * EaseOutCubic(Clamp01(t / 0.30)) * spread;
var chipX = cardCX + (i - slot) * gap;
var chipY = margin + cardH * 0.70 + Math.Sin((i + t * 6) * 2.4) * cardH * 0.015;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xf3, 0xc4)),
null,
new Rect(chipX - cardH * 0.05, chipY, cardH * 0.10, cardH * 0.10),
cardH * 0.03, cardH * 0.03);
}
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame GiftReceived(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / (EnterEnd * 1.4));
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var scale = 0.5 + 0.5 * EaseOutBack(enter);
var alpha = Math.Min(1.0, enter * 1.6) * (1 - exit);
ctx.PushOpacity(alpha);
ctx.PushTransform(new ScaleTransform(scale, scale, w / 2.0, h / 2.0));
DrawCard(ctx, margin, margin, cardW, cardH, "#14532d", "#4c1d95", 18);
DrawBrand(ctx, margin, margin, cardW, cardH);
// Confetti falling inside the card during the hold (deterministic seed).
if (t >= EnterEnd && t <= ExitStart)
{
var hold = (t - EnterEnd) / (ExitStart - EnterEnd);
for (var i = 0; i < 14; i++)
{
var seed = i * 37 + 11;
var dotX = margin + cardW * (((seed * 53) % 97) / 97.0);
var dotY = margin + cardH * (((seed + hold * 0.85) % 1.0));
var size = cardH * 0.02 + (i % 3) * cardH * 0.006;
ctx.DrawRectangle(
new SolidColorBrush(ConfettiPalette[i % ConfettiPalette.Length]),
null,
new Rect(dotX, dotY, size, size));
}
}
DrawCentered(ctx, "GIFTED MEMBERSHIP", cardCX, margin + cardH * 0.22, cardH * 0.12, FontWeights.Bold, Color.FromRgb(0xff, 0xd1, 0x66), cardW * 0.9);
var giver = string.IsNullOrWhiteSpace(e.GifterName) ? e.AuthorName : e.GifterName;
DrawCentered(ctx, giver, cardCX, margin + cardH * 0.42, cardH * 0.20, FontWeights.Bold, White, cardW * 0.9);
DrawCentered(ctx, "gave you a membership — celebrate!", cardCX, margin + cardH * 0.68, cardH * 0.11, FontWeights.Normal, Color.FromRgb(0xdb, 0xff, 0xf0), cardW * 0.9);
ctx.Pop();
ctx.Pop();
}
return CreateFrame(visual, w, h, dpi);
}
private static VideoFrame Milestone(ChatMessage e, double t, int w, int h)
{
var dpi = 96.0;
var visual = new DrawingVisual();
using (var ctx = visual.RenderOpen())
{
var margin = Math.Max(w, h) * 0.04;
var cardW = w - margin * 2;
var cardH = h - margin * 2;
var cardCX = w / 2.0;
var enter = Clamp01(t / EnterEnd);
var exit = Clamp01((t - ExitStart) / (1 - ExitStart));
var cardY = Lerp(h + cardH, margin, EaseOutCubic(enter));
cardY = Lerp(cardY, -cardH, EaseInCubic(exit));
DrawCard(ctx, margin, cardY, cardW, cardH, "#431407", "#c2410c", 14);
DrawBrand(ctx, margin, cardY, cardW, cardH);
var hero = e.MilestoneMonths > 0 ? $"{e.MilestoneMonths}" : "MILESTONE";
DrawCentered(ctx, hero, cardCX, cardY + cardH * 0.26, cardH * 0.30, FontWeights.Bold, Color.FromRgb(0xff, 0xdd, 0x66), cardW * 0.8);
var sub = e.MilestoneMonths > 0 ? "MONTHS STRONG" : "SUPPORTING THE STREAM";
DrawCentered(ctx, sub, cardCX, cardY + cardH * 0.52, cardH * 0.11, FontWeights.SemiBold, White, cardW * 0.9);
DrawCentered(ctx, "— " + e.AuthorName + " —", cardCX, cardY + cardH * 0.70, cardH * 0.10, FontWeights.Normal, Color.FromRgb(0xff, 0xe8, 0xd0), cardW * 0.9);
// Tenure bar: grows through the first half, retracts on exit.
var barP = Clamp01((t - 0.10) / 0.55) * (1 - exit);
var barW = cardW * 0.6 * barP;
ctx.DrawRoundedRectangle(
new SolidColorBrush(Color.FromRgb(0xff, 0xd1, 0x66)),
null,
new Rect(cardCX - barW / 2, cardY + cardH * 0.84, barW, cardH * 0.045),
4, 4);
}
return CreateFrame(visual, w, h, dpi);
}
// ─── Shared drawing ───
private static void DrawCard(DrawingContext ctx, double x, double y, double w, double h, string fromHex, string toHex, double radius)
{
var brush = new LinearGradientBrush(ParseColor(fromHex), ParseColor(toHex), new Point(0, 0), new Point(1, 1));
ctx.DrawRoundedRectangle(brush, new Pen(new SolidColorBrush(Color.FromArgb(0x66, 0xff, 0xff, 0xff)), Math.Max(1, radius * 0.06)), new Rect(x, y, w, h), radius, radius);
}
private static void DrawBrand(DrawingContext ctx, double x, double y, double w, double h)
{
var formatted = new FormattedText(
Brand, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, FontWeights.Normal, FontStretches.Normal),
Math.Max(9, h * 0.055),
new SolidColorBrush(Color.FromArgb(0x88, 0xff, 0xff, 0xff)),
96.0);
ctx.DrawText(formatted, new Point(x + w - formatted.Width - h * 0.08, y + h - formatted.Height - h * 0.05));
}
private static void DrawCentered(DrawingContext ctx, string text, double cx, double cy, double size, FontWeight weight, Color color, double maxWidth)
{
var formatted = new FormattedText(
text, Invariant, FlowDirection.LeftToRight,
new Typeface(new FontFamily("Segoe UI"), FontStyles.Normal, weight, FontStretches.Normal),
size,
new SolidColorBrush(color),
96.0);
formatted.MaxTextWidth = maxWidth;
formatted.MaxLineCount = 1;
formatted.Trimming = TextTrimming.CharacterEllipsis;
ctx.DrawText(formatted, new Point(cx - formatted.Width / 2, cy - formatted.Height / 2));
}
private static Color ParseColor(string hex)
{
hex = hex.Trim().TrimStart('#');
if (hex.Length == 6)
{
var r = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
return Color.FromRgb(r, g, b);
}
if (hex.Length == 8)
{
var a = byte.Parse(hex.AsSpan(0, 2), NumberStyles.HexNumber);
var r = byte.Parse(hex.AsSpan(2, 2), NumberStyles.HexNumber);
var g = byte.Parse(hex.AsSpan(4, 2), NumberStyles.HexNumber);
var b = byte.Parse(hex.AsSpan(6, 2), NumberStyles.HexNumber);
return Color.FromArgb(a, r, g, b);
}
return Colors.White;
}
private static VideoFrame CreateFrame(DrawingVisual visual, int width, int height, double dpi)
{
var rtb = new RenderTargetBitmap(width, height, dpi, dpi, PixelFormats.Pbgra32);
rtb.Render(visual);
var stride = width * 4;
var pixels = new byte[stride * height];
rtb.CopyPixels(pixels, stride, 0);
for (var i = 0; i < pixels.Length; i += 4)
{
(pixels[i], pixels[i + 2]) = (pixels[i + 2], pixels[i]);
}
return new VideoFrame(width, height, pixels);
}
private static double Clamp01(double v) => Math.Clamp(v, 0.0, 1.0);
private static double Lerp(double a, double b, double p) => a + (b - a) * p;
private static double EaseInCubic(double p) => p * p * p;
private static double EaseOutCubic(double p) => 1 - Math.Pow(1 - p, 3);
private static double EaseOutBack(double p)
{
const double c1 = 1.70158;
const double c3 = c1 + 1;
return 1 + c3 * Math.Pow(p - 1, 3) + c1 * Math.Pow(p - 1, 2);
}
}