Files
LlamaCasty/ViewModels/MainViewModel.Streaming.Operations.cs
T
gramps dbcb69278f fix(audio): a DEBUG build never shares the pipe name with Release
Creator was recording on the Release build and hit "Couldn't start the audio — another copy of
LlamaCasty is already using the audio pipe" when going live on Debug. The guard did its job (no
crash), but the collision should not have been possible: the pipe name only suffixed when
`YTLIVE_INSTANCE` was set, and a Debug launched straight from the IDE has no id — so it asked for
the same `ytllive_audio` the Release build was serving.

DEBUG now always suffixes: the claimed instance id when there is one, otherwise the process id. Any
number of dev builds can run beside the Release app, and each Debug process is distinct from every
other. Release is unchanged — it resolves to the plain name, and the whole mechanism disappears in
`#if DEBUG` as before.

`TryStartAudioPipe` now logs the resolved name (`Audio pipe: …`), so the next report of this kind is
one grep instead of a code hunt.

Tests: AudioPipeInstanceTests pins that an id-less DEBUG process never resolves to the bare name;
572 green, 0 warnings.
2026-11-06 12:30:48 -08:00

673 lines
31 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);
if (!TryStartAudioPipe()) return;
IsRecording = true;
LiveScene = StagedScene;
await _framePump.StartAsync();
}
/// <summary>Starts the live audio pipe, or explains why it cannot and rolls the session back.
/// <para>This used to throw straight out of the Go Live button: the pipe server allows one instance,
/// so with a second LlamaCasty running on the same machine the click killed the app
/// ("All pipe instances are busy", 2026-11-06). A click must never be able to end the process.</para>
/// </summary>
private bool TryStartAudioPipe()
{
var pipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName);
try
{
_audioMixer.StartLive(pipeName);
AppLog.Write($"Audio pipe: {pipeName}");
return true;
}
catch (Exception ex)
{
AppLog.Write(ex, "Audio pipe could not be started — session aborted");
_notifications.Error("Couldn't start the audio",
"Another copy of LlamaCasty is already using the audio pipe. Close it, then try again.");
StopStream();
return false;
}
}
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;
// TASK 3·20: the session report window starts here. Derived on demand from YouTube, never
// persisted, and reset when the session ends.
_sessionStartedAtUtc = DateTime.UtcNow;
ResetHealth(StreamStatus.Streaming);
if (!TryStartAudioPipe()) return;
_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;
_sessionStartedAtUtc = null;
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),
// Same derivation as the mixer's StartLive call: a dev instance serves its own pipe.
AudioPipeName = InstanceProfile.AudioPipeName(EncoderOptions.DefaultAudioPipeName),
};
}
// 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);
}
}