d4aa588da0
Replace the external StreamElements feed with NATIVE YouTube events per the creator's 2026-09-23 question. Chat parity half: - YouTubeChatService decodes ALL SIX liveChat/messages event types into ChatMessage.Kind (superChat/superSticker/newSponsor/membershipGifting/ giftMembershipReceived/memberMilestoneChat) — the four formerly-empty overlay rows are real now — and re-arms its one-shot poll on the server's pollingIntervalMillis (streamList connection semantics; clamp 1000-6000ms, maxResults=2000). ParsePage internal static seam + ChatPage record for deterministic tests; optional HttpClient ctor seam kept; InjectSimulatedMessage (TASK 41) preserved. Alerts half (creator rulings: one celebration zone, six UNIQUE animations, no menus/polls, sub mention = chat row only, NO viewer count): - New SourceType.AlertBox 'Stream Alerts' (one per layout, CanAddAlerts gate mirroring chat; idle = transparent). - AlertRenderer: six distinct branded animations (SuperChat slide-up/shine/ count-up, SuperSticker scale-pop, NewMember drop-in/flash, MemberGift slide-left/ chip-fan, GiftReceived confetti, MemberMilestone rise/growth-bar), every card drawing the 'made with LlamaCasty!' brand line. - AlertOverlayLayer: true component (Commit-G pattern) — queue (cap 10, drop tail never stall), 33ms UI ticker, cache-first RenderFrame + UpdatePreview, Advance(double) as the deterministic test clock; ChatEventKind.None rows never enqueue. - Wired: resolver RenderAlertBox, _alertLayer ctor + dispose, LoadLayout previews, Add menu item (Controls/LeftPanel.xaml), TestSessionViewModel sims tagged (member->NewMember, superchat->SuperChat). Good Dog ONE integration test: AlertLayerTests (RealApp STA, real WPF raster) — ParsePage classifies all six kinds + cadence fields; None rows enqueue nothing; six events play pairwise-distinct moving frames then drain to null. Gate: clean build 0 warnings; full suite 316/317, the one failure (LayerReorderPersistenceTests.RealMouseDrag) repros on the clean tree — the known environmental class (real-mouse-drag no-ops with a game/fullscreen window focused). References (OBS/overlay ecosystem): - streamList semantics: https://developers.google.com/youtube/v3/live/docs/liveChatMessages/streamList - OBS alert-box pattern (designated celebration zone, idle transparent): creator-chosen model
270 lines
12 KiB
C#
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);
|