Files
LlamaCasty/Services/YouTubeChatService.cs
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

270 lines
12 KiB
C#

using System.Net.Http;
using System.Text.Json;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// Polls YouTube Live Chat API for messages.
/// 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;
private Timer? _pollTimer;
private string? _nextPageToken;
private string? _liveChatId;
private bool _isRunning;
private int _intervalMs = 2000;
public event Action<ChatMessage>? MessageReceived;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true
};
public YouTubeChatService(YouTubeAuthService auth, HttpClient? http = null)
{
_auth = auth;
_http = http ?? new HttpClient();
}
public void Start(string liveChatId, int pollIntervalMs = 2000)
{
_liveChatId = liveChatId;
_isRunning = true;
_nextPageToken = null;
_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()
{
_isRunning = false;
_pollTimer?.Dispose();
_pollTimer = null;
}
/// <summary>TASK 41: injects a synthetic message through the exact seam the real
/// poll uses (MessageReceived), so a simulated member / SuperChat / subscriber
/// event renders through the whole overlay pipeline. YouTube's insert API only
/// creates text messages — these events are local-only by design and never
/// reach YouTube (marked <see cref="ChatMessage.IsSimulated"/>).</summary>
public void InjectSimulatedMessage(ChatMessage message)
=> MessageReceived?.Invoke(message);
private async Task Poll()
{
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=2000";
if (_nextPageToken != null)
url += $"&pageToken={_nextPageToken}";
_http.DefaultRequestHeaders.Authorization = new("Bearer", _auth.CurrentChannel.AccessToken);
var response = await _http.GetAsync(url);
if (!response.IsSuccessStatusCode) return;
var json = await response.Content.ReadAsStringAsync();
var page = ParsePage(json);
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()
{
Stop();
_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);