feat(stream): TASK 41 Test Stream mode — private test broadcast + TEST drawer

Test button next to Start runs the real private-only go-live pipeline as a
session variant (IsTestStream): BeginTestStream -> extracted
StartStreamingSession(alsoRecord:false) shared with the dialog path — no Go
Live dialog, no 'last live' stamp, no recording. Gold top bar + TEST badge +
'End Test' button face; TEST drawer (third right-rail pull-out, three-way
exclusivity) auto-opens once the liveChatId resolves.

Chat tooling: Mock Chat Input sends a REAL liveChat/messages.insert
(YouTubeStreamService.InsertChatMessageAsync) that round-trips the real ~2s
poll and renders through the live overlay; simulated Subscriber/New
Member/Super Chat events inject through the poller's MessageReceived seam
(YouTubeChatService.InjectSimulatedMessage, ChatMessage.IsSimulated) — the
insert API only creates textMessageEvent, so non-text events are local-only
by design (ref: developers.google.com/youtube/v3/live/docs/liveChatMessages/insert).
Scopes youtube + youtube.force-ssl already cover insert; no re-consent.

Good Dog: ytLive.Tests/TestStreamTests.cs — coordinator protocol with fake
HTTP + real-window drawer exclusivity/gate. 0 warnings; 314/314 pass (clean
run; intermittent host abort is the pre-existing WinRT-webcam flake).

Docs ride in the same change: TASKS.md row 41, TASKS/task-41-test-stream-mode.md,
ai.md (YouTube Live API constraints), HANDOFF rewrite.
This commit is contained in:
2026-09-22 09:31:00 -07:00
parent 57841d39ac
commit bb5dcb4ba2
16 changed files with 893 additions and 85 deletions
@@ -76,39 +76,63 @@ public partial class MainViewModel : ViewModelBase
StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
if (window.ShowDialog() == true)
if (window.ShowDialog() != true) return;
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
StartStreamingSession(alsoRecord);
}
/// <summary>TASK 41 — Test stream: one click, no dialog. Every broadcast is
/// already private-only (ship step 7), so there is nothing to confirm; the test
/// session reuses the ENTIRE real go-live pipeline (reusable stream, broadcast
/// create, RTMP push, chat poll) with IsTestStream flipped so the face says TEST
/// and the Test drawer opens once the liveChatId resolves. No "last live" stamp:
/// a rehearsal isn't a broadcast the creator took on the record.</summary>
private void BeginTestStream()
{
if (!CanStartTest) return;
IsTestStream = true; // before StreamStatus so the visuals initialize gold
StreamTitle = BroadcastForm.Title;
StreamDescription = BroadcastForm.Description;
StreamVisibility = "Private";
StartStreamingSession(alsoRecord: false);
}
/// <summary>Boots the streaming face of ANY session (dialog go-live or test):
/// status streaming, audio live, timer reset, optional recording, then the async
/// broadcast preparation. The test path skips BroadcastForm.CaptureGoLive by
/// design and never records.</summary>
private void StartStreamingSession(bool alsoRecord)
{
WindowTitle = string.IsNullOrWhiteSpace(StreamTitle)
? "LlamaCasty"
: $"{StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
{
StreamTitle = dialog.StreamTitle;
StreamDescription = dialog.StreamDescription;
StreamVisibility = dialog.Visibility; // always "Private" (ship step 7)
BroadcastForm.CaptureGoLive(dialog.StreamTitle, dialog.StreamDescription, DateTime.UtcNow);
WindowTitle = string.IsNullOrWhiteSpace(dialog.StreamTitle)
? "LlamaCasty"
: $"{dialog.StreamTitle} — LlamaCasty";
StreamStatus = StreamStatus.Streaming;
ResetHealth(StreamStatus.Streaming);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
var recordStarted = false;
if (alsoRecord)
if (StartRecordFile() == null)
{
if (StartRecordFile() == null)
{
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
_notifications.Error("Couldn't record",
"No record output path could be created — streaming continues without recording.");
}
else
{
_recordStartTime = DateTime.Now;
recordStarted = true;
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
@@ -158,9 +182,10 @@ public partial class MainViewModel : ViewModelBase
// Start chat polling (TASK 9.4): fetch the liveChatId from the broadcast's
// contentDetails, then start the poll loop. Chat is non-fatal — if it fails,
// the stream continues without on-panel chat.
string? liveChatId = null;
try
{
var liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
@@ -175,6 +200,14 @@ public partial class MainViewModel : ViewModelBase
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}");
}
// TASK 41: a test broadcast hands its session to the Test drawer once
// the liveChatId resolves — Mock Chat Input + simulated events need it.
if (IsTestStream && _currentBroadcastId != null)
{
TestSession.BeginTest(_currentBroadcastId, liveChatId);
AppLog.Write($"Test session dock open for {_currentBroadcastId}");
}
}
catch (Exception ex)
{
@@ -244,6 +277,10 @@ public partial class MainViewModel : ViewModelBase
StreamStatus = StreamStatus.Offline;
IsRecording = false;
// TASK 41: a stopped test broadcast forgets its drawer session (idempotent)
// and drops the TEST face — a test never outlives its run.
TestSession.EndTestSilently();
IsTestStream = false;
// Stop ends EVERYTHING (creator ruling 2026-09-01): a lit pill with no session
// behind it is a lie — the stuck REC pill after the failed 2026-09-01 recording
// attempt was the report. Intent resets with reality; re-arming is one click.
@@ -379,8 +416,10 @@ public partial class MainViewModel : ViewModelBase
private void UpdateLiveVisuals()
{
var live = IsLive;
TopBarBackground = live ? "#e94560" : "#16213e";
PreviewGlowBrush = live ? "#e94560" : "Transparent";
// A live TEST broadcast wears gold, not the live red — the TEST badge +
// gold are the "this is a rehearsal" cue (TASK 41).
TopBarBackground = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "#16213e";
PreviewGlowBrush = live ? IsTestStream ? "#9c6f1c" : "#e94560" : "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (live)