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