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; 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; } 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); _audioMixer.StartLive(EncoderOptions.DefaultAudioPipeName); IsRecording = true; 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) { 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) { _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. /// 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 { var stream = await _youtubeStream.GetOrCreateReusableStreamAsync(); if (stream == null) { AppLog.Write("Reusable stream unavailable; check the OAuth session"); _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); _currentBroadcastId = await _youtubeStream.CreateBroadcast( StreamTitle, StreamDescription, DateTime.UtcNow, stream.Id); if (_currentBroadcastId == null) { AppLog.Write("Broadcast creation failed; check the OAuth session"); _notifications.Error("Couldn't go live", "YouTube rejected the broadcast — check the sign-in and try again."); StopStream(); // full rollback return; } AppLog.Write($"Broadcast created: {_currentBroadcastId}"); OnPropertyChanged(nameof(CurrentBroadcastId)); 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."); } // 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. try { var 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"); } } catch (Exception chatEx) { AppLog.Write($"Chat start failed (non-fatal): {chatEx.Message}"); } } catch (Exception ex) { AppLog.Write($"Go-live preparation failed: {ex.Message}"); _notifications.Error("Couldn't go live", ex.Message); StopStream(); // full rollback — a created broadcast (if any) gets the close-out return; } await _framePump.StartAsync(); // never throws; failures log + surface via Failed // Health polling (TASK 5 item 3): poll immediately, then every 30s while // live. Report-by-exception — a banner appears only on warning/error. _healthPollTimer.Start(); _ = PollHealthAsync(); } 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. 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"); return; } CurrentHealth.HealthStatus = health.HealthStatus; CurrentHealth.HealthMessage = health.HealthStatus; CurrentHealth.ConfigurationIssues.Clear(); CurrentHealth.ConfigurationIssues.AddRange(health.ConfigurationIssues); OnPropertyChanged(nameof(CurrentHealth)); ApplyHealthIssue(health.ConfigurationIssues); } catch (Exception ex) { AppLog.Write($"Stream health poll failed: {ex.Message}"); } } // 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 ? "#8f1f1f" : "#b8860b"; HealthIssueBanner = report.Text; } private async void StopStream() { var wasLive = IsLive; var wasRecording = IsRecording; StreamStatus = StreamStatus.Offline; IsRecording = 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"; 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. if (wasLive && _currentBroadcastId != null) { var closeOutError = await _youtubeStream.EndBroadcastAsync(_currentBroadcastId); if (closeOutError != null) AppLog.Write($"End close-out: {closeOutError} — enableAutoStop will finish the broadcast"); } _currentBroadcastId = null; OnPropertyChanged(nameof(CurrentBroadcastId)); 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). private EncoderOptions? BuildEncoderOptions() { var url = _rtmpUrlProvider(); var quality = SelectedQuality; return new EncoderOptions { RtmpUrl = url ?? string.Empty, StreamEnabled = OnAirPillOn, RecordEnabled = RecordPillOn, RecordPath = _activeRecordPath, Width = quality.Width, Height = quality.Height, 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}"); 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; TopBarBackground = live ? "#e94560" : "#16213e"; PreviewGlowBrush = live ? "#e94560" : "Transparent"; PreviewGlowThickness = live ? new Thickness(3) : new Thickness(0); if (live) { if (BrandFlashEnabled) StartBrandFlashTimer(); } else { _healthPollTimer.Stop(); _brandFlashTimer.Stop(); _brandFlashOffTimer.Stop(); BrandFlashActive = false; } 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)); } 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)); } }