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
+1
View File
@@ -22,6 +22,7 @@ public partial class MainViewModel
if (!value) OnAirPillOn = false;
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowTestButton));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
}
}
@@ -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)
+39 -2
View File
@@ -41,6 +41,13 @@ public partial class MainViewModel : ViewModelBase
private bool _onAirPillOn;
private bool _isRecording;
/// <summary>TASK 41: the current session is a private TEST broadcast — the real
/// go-live pipeline runs (broadcast create, reusable stream, RTMP push, chat
/// poll), but the visuals say TEST (top-bar gold + badge), no "last live" stamp
/// is written, and the Test drawer is open. One flag flips the whole face of the
/// session; cleared on stop / rollback.</summary>
private bool _isTestStream;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
@@ -64,6 +71,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
OnPropertyChanged(nameof(AccountStatusLightToolTip));
OnPropertyChanged(nameof(ShowChatInactiveMessage));
OnPropertyChanged(nameof(ShowPreviewPlaceholder));
@@ -126,6 +134,7 @@ public partial class MainViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowEndStreamButton));
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
UpdateSessionTimer();
}
}
@@ -154,8 +163,35 @@ public partial class MainViewModel : ViewModelBase
public bool ShowEndStreamButton => IsLive || IsRecording;
/// <summary>The one button whose label changes: while recording it reads
/// "Stop Recording", while live "End Stream".</summary>
public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
/// "Stop Recording", while live "End Stream" — and while a test broadcast runs,
/// "End Test" so the creator knows exactly what they're stopping.</summary>
public string EndButtonText =>
IsRecording && !IsLive ? "Stop Recording"
: IsTestStream ? "End Test"
: "End Stream";
/// <summary>TASK 41 — the Test button (top bar, left of Start): a one-click
/// private test broadcast. Only meaningful signed-in and idle — it disappears
/// the moment a session (live OR recording) starts.</summary>
public bool ShowTestButton => IsOffline && !IsRecording && IsConnected;
public bool CanStartTest => ShowTestButton;
/// <summary>TASK 41 — the current session is a test broadcast (drives the TEST
/// badge + gold top bar + "End Test" face). Internal setter is a test seam like
/// IsRecording: <see cref="BeginTestStream"/> flips it before <see cref="StreamStatus"/>
/// goes Streaming so the visuals initialize amber, and the stop/rollback path
/// clears it.</summary>
public bool IsTestStream
{
get => _isTestStream;
internal set
{
if (!SetProperty(ref _isTestStream, value)) return;
OnPropertyChanged(nameof(EndButtonText));
UpdateLiveVisuals();
}
}
public StreamHealth CurrentHealth
{
@@ -330,6 +366,7 @@ public partial class MainViewModel : ViewModelBase
}
public ICommand StartStreamCommand { get; }
public ICommand StartTestCommand { get; }
public ICommand EndStreamCommand { get; }
public ICommand SignInCommand { get; }
public ICommand ChooseRecordFolderCommand { get; }
+26 -2
View File
@@ -12,6 +12,11 @@ public partial class MainViewModel
/// on the Live screen's right edge (TASK 39).</summary>
public Services.YppTrackerViewModel Ypp { get; private set; } = null!;
/// <summary>The Test Stream pull-out backing VM — the TEST drawer at the
/// bottom of the right rail (TASK 41): mock chat input (real insert →
/// poll round-trip) + locally-simulated member / SuperChat / subscriber rows.</summary>
public Services.TestSessionViewModel TestSession { get; private set; } = null!;
private void InitYpp()
{
Ypp = new YppTrackerViewModel(
@@ -20,17 +25,36 @@ public partial class MainViewModel
() => IsConnected,
() => _youtubeAuth.CurrentChannel?.DisplayName);
// One drawer at a time: the two pull-outs share the preview's right edge,
// so opening one collapses the other (both directions).
TestSession = new TestSessionViewModel(
_youtubeStream,
_youtubeChat,
onEndStream: StopStream);
// One drawer at a time: the three pull-outs share the preview's right edge,
// so opening one collapses the other two (all directions).
BroadcastForm.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(LiveBroadcastFormViewModel.IsDrawerOpen) && BroadcastForm.IsDrawerOpen)
{
Ypp.IsDrawerOpen = false;
TestSession.IsDrawerOpen = false;
}
};
Ypp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(YppTrackerViewModel.IsDrawerOpen) && Ypp.IsDrawerOpen)
{
BroadcastForm.IsDrawerOpen = false;
TestSession.IsDrawerOpen = false;
}
};
TestSession.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(TestSessionViewModel.IsDrawerOpen) && TestSession.IsDrawerOpen)
{
BroadcastForm.IsDrawerOpen = false;
Ypp.IsDrawerOpen = false;
}
};
}
}
+1
View File
@@ -219,6 +219,7 @@ public partial class MainViewModel : ViewModelBase
OpenPremiumCommand = new RelayCommand(_ => OpenUrl(PremiumUrl), _ => IsPremiumAvailable);
OpenLicensesCommand = new RelayCommand(_ => ShowLicenses());
StartStreamCommand = new RelayCommand(_ => StartSession());
StartTestCommand = new RelayCommand(_ => BeginTestStream(), _ => CanStartTest);
SignInCommand = new RelayCommand(_ => _ = SignInAsync(), _ => !IsConnected);
EndStreamCommand = new RelayCommand(_ => StopStream(), _ => IsLive || IsRecording);
ChooseRecordFolderCommand = new RelayCommand(_ => ChooseRecordFolder());