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:
+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);
|
||||
|
||||
Reference in New Issue
Block a user