Files
LlamaCasty/ViewModels/MainViewModel.Streaming.Operations.cs
T
gramps ec1091a170 feat(stream): probe the ingest before going live — no more ghost broadcasts (TASK 32·5)
"I forgot the VPN" used to create a broadcast on YouTube that nothing ever pushed to: the app looked
live, YouTube held an empty broadcast, and the creator had to work out what happened. Prevention beats
recovery here, so go-live now asks one question first.

- `Services/IngestProbe.cs` — `IIngestProbe` + `TcpIngestProbe`: parse the RTMP URL, connect to the host
  (rtmp → 1935, rtmps → 443) with a 2 s timeout. Any failure answers "not reachable"; the point is a
  fast unambiguous answer, not a diagnosis.
- `MainViewModel.IngestPreflightAsync` runs **before the first API call** whenever a cached ingest URL
  exists; on a first run it probes once the stream is resolved, still before any broadcast is created.
  A false answer logs, tells the creator ("Nothing was created on YouTube…"), and rolls the session
  back — nothing to clean up on YouTube.
- The probe is a test seam (`IngestProbeOverride`) like the camera and chroma ones.

Tests: `IngestPreflightTests` — unreachable aborts, reachable continues, URL parsing (rtmp/rtmps,
garbage), and the probe against **real sockets**: a listening port answers true, the same port closed
answers false and does so fast. FAST gate: 547 passed, 0 failed, 0 warnings.
2026-11-05 14:56:20 -08:00

643 lines
29 KiB
C#

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using Microsoft.Win32;
using ytLive.Helpers;
using ytLive.Models;
using ytLive.Services;
using ytLive.Services.Encoder;
namespace ytLive.ViewModels;
public partial class MainViewModel : ViewModelBase
{
/// <summary>Entry point for the primary Start button (and the start/stop hotkey):
/// routes to a record session or the YouTube go-live dialog by the pill state.</summary>
private void StartSession()
{
if (IsLive || IsRecording) return;
var streaming = OnAirPillOn;
var recording = RecordPillOn;
// The scoreboard's scope is the session: every start clears it (creator ruling 2026-10-30).
StartScoreboardSession();
if (!streaming && !recording)
{
// Nothing armed = "record something" (local recording needs no account,
// 2026-09-01): a Start that silently no-ops is the dead end the design
// principle forbids. The pill lights so reality and intent agree.
RecordPillOn = true;
BeginRecordOnly();
return;
}
if (!streaming)
{
BeginRecordOnly();
return;
}
if (!IsConnected)
{
// ON-AIR armed signed-out: intent (the pill stayed lit) is reality-aware.
// A signed-out go-live would dead-end in the account dialog {DASH} so Start
// turns the intent into the sign-in face instead. Reality and intent meet
// at the account connection; the pill keeps its light either way.
_ = SignInAsync();
return;
}
BeginGoLive(recording);
}
/// <summary>Record-only (REC pill on, ON-AIR off): no YouTube dialog — recording
/// is local and works signed-out. Generates the auto-named file and starts the
/// pipeline immediately.</summary>
private async void BeginRecordOnly()
{
var path = StartRecordFile();
if (path == null)
{
_notifications.Error("Couldn't record", "No record output path could be created.");
return;
}
_recordStartTime = DateTime.Now;
_liveElapsed = TimeSpan.Zero;
_recordLength = TimeSpan.Zero;
ResetHealth(StreamStatus.Offline);
_audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName);
IsRecording = true;
LiveScene = StagedScene;
await _framePump.StartAsync();
}
private void BeginGoLive(bool alsoRecord)
{
var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel)
{
StreamTitle = BroadcastForm.Title,
StreamDescription = BroadcastForm.Description,
};
var window = new ytLive.GoLiveWindow(dialog) { Owner = System.Windows.Application.Current.MainWindow };
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;
// Test is stream-world-only, so the ON-AIR intent is armed by the time we
// get here — but arm it explicitly anyway: the encoder output gate reads the
// pill (StreamEnabled = OnAirPillOn), and a test that never armed the pill
// boots a pump with zero outputs and dies on "At least one output required".
OnAirPillOn = true;
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)
{
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;
}
}
IsRecording = recordStarted;
_ = PrepareAndStartLiveAsync();
}
/// <summary>Creates the record file (auto-named <c>ty-…-0000.mp4</c> in the record
/// folder) and returns its path, or null when the folder can't be prepared.</summary>
/// <summary>TASK 32·5 — is YouTube's ingest reachable right now? A false answer aborts go-live
/// before anything is created on YouTube: "I forgot the VPN" used to produce a broadcast that
/// nobody ever saw, and the app looked live while the push had nowhere to go.</summary>
internal async Task<bool> IngestPreflightAsync(string rtmpUrl)
{
var probe = IngestProbeOverride ?? new TcpIngestProbe();
if (await probe.CanReachAsync(rtmpUrl)) return true;
AppLog.Write("Ingest unreachable — go-live aborted before creating a broadcast",
AppLogMessageType.Error, AppLogErrorClass.Class1);
_notifications.Error("Can't reach YouTube's ingest",
"Nothing was created on YouTube. Check your network or VPN, then try again.");
StopStream(); // rollback: no ghost broadcast, no half-lit session
return false;
}
/// <summary>One-click go-live (TASK 5): ensure the reusable stream, create a
/// broadcast bound to it, then start the frame pump. The pump reads the RTMP
/// URL once at startup (FramePump.StartAsync), so the stream URL must be set
/// before the pump starts — hence the ordering here. Failures surface as an
/// error health state, never a crash.</summary>
private async Task PrepareAndStartLiveAsync()
{
try
{
// TASK 32·5 — prevention. With a cached ingest URL we probe BEFORE the first API call, so
// a dead network costs nothing and leaves nothing behind on YouTube. First run (no cached
// URL yet) probes once the stream is resolved, still before any broadcast is created.
var cachedIngest = _reusableStreamUrl;
if (!string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(cachedIngest))
return;
var stream = await _youtubeStream.GetOrCreateReusableStreamAsync();
if (stream == null)
{
AppLog.Write("Reusable stream unavailable; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live",
"The reusable stream is unavailable — check the YouTube sign-in and try again.");
StopStream(); // full rollback — audio loop + recording + pills, no Error-status zombie
return;
}
_reusableStream = stream;
_reusableStreamUrl = stream.RtmpUrl;
_layoutStore.SaveReusableStream(stream);
if (string.IsNullOrWhiteSpace(cachedIngest) && !await IngestPreflightAsync(stream.RtmpUrl))
return;
_currentBroadcastId = await _youtubeStream.CreateBroadcast(
StreamTitle, StreamDescription, DateTime.UtcNow);
_broadcastSeenLive = false;
if (_currentBroadcastId == null)
{
AppLog.Write("Broadcast creation failed; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again.");
StopStream(); // full rollback
return;
}
AppLog.WriteSuccess($"Broadcast created: {_currentBroadcastId}");
OnPropertyChanged(nameof(CurrentBroadcastId));
// ⛔ THE BINDING — the call whose absence is why nothing ever went live
// (2026-10-04). Sending `contentDetails.boundStreamId` on the insert does NOT
// bind: the insert is accepted, its sibling fields take effect, and the
// broadcast still comes back `bound=NONE`, sits in lifeCycleStatus `created`
// forever ("incomplete settings"), so `enableAutoStart` has no bound stream to
// fire from and close-out 403s `invalidTransition`. Meanwhile ffmpeg happily
// pushes ~99MB into the reusable stream, because a reusable stream accepts
// bytes for a broadcast that is not watching — which is why every symptom
// pointed away from the binding.
//
// Failure here is fatal to the session, not cosmetic: an unbound broadcast can
// never go live, so continuing would present a live-looking UI over a broadcast
// that is quietly never going to be seen.
var bindError = await _youtubeStream.BindBroadcastAsync(_currentBroadcastId, stream.Id);
if (bindError != null)
{
AppLog.Write($"Stream binding failed: {bindError}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
// Show the real reason. "Check the sign-in" would be wrong for five of the
// six documented bind failures, and would hide our own ordering bug behind
// a plausible excuse — see BindFailureMessage.
_notifications.Error("Couldn't go live", bindError);
StopStream(); // full rollback
return;
}
if (string.IsNullOrWhiteSpace(StreamTitle) && string.IsNullOrWhiteSpace(StreamDescription))
{
BroadcastForm.IsDrawerOpen = true;
_notifications.Warning("Complete your broadcast details",
"Your title and description are empty — fill them in from the Text panel on the right.");
}
}
catch (Exception ex)
{
AppLog.Write($"Go-live preparation failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't go live", ex.Message);
StopStream(); // full rollback — a created broadcast (if any) gets the close-out
return;
}
// The pump is what pushes RTMP; enableAutoStart flips the broadcast ready→live
// only once frames flow. The liveChatId (snippet.liveChatId) does not exist
// before that (2026-09-25 diagnosis), so chat resolution runs AFTER the pump
// starts. Chat is non-fatal — if it fails, the stream continues without chat.
LiveScene = StagedScene;
EditingActive = false;
await _framePump.StartAsync(); // never throws; failures log + surface via Failed
// Check promptly until YouTube confirms the broadcast is live. Health
// polling returns to its normal interval after that confirmation.
_healthPollTimer.Interval = TimeSpan.FromSeconds(2);
_healthPollTimer.Start();
_ = PollHealthAsync();
string? liveChatId = null;
try
{
if (_currentBroadcastId != null)
liveChatId = await _youtubeStream.GetBroadcastLiveChatIdAsync(_currentBroadcastId);
if (liveChatId != null)
{
_youtubeChat.Start(liveChatId);
AppLog.Write($"Chat polling started: {liveChatId}");
}
else
{
AppLog.Write("Could not retrieve liveChatId; chat polling disabled",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
catch (Exception chatEx)
{
AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
// 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}");
}
}
private void OnHealthPollTick(object? sender, EventArgs e) => _ = PollHealthAsync();
/// <summary>One liveStreams.list poll (TASK 5 item 3): updates the YouTube
/// health state + the report-by-exception banner. Never throws — a poll
/// failure logs and leaves the last state untouched.
///
/// Also piggybacks the broadcast diagnostic (2026-10-04) — the health poll was
/// already running during a stream but only ever read the STREAM's health, which
/// is why two test runs could push 100MB to YouTube and still leave the app
/// unable to say whether the broadcast had ever left `ready`. Deliberately
/// self-limiting: it only logs while the broadcast has NOT reached `live`, so it
/// shouts exactly when something is wrong and goes quiet once healthy, rather than
/// adding a permanent per-tick line to the log.</summary>
private async Task PollHealthAsync()
{
if (_reusableStream == null || !IsLive) return;
try
{
var health = await _youtubeStream.GetStreamHealthAsync(_reusableStream.Id);
if (health == null)
{
AppLog.Write("Stream health poll failed; check the OAuth session",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
else
{
CurrentHealth.HealthStatus = health.HealthStatus;
CurrentHealth.HealthMessage = health.HealthStatus;
CurrentHealth.ConfigurationIssues.Clear();
CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues);
OnPropertyChanged(nameof(CurrentHealth));
ApplyHealthIssue(health.ConfigurationIssues);
}
if (!string.IsNullOrWhiteSpace(_currentBroadcastId) && !_broadcastSeenLive)
{
var lifeCycle = await _youtubeStream.LogBroadcastDiagnosticsAsync(_currentBroadcastId, _reusableStream.Id);
ApplyBroadcastLifecycleStatus(lifeCycle);
}
}
catch (Exception ex)
{
AppLog.Write($"Stream health poll failed: {ex.Message}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
}
// Report-by-exception: good/ok/noData (or info-only issues) clear the banner;
// the first warning/error issue raises it, colored by the worst severity.
private void ApplyHealthIssue(List<StreamConfigurationIssue> issues)
{
var report = StreamHealthReporter.BannerFor(issues);
if (report.Text == null)
{
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
return;
}
HealthIssueBackground = report.IsError
? ThemeBrush.Hex("Brush.ErrorContainer")
: ThemeBrush.Hex("Brush.WarningContainer");
HealthIssueBanner = report.Text;
}
private async void StopStream()
{
CancelStreamRecovery(); // a stop wins over a relaunch in flight
RemoveAttentionAlert("stream-recovery");
var wasLive = IsLive;
var wasRecording = IsRecording;
StreamStatus = StreamStatus.Offline;
IsRecording = false;
LiveScene = null;
EditingActive = true;
// 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.
RecordPillOn = false;
OnAirPillOn = false;
WindowTitle = "LlamaCasty";
// Deactivate zoom when stopping
if (_zoomState.IsActive || _zoomState.IsMoving)
{
_zoomState.IsActive = false;
_zoomState.IsMoving = false;
OnPropertyChanged(nameof(IsZoomActive));
OnPropertyChanged(nameof(ShowZoomOverlay));
OnPropertyChanged(nameof(ZoomState));
RebuildQualityDimRects();
}
ResetHealth(StreamStatus.Offline);
// Stop chat polling first (TASK 9.4) — no more messages arriving while
// we're tearing down the stream.
_youtubeChat.Stop();
// Close the audio pipe FIRST so ffmpeg sees EOF on the audio input,
// then stop the pump (stdin EOF) — both inputs end and the encoder
// finalizes the output. Audio capture itself is always-on (preview
// monitoring); only the live/report pipe, the pump, and the session stop here.
_audioMixer.StopLive();
await _framePump.StopAsync();
// Proper close-out (TASK 9 decision 6, built 2026-09-01): transition(complete)
// AFTER the RTMP push is closed so no frames post-date the end — YouTube then
// finalizes the VOD immediately instead of leaving viewers on a frozen
// "stream offline" until enableAutoStop fires (~1 min). Log-only failure:
// the backstop still ends the broadcast; a stop never fails over cosmetics.
var endedBroadcastId = wasLive ? _currentBroadcastId : null;
if (endedBroadcastId != null)
{
var closeOutError = await _youtubeStream.EndBroadcastAsync(endedBroadcastId);
if (closeOutError != null)
AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast",
AppLogMessageType.Error, AppLogErrorClass.Class2);
}
_currentBroadcastId = null;
OnPropertyChanged(nameof(CurrentBroadcastId));
// Creator ruling 2026-10-09: a PRIVATE broadcast ends like a recording — Enter keeps
// it on YouTube, Escape deletes it.
if (endedBroadcastId != null)
await OfferToKeepPrivateBroadcastAsync(endedBroadcastId);
if (wasRecording) await FinalizeRecordingAsync();
// Explicit sign-out only (TASK 18): stopping the stream does NOT clear
// the session/token — the creator signs out deliberately via the
// Logout / Change Account commands.
AppLog.Write(wasLive ? "Stream ended" : "Recording ended");
}
/// <summary>On stop the auto-named <c>ty-…-0000.mp4</c> file is renamed to carry
/// the real recording length (<c>ty-…-hh2mm2.mp4</c>), with a numeric suffix on
/// name collisions. Runs only after the pump has fully closed the file.</summary>
/// <summary>Falls back to the user's Downloads folder when no preference is set;
/// returns MyVideos if Downloads doesn't exist.</summary>
// Full encoder options for the current tier, or null when no RTMP URL is
// available — the pump then skips the encoder entirely (TASK 5 fills the seam).
//
// BOTH outputs (RTMP + local MP4) are encoded from this one option set, so the
// output-profile rule is applied here, once: a capped build cannot stream 1080p and
// record it too. See Models/OutputProfile.cs — pixels, not "720p".
private EncoderOptions? BuildEncoderOptions()
{
var url = _rtmpUrlProvider();
var quality = OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput);
var compositor = BuildCompositorOptions();
return new EncoderOptions
{
RtmpUrl = url ?? string.Empty,
StreamEnabled = OnAirPillOn,
RecordEnabled = RecordPillOn,
RecordPath = _activeRecordPath,
Width = quality.Width,
Height = quality.Height,
InputWidth = compositor.OutputWidth,
InputHeight = compositor.OutputHeight,
Fps = quality.Fps,
BitrateKbps = (int)Math.Round(quality.Bitrate * 1000),
};
}
// internal: test seam (mirrors LayoutPathOverride) — the rollback path is
// unobservable without simulating a pump death; ytLive.Tests has InternalsVisibleTo.
internal void OnFramePumpFailed(object? sender, string message)
{
// No zombies (2026-09-01): a dead pump means NO session — the old handler
// toasted and (live-only) flipped StreamStatus, leaving a record-only session
// with IsRecording=true and no encoder (first real launch proved it). The
// failure now runs the NORMAL stop path on the UI thread (pills off, audio
// loop closed, recording finalized, close-out if live), then explains itself.
AppLog.Write($"Frame pump failed: {message}", AppLogMessageType.Error, AppLogErrorClass.Class2);
void Handle()
{
var live = IsLive;
_notifications.Error(live ? "The stream pipeline stopped" : "Recording stopped", message);
StopStream();
}
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(Handle);
else
Handle();
}
// TASK 4 ship step 6: the encoder's parsed health (bitrate/FPS/dropped/
// duration) lands in the bottom bar. The stderr loop raises on a background
// thread — marshal to the UI thread like the audio level handlers.
private void OnFramePumpHealthUpdated(object? sender, StreamHealth health)
{
var dispatcher = System.Windows.Application.Current?.Dispatcher;
if (dispatcher != null && !dispatcher.CheckAccess())
dispatcher.BeginInvoke(() => ApplyHealth(health));
else
ApplyHealth(health);
}
private void ApplyHealth(StreamHealth health)
{
CurrentHealth.Status = health.Status;
CurrentHealth.CurrentBitrate = health.CurrentBitrate;
CurrentHealth.FPS = health.FPS;
CurrentHealth.DroppedFrames = health.DroppedFrames;
CurrentHealth.StreamDuration = health.StreamDuration;
CurrentHealth.LastError = health.LastError;
CurrentHealth.HealthMessage = health.HealthMessage;
OnPropertyChanged(nameof(CurrentHealth));
}
// Keeps the bottom-bar stats honest across sessions: dropped frames and the
// elapsed duration must not linger from a previous go-live (bitrate/FPS stay
// on the tier's targets — ApplyStreamQuality sets them on pick).
private void ResetHealth(StreamStatus status)
{
CurrentHealth.Status = status;
CurrentHealth.DroppedFrames = 0;
CurrentHealth.StreamDuration = TimeSpan.Zero;
CurrentHealth.LastError = null;
CurrentHealth.HealthStatus = null;
CurrentHealth.HealthMessage = null;
CurrentHealth.ConfigurationIssues.Clear();
HealthIssueBanner = null;
HealthIssueBackground = "Transparent";
OnPropertyChanged(nameof(CurrentHealth));
}
private void UpdateLiveVisuals()
{
var live = IsLive;
// TEST keeps its gold identity. For a normal ON-AIR session, green means
// YouTube has confirmed live; before then it keeps the existing red color.
TopBarBackground = live
? IsTestStream
? ThemeBrush.Hex("Brush.TestContainer")
: ThemeBrush.Hex(_youtubeBroadcastLive ? "Brush.Positive" : "Brush.Primary")
: ThemeBrush.Hex("Brush.SurfaceContainer");
PreviewGlowBrush = live
? ThemeBrush.Hex(IsTestStream ? "Brush.TestContainer" : "Brush.Primary")
: "Transparent";
PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0);
if (!live)
{
_healthPollTimer.Stop();
_healthPollTimer.Interval = TimeSpan.FromSeconds(30);
}
// NOTE: the branding credit is deliberately NOT started/stopped here.
// CREATOR RULING 2026-09-26 — it must appear in EVERY scene and in
// recordings, not only while streaming. It used to be gated on IsLive, which
// meant a recording made without ever going live carried no credit at all.
// The presenter is started once for the life of the app (MainViewModel ctor);
// the licence gate rides on BrandFlashPresenter.Enabled via IsPremium, so it
// needs no live branch — IsPremium's setter pushes it from any thread.
UpdateSessionTimer();
}
/// <summary>Runs the elapsed/rec-dot-pulse timer while a session is active —
/// live (Streaming) OR recording. Reset the elapsed when a new session starts.</summary>
private void UpdateSessionTimer()
{
var active = IsLive || IsRecording;
if (active)
{
_recDotPulse = 1.0;
_liveTimer.Start();
}
else
{
_liveTimer.Stop();
LiveElapsedText = "00:00:00";
_recDotPulse = 1.0;
}
OnPropertyChanged(nameof(RecDotOpacity));
OnPropertyChanged(nameof(LiveElapsedText));
}
private void OnLiveTimerTick(object? sender, EventArgs e)
{
_liveElapsed = _liveElapsed.Add(TimeSpan.FromSeconds(1));
LiveElapsedText = _liveElapsed.ToString(@"hh\:mm\:ss");
_recordLength = _liveElapsed;
_recDotPulse = _recDotPulse > 0.5 ? 0.35 : 1.0;
OnPropertyChanged(nameof(RecDotOpacity));
}
/// <summary>What happened to a finished broadcast at the keep-or-delete prompt.</summary>
internal enum PrivateBroadcastOutcome { NotPrivate, Kept, Deleted, DeleteFailed }
/// <summary>Test seam: answers the keep-or-delete prompt (title → keep?) instead of showing
/// <see cref="KeepBroadcastDialog"/>. Mirrors the other static overrides.</summary>
internal static Func<string, bool>? KeepBroadcastPromptOverride { get; set; }
private async Task OfferToKeepPrivateBroadcastAsync(string broadcastId)
{
var title = BroadcastForm.Title;
var (outcome, error) = await ClosePrivateBroadcastAsync(_youtubeStream, broadcastId,
() => KeepBroadcastPromptOverride?.Invoke(title) ?? new KeepBroadcastDialog(title).ShowDialog() == true);
switch (outcome)
{
case PrivateBroadcastOutcome.Kept:
AppLog.Write($"Private broadcast kept: {broadcastId}");
_notifications.Info("Broadcast kept", "It's on YouTube as a private video.");
break;
case PrivateBroadcastOutcome.Deleted:
AppLog.Write($"Private broadcast deleted by creator: {broadcastId}");
_notifications.Info("Broadcast deleted", "The broadcast was removed from YouTube.");
break;
case PrivateBroadcastOutcome.DeleteFailed:
AppLog.Write($"Private broadcast delete failed: {broadcastId}: {error}",
AppLogMessageType.Error, AppLogErrorClass.Class2);
_notifications.Error("Couldn't delete the broadcast",
$"{error}. It's still on YouTube as a private video — you can delete it in YouTube Studio.");
break;
}
}
/// <summary>The keep-or-delete decision for a finished broadcast. Asks ONLY when YouTube
/// itself reports the broadcast as private (visibility can change mid-stream, so local state
/// isn't trusted); an unreadable status asks nothing and deletes nothing. Keep = leave it;
/// otherwise delete the video from YouTube.</summary>
internal static async Task<(PrivateBroadcastOutcome Outcome, string? Error)> ClosePrivateBroadcastAsync(
YouTubeStreamService youtube, string broadcastId, Func<bool> askKeep)
{
var privacy = await youtube.GetBroadcastPrivacyAsync(broadcastId);
if (!string.Equals(privacy, "private", StringComparison.OrdinalIgnoreCase))
return (PrivateBroadcastOutcome.NotPrivate, null);
if (askKeep()) return (PrivateBroadcastOutcome.Kept, null);
var error = await youtube.DeleteFinishedBroadcastAsync(broadcastId);
return error == null ? (PrivateBroadcastOutcome.Deleted, null) : (PrivateBroadcastOutcome.DeleteFailed, error);
}
}