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

237 lines
8.9 KiB
C#

using System;
using System.Threading.Tasks;
using System.Windows.Input;
using ytLive.Helpers;
using ytLive.Models;
namespace ytLive.Services;
/// <summary>
/// TASK 41 — the Test Stream pull-out (the "TEST" tab at the bottom of the Live
/// screen's right rail). Drives a private test broadcast's chat tooling:
/// Mock Chat Input sends a REAL liveChat/messages.insert (round-tripping through
/// the normal ~2s poll and the live overlay path), and the simulated-event buttons
/// inject synthetic member / SuperChat / subscriber rows through the exact
/// MessageReceived seam the real poll uses — local-only, never sent to YouTube
/// (the insert API only creates text). The broadcast itself runs through the real
/// private go-live pipeline; this VM only owns the dock.
/// </summary>
public sealed class TestSessionViewModel : ViewModelBase
{
private readonly YouTubeStreamService _stream;
private readonly YouTubeChatService _chat;
private readonly Action? _onEndStream;
private bool _isDrawerOpen;
private bool _isTestSession;
private string? _broadcastId;
private string? _liveChatId;
private string _sendMessageText = string.Empty;
private bool _isSending;
private string? _lastEventText;
private int _eventCounter;
/// <summary>Fake viewer names for simulated events — several backs make the
/// rows look like a real muted room.</summary>
private static readonly string[] SimulatedViewers = { "PixelPasta", "NightskyNick", "LunaGlow", "CactusFox" };
public TestSessionViewModel(
YouTubeStreamService stream,
YouTubeChatService chat,
Action? onEndStream = null)
{
_stream = stream;
_chat = chat;
_onEndStream = onEndStream;
SendTestMessageCommand = new RelayCommand(_ => _ = SendTestMessageAsync(), _ => CanSendTestMessage);
SimulateSubscriberCommand = new RelayCommand(_ => Simulate(SimulatedSubscriber()));
SimulateMemberCommand = new RelayCommand(_ => Simulate(SimulatedMember()));
SimulateSuperChatCommand = new RelayCommand(_ => Simulate(SimulatedSuperChat()));
EndTestCommand = new RelayCommand(_ => _onEndStream?.Invoke());
ToggleDrawerCommand = new RelayCommand(_ => IsDrawerOpen = !IsDrawerOpen);
CloseDrawerCommand = new RelayCommand(_ => IsDrawerOpen = false);
}
public ICommand SendTestMessageCommand { get; }
public RelayCommand SimulateSubscriberCommand { get; }
public RelayCommand SimulateMemberCommand { get; }
public RelayCommand SimulateSuperChatCommand { get; }
public RelayCommand EndTestCommand { get; }
public RelayCommand ToggleDrawerCommand { get; }
public RelayCommand CloseDrawerCommand { get; }
public bool IsDrawerOpen
{
get => _isDrawerOpen;
set => SetProperty(ref _isDrawerOpen, value);
}
/// <summary>Whether a test broadcast is currently live (ended by BeginTest /
/// EndTestSilently; EndTestCommand asks the app to stop, which routes back here).</summary>
public bool IsTestSession
{
get => _isTestSession;
private set
{
if (SetProperty(ref _isTestSession, value))
{
OnPropertyChanged(nameof(SessionCaption));
OnPropertyChanged(nameof(ChatIdLine));
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
}
public string? BroadcastId => _broadcastId;
public string ChatIdLine => _liveChatId == null ? "liveChatId: —" : $"liveChatId: {_liveChatId}";
public string SessionCaption => IsTestSession
? "Test stream is live — private to you, zero notifications, real chat."
: "No test in progress — hit the Test button in the top bar to start a private broadcast and check your chat overlay.";
public string SendMessageText
{
get => _sendMessageText;
set
{
if (SetProperty(ref _sendMessageText, value))
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
public bool IsSending
{
get => _isSending;
private set
{
if (SetProperty(ref _isSending, value))
OnPropertyChanged(nameof(CanSendTestMessage));
}
}
public bool CanSendTestMessage => IsTestSession && !IsSending && _liveChatId != null
&& !string.IsNullOrWhiteSpace(SendMessageText);
/// <summary>Feedback line for the last send / simulated event (null = nothing yet).</summary>
public string? LastEventText
{
get => _lastEventText;
private set => SetProperty(ref _lastEventText, value);
}
/// <summary>Called once by MainViewModel when the test broadcast is live and its
/// liveChatId resolved — opens the dock so the chat tools are immediately usable.</summary>
public void BeginTest(string broadcastId, string? liveChatId)
{
_broadcastId = broadcastId;
_liveChatId = string.IsNullOrWhiteSpace(liveChatId) ? null : liveChatId;
SendMessageText = string.Empty;
LastEventText = _liveChatId == null
? "Chat polling couldn't start — Mock Chat Input is disabled."
: null;
IsTestSession = true;
IsDrawerOpen = true;
}
/// <summary>Called by MainViewModel's stop path when the broadcast ends — the
/// dock forgets the ended session (idempotent).</summary>
public void EndTestSilently()
{
_broadcastId = null;
_liveChatId = null;
IsTestSession = false;
IsSending = false;
}
/// <summary>Mock Chat Input: real liveChat/messages.insert. The inserted text
/// arrives through the normal poll and renders via the live overlay path — the
/// whole point is that the round-trip is the real one.</summary>
public async Task SendTestMessageAsync()
{
if (!CanSendTestMessage || _liveChatId == null) return;
var text = SendMessageText.Trim();
IsSending = true;
SendMessageText = string.Empty;
LastEventText = null;
try
{
var error = await _stream.InsertChatMessageAsync(_liveChatId, text);
LastEventText = error == null
? $"Sent — “{text}” should appear on the chat overlay within a couple of seconds."
: $"Couldn't send: {error}";
}
catch (Exception ex)
{
AppLog.Write($"Test chat message send failed: {ex.Message}");
LastEventText = "Couldn't send that message — try again.";
}
finally
{
IsSending = false;
}
}
private void Simulate(ChatMessage message)
{
if (!IsTestSession) return;
_chat.InjectSimulatedMessage(message);
_eventCounter++;
LastEventText = $"Fired #{_eventCounter}: {Describe(message)} — renders on any visible chat overlay.";
AppLog.Write($"Test chat simulation {_eventCounter} injected");
}
/// <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.
/// 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();
return NewSimulated(author, "New subscriber — welcome to the herd! 🦙 (simulated subscribe)");
}
private ChatMessage SimulatedMember()
{
var author = NextViewer();
var message = NewSimulated(author, $"🎉 {author} became a member!");
message.IsMember = true;
message.MembershipLevel = "New member";
message.Kind = ChatEventKind.NewMember;
return message;
}
private ChatMessage SimulatedSuperChat()
{
var author = NextViewer();
var message = NewSimulated(author, "Keep the streams flowing, llama power 🦙");
message.IsSuperChat = true;
message.SuperChatAmount = 5.00;
message.SuperChatCurrency = "$";
message.SuperChatDisplayString = "$5.00";
message.Kind = ChatEventKind.SuperChat;
return message;
}
private ChatMessage NewSimulated(string author, string text)
{
return new ChatMessage
{
AuthorName = author,
Message = text,
Timestamp = DateTime.Now,
IsSimulated = true,
};
}
private string NextViewer() => SimulatedViewers[_eventCounter % SimulatedViewers.Length];
private static string Describe(ChatMessage m)
{
if (m.IsSuperChat) return $"{m.AuthorName} pledged {m.SuperChatCurrency}{m.SuperChatAmount:0.00} in a Super Chat";
if (m.IsMember) return $"{m.AuthorName} just became a member ({m.MembershipLevel})";
return $"{m.AuthorName} subscribed";
}
}