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 { /// 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. 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); } /// 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. 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(); } /// Starts the live audio pipe, or explains why it cannot and rolls the session back. /// 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. /// 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; } } /// Builds the Start Stream dialog's model from the Stream Info tab. Re-reads the store /// first: the form is loaded once at startup, and a second LlamaCasty on the same profile (the dev /// instance, or Release recording while Debug goes live) writes its edits straight to the store, so /// this process's copy can be empty while the tab the creator filled in is not (2026-11-06). internal GoLiveViewModel CreateGoLiveDialog() { BroadcastForm.Load(); var dialog = new GoLiveViewModel(() => SignInAsync(), _youtubeAuth.CurrentChannel) { StreamTitle = BroadcastForm.Title, StreamDescription = BroadcastForm.Description, }; AppLog.Write($"Go Live dialog: title='{dialog.StreamTitle}' description={dialog.StreamDescription.Length} chars"); return dialog; } private void BeginGoLive(bool alsoRecord) { var dialog = CreateGoLiveDialog(); 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); } /// 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. 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); } /// 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. 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; StartStepDownWatch(); StartClipRing(); 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(); } /// Creates the record file (auto-named ty-…-0000.mp4 in the record /// folder) and returns its path, or null when the folder can't be prepared. /// 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. internal async Task 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; } /// 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. 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; ApplyEditLockForSession(starting: true); 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(); /// 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. 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 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; } /// True when the session is being torn down because something broke, /// not because the creator pressed Stop. A failure clears both outputs (there is no session and no /// choice worth keeping); a creator's Stop of a recording restores the Local MP4 pair. private async void StopStream(bool afterFailure = false) { var dispatcher = Application.Current?.Dispatcher; if (dispatcher != null && !dispatcher.CheckAccess()) { _ = dispatcher.BeginInvoke(new Action(() => StopStream())); return; } CancelStreamRecovery(); // a stop wins over a relaunch in flight StopStepDownWatch(); StopClipRing(); RemoveAttentionAlert("stream-recovery"); var wasLive = IsLive; var wasRecording = IsRecording; var wasRecordingOnly = IsRecording && !IsLive && !afterFailure; StreamStatus = StreamStatus.Offline; _sessionStartedAtUtc = null; IsRecording = false; LiveScene = null; ApplyEditLockForSession(starting: 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 the session (creator ruling 2026-09-01): a lit pill with no session behind it is a // lie. Since build 730 the pills are the chosen OUTPUT, not an armed intent, so a finished // local recording returns to its idle pair — Local MP4 chosen, Start Recording beside it // (creator's words) — while a finished stream clears both and asks again. if (wasRecordingOnly) { RecordPillOn = true; OnAirPillOn = false; } else { 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"); } /// On stop the auto-named ty-…-0000.mp4 file is renamed to carry /// the real recording length (ty-…-hh2mm2.mp4), with a numeric suffix on /// name collisions. Runs only after the pump has fully closed the file. /// Falls back to the user's Downloads folder when no preference is set; /// returns MyVideos if Downloads doesn't exist. // 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 = EffectiveQuality(OutputProfile.Resolve(SelectedQuality, QualityOptions, HasProOutput)); // TASK 33: while a session is live, a step-down owns the emitted tier even though the dropdown // still shows what the creator picked. Their intent is not edited by the policy. if (_encoderTierOverride is { } landed) quality = new QualityOption($"{landed.Width}p{landed.Fps}", landed.Fps, landed.BitrateMbps, landed.Width, landed.Height); 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), // TASK 65: null when clips are off, which leaves ffmpeg's command line untouched. ClipBuffer = _clipRing, }; } // 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(afterFailure: true); } 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(); } /// Runs the elapsed/rec-dot-pulse timer while a session is active — /// live (Streaming) OR recording. Reset the elapsed when a new session starts. 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)); OnPropertyChanged(nameof(RecordChipText)); } 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.2 : 1.0; OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(RecordChipText)); // TASK 55·3: the last useful warning before the encoder fails for a reason it won't explain. if (IsRecording && !_lowDiskWarned && _recordFolder is { } folder && DiskSpaceGuard.FreeBytes(folder) is { } free && DiskSpaceGuard.ShouldWarnDuring(free)) { _lowDiskWarned = true; _notifications.Warning("Disk space is nearly gone", "The recording may stop on its own. Free up space now, or stop and save what you have."); } } /// What happened to a finished broadcast at the keep-or-delete prompt. internal enum PrivateBroadcastOutcome { NotPrivate, Kept, Deleted, DeleteFailed } /// Test seam: answers the keep-or-delete prompt (title → keep?) instead of showing /// . Mirrors the other static overrides. internal static Func? 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; } } /// 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. internal static async Task<(PrivateBroadcastOutcome Outcome, string? Error)> ClosePrivateBroadcastAsync( YouTubeStreamService youtube, string broadcastId, Func 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); } }