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