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
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,9 @@ public sealed class TestSessionViewModel : ViewModelBase
|
||||
|
||||
/// <summary>Subscriptions aren't chat events on YouTube, so a "subscriber" row
|
||||
/// is a plain text-shaped message carrying the count — honest: the chat overlay
|
||||
/// shows it as a regular row, which is exactly what a sub looks like in chat.</summary>
|
||||
/// shows it as a regular row, which is exactly what a sub looks like in chat.
|
||||
/// It carries <see cref="ChatEventKind.None"/> on purpose: no alert animation
|
||||
/// fires for free subs — the chat mention is the announcement (creator ruling).</summary>
|
||||
private ChatMessage SimulatedSubscriber()
|
||||
{
|
||||
var author = NextViewer();
|
||||
@@ -197,6 +199,7 @@ public sealed class TestSessionViewModel : ViewModelBase
|
||||
var message = NewSimulated(author, $"🎉 {author} became a member!");
|
||||
message.IsMember = true;
|
||||
message.MembershipLevel = "New member";
|
||||
message.Kind = ChatEventKind.NewMember;
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -207,6 +210,8 @@ public sealed class TestSessionViewModel : ViewModelBase
|
||||
message.IsSuperChat = true;
|
||||
message.SuperChatAmount = 5.00;
|
||||
message.SuperChatCurrency = "$";
|
||||
message.SuperChatDisplayString = "$5.00";
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
return message;
|
||||
}
|
||||
|
||||
|
||||
+188
-69
@@ -6,16 +6,22 @@ namespace ytLive.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Polls YouTube Live Chat API for messages.
|
||||
/// YouTube doesn't have WebSocket for chat — polling is the only option.
|
||||
/// YouTube doesn't have WebSocket for chat — polling is the only option. The
|
||||
/// cadence follows the server's <c>pollingIntervalMillis</c> (the streamList
|
||||
/// connection semantics: reconnect with nextPageToken on that cadence, never
|
||||
/// faster than the refresh rate — YouTube rejects requests sent too quickly).
|
||||
/// The chat feed IS YouTube's event feed: Super Chats, stickers, memberships,
|
||||
/// gifts and milestones arrive here as typed messages (see <see cref="ChatEventKind"/>).
|
||||
/// </summary>
|
||||
public class YouTubeChatService : IDisposable
|
||||
{
|
||||
private readonly YouTubeAuthService _auth;
|
||||
private readonly HttpClient _http = new();
|
||||
private readonly HttpClient _http;
|
||||
private Timer? _pollTimer;
|
||||
private string? _nextPageToken;
|
||||
private string? _liveChatId;
|
||||
private bool _isRunning;
|
||||
private int _intervalMs = 2000;
|
||||
|
||||
public event Action<ChatMessage>? MessageReceived;
|
||||
|
||||
@@ -24,9 +30,10 @@ public class YouTubeChatService : IDisposable
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
public YouTubeChatService(YouTubeAuthService auth)
|
||||
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
|
||||
{
|
||||
_auth = auth;
|
||||
_http = http ?? new HttpClient();
|
||||
}
|
||||
|
||||
public void Start(string liveChatId, int pollIntervalMs = 2000)
|
||||
@@ -34,7 +41,9 @@ public class YouTubeChatService : IDisposable
|
||||
_liveChatId = liveChatId;
|
||||
_isRunning = true;
|
||||
_nextPageToken = null;
|
||||
_pollTimer = new(async _ => await Poll(), null, 0, pollIntervalMs);
|
||||
_intervalMs = Math.Clamp(pollIntervalMs, 1000, 6000);
|
||||
_pollTimer = new Timer(async _ => await Poll(), null, Timeout.Infinite, Timeout.Infinite);
|
||||
_pollTimer.Change(0, Timeout.Infinite);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
@@ -54,14 +63,14 @@ public class YouTubeChatService : IDisposable
|
||||
|
||||
private async Task Poll()
|
||||
{
|
||||
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
if (!_isRunning || _liveChatId == null || _auth.CurrentChannel == null) return;
|
||||
|
||||
if (_auth.CurrentChannel.TokenExpiry <= DateTime.UtcNow.AddMinutes(5))
|
||||
await _auth.RefreshToken();
|
||||
|
||||
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=50";
|
||||
var url = $"https://www.googleapis.com/youtube/v3/liveChat/messages?liveChatId={_liveChatId}&part=snippet,authorDetails&maxResults=2000";
|
||||
if (_nextPageToken != null)
|
||||
url += $"&pageToken={_nextPageToken}";
|
||||
|
||||
@@ -70,76 +79,182 @@ public class YouTubeChatService : IDisposable
|
||||
if (!response.IsSuccessStatusCode) return;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
var page = ParsePage(json);
|
||||
|
||||
if (data.TryGetProperty("nextPageToken", out var token))
|
||||
_nextPageToken = token.GetString();
|
||||
|
||||
if (!data.TryGetProperty("items", out var items)) return;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var author = item.GetProperty("authorDetails");
|
||||
var type = snippet.GetProperty("type").GetString() ?? "textMessageEvent";
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Id = item.GetProperty("id").GetString()!,
|
||||
AuthorName = author.GetProperty("displayName").GetString()!,
|
||||
AuthorChannelId = author.GetProperty("channelId").GetString()!,
|
||||
AuthorImageUrl = author.GetProperty("profileImageUrl").GetString()!,
|
||||
Timestamp = DateTime.Parse(snippet.GetProperty("publishedAt").GetString()!),
|
||||
IsMember = author.GetProperty("isChatSponsor").GetBoolean(),
|
||||
MembershipLevel = author.TryGetProperty("badgeText", out var badgeText)
|
||||
? badgeText.GetString()
|
||||
: null
|
||||
};
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "superChatEvent":
|
||||
message.IsSuperChat = true;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
message.SuperChatAmount = scDetails.TryGetProperty("amountMicros", out var amt)
|
||||
? amt.GetDouble() / 1_000_000.0
|
||||
: 0;
|
||||
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = scDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "newSponsorEvent":
|
||||
message.Message = $"🎉 {message.AuthorName} became a member!";
|
||||
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
|
||||
{
|
||||
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
|
||||
? level.GetString()
|
||||
: message.MembershipLevel;
|
||||
}
|
||||
break;
|
||||
|
||||
default: // textMessageEvent
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
|
||||
? textDetails.TryGetProperty("messageText", out var text)
|
||||
? text.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
break;
|
||||
}
|
||||
if (page.NextPageToken != null)
|
||||
_nextPageToken = page.NextPageToken;
|
||||
if (page.PollIntervalMs > 0 && page.PollIntervalMs != _intervalMs)
|
||||
_intervalMs = page.PollIntervalMs;
|
||||
|
||||
foreach (var message in page.Messages)
|
||||
MessageReceived?.Invoke(message);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
System.Diagnostics.Debug.WriteLine($"Chat poll error: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Re-arm the one-shot poll on the server's cadence. Guarded by
|
||||
// _isRunning so a concurrent Stop() (dispose + null) can't race a
|
||||
// Change() back onto the disposed timer.
|
||||
if (_isRunning)
|
||||
_pollTimer?.Change(_intervalMs, Timeout.Infinite);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One liveChat/messages page → typed chat messages + pagination +
|
||||
/// the server's poll cadence. Internal + separated from the HTTP so the six
|
||||
/// event parses are deterministically testable without any network.</summary>
|
||||
internal static ChatPage ParsePage(string json)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
var nextPageToken = data.TryGetProperty("nextPageToken", out var token)
|
||||
? token.GetString()
|
||||
: null;
|
||||
|
||||
var intervalMs = data.TryGetProperty("pollingIntervalMillis", out var interval)
|
||||
&& interval.ValueKind == JsonValueKind.Number
|
||||
? interval.GetInt32()
|
||||
: 0;
|
||||
|
||||
if (!data.TryGetProperty("items", out var items)) return new ChatPage(messages, nextPageToken, intervalMs);
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var snippet = item.GetProperty("snippet");
|
||||
var type = snippet.TryGetProperty("type", out var t) && t.ValueKind == JsonValueKind.String
|
||||
? t.GetString()
|
||||
: "textMessageEvent";
|
||||
|
||||
var message = new ChatMessage
|
||||
{
|
||||
Id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() ?? Guid.NewGuid().ToString() : Guid.NewGuid().ToString(),
|
||||
Timestamp = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
if (item.TryGetProperty("authorDetails", out var author))
|
||||
{
|
||||
message.AuthorName = author.TryGetProperty("displayName", out var dn) ? dn.GetString() ?? string.Empty : string.Empty;
|
||||
message.AuthorChannelId = author.TryGetProperty("channelId", out var cid) ? cid.GetString() ?? string.Empty : string.Empty;
|
||||
message.AuthorImageUrl = author.TryGetProperty("profileImageUrl", out var im) ? im.GetString() ?? string.Empty : string.Empty;
|
||||
message.IsMember = author.TryGetProperty("isChatSponsor", out var sp) ? sp.GetBoolean() : false;
|
||||
message.MembershipLevel = author.TryGetProperty("badgeText", out var badge)
|
||||
? badge.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
if (snippet.TryGetProperty("publishedAt", out var published)
|
||||
&& DateTime.TryParse(published.GetString(), out var publishedAt))
|
||||
message.Timestamp = publishedAt;
|
||||
|
||||
switch (type)
|
||||
{
|
||||
case "superChatEvent":
|
||||
message.IsSuperChat = true;
|
||||
message.Kind = ChatEventKind.SuperChat;
|
||||
if (snippet.TryGetProperty("superChatDetails", out var scDetails))
|
||||
{
|
||||
if (scDetails.TryGetProperty("displayString", out var display)
|
||||
&& display.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = display.GetString();
|
||||
if (scDetails.TryGetProperty("amountMicros", out var amt)
|
||||
&& amt.ValueKind == JsonValueKind.Number)
|
||||
message.SuperChatAmount = amt.GetDouble() / 1_000_000.0;
|
||||
message.SuperChatCurrency = scDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = scDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "superStickerEvent":
|
||||
message.Kind = ChatEventKind.SuperSticker;
|
||||
if (snippet.TryGetProperty("superStickerDetails", out var stickerDetails))
|
||||
{
|
||||
message.SuperStickerDescription = stickerDetails.TryGetProperty("stickerDescription", out var desc)
|
||||
? desc.GetString()
|
||||
: stickerDetails.TryGetProperty("description", out var descAlt)
|
||||
? descAlt.GetString()
|
||||
: null;
|
||||
if (stickerDetails.TryGetProperty("displayString", out var display)
|
||||
&& display.ValueKind == JsonValueKind.String)
|
||||
message.SuperChatDisplayString = display.GetString();
|
||||
message.SuperChatCurrency = stickerDetails.TryGetProperty("currency", out var cur)
|
||||
? cur.GetString()
|
||||
: null;
|
||||
message.Message = stickerDetails.TryGetProperty("userComment", out var comment)
|
||||
? comment.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
}
|
||||
break;
|
||||
|
||||
case "newSponsorEvent":
|
||||
message.Kind = ChatEventKind.NewMember;
|
||||
message.IsMember = true;
|
||||
message.Message = $"🎉 {message.AuthorName} became a member!";
|
||||
if (snippet.TryGetProperty("membershipDetails", out var memDetails))
|
||||
{
|
||||
message.MembershipLevel = memDetails.TryGetProperty("memberLevelName", out var level)
|
||||
? level.GetString()
|
||||
: message.MembershipLevel;
|
||||
}
|
||||
break;
|
||||
|
||||
case "membershipGiftingEvent":
|
||||
message.Kind = ChatEventKind.MemberGift;
|
||||
if (snippet.TryGetProperty("membershipGiftingDetails", out var giftingDetails)
|
||||
&& giftingDetails.TryGetProperty("giftNewMembersCount", out var count)
|
||||
&& count.ValueKind == JsonValueKind.Number)
|
||||
message.GiftNewMembersCount = count.GetInt32();
|
||||
message.Message = message.GiftNewMembersCount > 0
|
||||
? $"🎁 {message.AuthorName} gifted {message.GiftNewMembersCount} memberships!"
|
||||
: $"🎁 {message.AuthorName} gifted memberships!";
|
||||
break;
|
||||
|
||||
case "giftMembershipReceivedEvent":
|
||||
message.Kind = ChatEventKind.GiftReceived;
|
||||
if (snippet.TryGetProperty("giftMembershipReceivedDetails", out var giftReceived)
|
||||
&& giftReceived.TryGetProperty("gifterName", out var gifter))
|
||||
message.GifterName = gifter.GetString();
|
||||
message.Message = message.GifterName != null
|
||||
? $"{message.GifterName} gifted you a membership! 🎉"
|
||||
: $"{message.AuthorName} received a membership gift! 🎉";
|
||||
break;
|
||||
|
||||
case "memberMilestoneChatEvent":
|
||||
message.Kind = ChatEventKind.MemberMilestone;
|
||||
message.IsMember = true;
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var msd)
|
||||
? msd.TryGetProperty("messageText", out var mst)
|
||||
? mst.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
if (message.Message.Length > 0)
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(message.Message, @"(\d+)\s*month", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var months))
|
||||
message.MilestoneMonths = months;
|
||||
}
|
||||
break;
|
||||
|
||||
default: // textMessageEvent and anything else — anonymous chat rows only.
|
||||
message.Message = snippet.TryGetProperty("textMessageDetails", out var textDetails)
|
||||
? textDetails.TryGetProperty("messageText", out var text)
|
||||
? text.GetString() ?? string.Empty
|
||||
: string.Empty
|
||||
: string.Empty;
|
||||
break;
|
||||
}
|
||||
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
return new ChatPage(messages, nextPageToken, intervalMs);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -148,3 +263,7 @@ public class YouTubeChatService : IDisposable
|
||||
_http.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Decoded liveChat/messages response: the messages + where to resume +
|
||||
/// how long to wait before reconnecting.</summary>
|
||||
internal sealed record ChatPage(List<ChatMessage> Messages, string? NextPageToken, int PollIntervalMs);
|
||||
|
||||
+5
-1
@@ -8,7 +8,11 @@ External-facing logic: YouTube API, persistence. See
|
||||
| `YouTubeAuthService.cs` | OAuth2 via Google: loopback callback (`http://localhost:8765/oauth2/callback`), token exchange, refresh, channel fetch. Constructor takes optional `HttpClient` + `sessionChanged` callback (test seam + save hook); session persists via `Helpers/TokenStore` (DPAPI); `ClearSession()` signs out (called by `MainViewModel.StopStream` on End Livestream) |
|
||||
| `YouTubeStreamService.cs` | Broadcast/stream management via the v3 API. **`CreateBroadcast` always sends `privacyStatus = "private"`** (ship step 7 — private-only until v1) and the one-click v3 flags (`enableAutoStart/Stop`, `enableMonitorStream=false`, `latencyPreference=low`); injectable `HttpClient? http = null` ctor seam so tests can fake the API. **`GetOrCreateReusableStreamAsync` (TASK 9, shipped 2026-08-16)** returns the channel's `ReusableStream` — lists `liveStreams?mine=true` and reuses the existing `cdn.isReusable` stream, inserting once only on first use (`resolution=variable`, `frameRate=variable`, `isReusable=true`); a stream id given to `CreateBroadcast` binds at insert via `contentDetails.boundStreamId`. RTMP URL = `ingestionAddress + "/" + streamName`. **`GetStreamHealthAsync(streamId)` (TASK 9 item 3, shipped 2026-08-16)** polls `liveStreams?part=status` → `StreamHealth` with parsed `configurationIssues[]` (NOTE 2026-09-22: the real API nests `status.healthStatus = {status, lastUpdateTimeSeconds, configurationIssues[]}` as an OBJECT — the parse reads the nested `.status` and tolerates the old flat-string shape; the object shape used to crash every poll) | **`EndBroadcastAsync` (2026-09-01; hardened 2026-09-22)** posts `liveBroadcasts/transition?broadcastStatus=complete&part=status` AFTER RTMP EOF with a pre-flight `liveBroadcasts.list` read of the broadcast's OWN `lifeCycleStatus`: only transitions from `live`/`testing`, skips already-complete broadcasts silently (2026-09-22: every blind end logged `invalidTransition` — autoStop races us to complete); `enableAutoStop` finishes every skip |
|
||||
| `StreamHealthReporter.cs` | Pure report-by-exception decision (TASK 9 item 3): `BannerFor(issues)` → `HealthIssueReport(Text?, IsError)` — null text on good/ok/noData/info-only, first warning/error issue produces its type text, error beats warning for color |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable` |
|
||||
| `YouTubeChatService.cs` | Polls `liveChat/messages`, raises `MessageReceived`; `IDisposable`. **TASK 43 rewrite (2026-09-24):** re-arms its one-shot poll on the server's `pollingIntervalMillis` (streamList semantics — reconnect on that cadence with `nextPageToken`, clamp 1000–6000ms, `maxResults=2000`); `ParsePage` (internal static seam) decodes **all six** event types into `ChatMessage.Kind`/fields (superChat / superSticker / newSponsor / membershipGifting / giftMembershipReceived / memberMilestoneChat + milestone months best-effort regex); optional `HttpClient` ctor seam endures; `InjectSimulatedMessage` (TASK 41) preserved |
|
||||
| `ChatBoxRenderer.cs` | Chat overlay rasterizer (WPF FormattedText → BGRA `VideoFrame`), live rows + Super Chat/member styling |
|
||||
| `ChatOverlayLayer.cs` | **The chat component (Commit G):** owns the message buffer, `ChatBoxRenderer`, fade/mock timers, per-source preview renders, live `RenderFrame`; raster-on-change cache pattern (OBS text-source contract) |
|
||||
| `AlertRenderer.cs` | **The alert rasterizer (TASK 43, 2026-09-24):** renders one event's celebration animation into a BGRA `VideoFrame` — SIX DISTINCT animations (SuperChat slide-up/shine/count-up, SuperSticker scale-pop, NewMember drop-in/flash-band, MemberGift slide-left/chip-fan, GiftReceived confetti scale-in, MemberMilestone rise/growth-bar), enter/hold/exit phases, brand line on every card |
|
||||
| `AlertOverlayLayer.cs` | **The native alert-box component (TASK 43):** event queue (cap 10) + 33ms dispatch ticker + `RenderFrame` cache-first live path + `UpdatePreview`; **`Advance(double)` is the deterministic test clock** (tests never start the timer); `ChatEventKind.None` rows (sub mentions) never enqueue; idle = transparent (null frame) |
|
||||
| `LayoutStore.cs` | SQLite persistence (`Microsoft.Data.Sqlite`) at `%APPDATA%\ytLlive\ytLlive.db`; assets stored as BLOBs keyed by SHA-256 content hash; save/open layout files; schema `user_version` 6 (`Source.ClipShape`/`IsMirrored` via `ALTER TABLE` for pre-v2 DBs; v3 = singleton `Webcam` + per-scene `WebcamSceneConfig`, migrated idempotently **without backfill** — the stale `Source.DeviceId` column remains but is no longer read/written; v4 = `WebcamSceneConfig.RectWidth`/`RectHeight`, the pre-Round rect for the round-to-rect restore; v5 = `Source.IsBackdrop` + `Source.CaptureKey`, the live-capture backdrop; v6 = `Scene.HasBackdrop` — Live-only policy, one-time backfill turns Starting/BRB/Chat/Ending off + drops their backdrop sources; `MainViewModel.NormalizeBackgrounds` re-normalizes on every load — TASK 25: one locked "Background" per canonical screen (static art everywhere, DisplayCapture on Live), duplicates dropped, index 0, orphaned `BackgroundUseDefault_*`/`BackgroundPath_*` Settings keys purged at save). Settings key/value table holds app state: `MicSourceName`, `ReusableStream` (TASK 9 — the cached reusable stream's id/address/name so the pump has its RTMP URL at startup) |
|
||||
| `VideoFrame.cs` | Normalized CPU frame seam (`Width`/`Height`/tightly-packed BGRA `byte[]`) — the only pixel type the rest of the app knows about; future capture sources (screen, background-removed webcam) feed the same seam |
|
||||
| `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device |
|
||||
|
||||
Reference in New Issue
Block a user