From 31362d1a43c9169763b01fdbbe4594fb277eb93f Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 30 Aug 2026 22:30:59 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20MainViewModel.Streaming=20u?= =?UTF-8?q?nder=20500=20lines=20=E2=80=94=20500-limit=20Phase=203,=20Commi?= =?UTF-8?q?t=20B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming.cs 789 -> 330. Moved the go-live/record/health operation bodies into a new MainViewModel.Streaming.Operations.cs partial (459 lines: StartSession, BeginRecordOnly, BeginGoLive, StartRecordFile, PrepareAndStartLiveAsync, PollHealthAsync/OnHealthPollTick/ApplyHealthIssue, StopStream, FinalizeRecordingAsync, UniquePath, Choose/ResetRecordFolder, DefaultRecordFolder, OnFramePumpFailed/HealthUpdated, ApplyHealth, ResetHealth, BuildEncoderOptions). Streaming.cs keeps state, quality/resolution tiers, session timer, command declarations. Pruned dead usings in both files. Zero behavior change; build 0 warnings; 246 pass, only the 2 known failures. Docs: ViewModels/index.md Phase 3 tracker updated in same commit. --- .../MainViewModel.Streaming.Operations.cs | 475 ++++++++++++++++++ ViewModels/MainViewModel.Streaming.cs | 461 ----------------- ViewModels/index.md | 1 + 3 files changed, 476 insertions(+), 461 deletions(-) create mode 100644 ViewModels/MainViewModel.Streaming.Operations.cs diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs new file mode 100644 index 0000000..15811d1 --- /dev/null +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -0,0 +1,475 @@ +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) 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. + private string? StartRecordFile() + { + try + { + var folder = _recordFolder ?? DefaultRecordFolder(); + Directory.CreateDirectory(folder); + var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4"; + var path = Path.Combine(folder, name); + _activeRecordPath = path; + return path; + } + catch (Exception ex) + { + AppLog.Write(ex, "Recording: failed to prepare output path"); + return null; + } + } + + /// 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."); + StreamStatus = StreamStatus.Error; + 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."); + StreamStatus = StreamStatus.Error; + 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); + StreamStatus = StreamStatus.Error; + 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; + 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(); + _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. + private async Task FinalizeRecordingAsync() + { + var startPath = _activeRecordPath; + _activeRecordPath = null; + if (string.IsNullOrEmpty(startPath)) return; + try + { + while (_framePump.IsRunning) await Task.Delay(50); + + if (!File.Exists(startPath)) + { + AppLog.Write($"Recording: output not found for rename: {startPath}"); + return; + } + _recordLength = _liveElapsed; + var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength); + var dir = Path.GetDirectoryName(startPath)!; + + // Manual rename modal: give the creator a chance to name the recording; + // null = keep the auto name (or the default recorded above). + string stem = autoStem; + var dialog = new RenameRecordingDialog(autoStem); + if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName)) + stem = dialog.ResultFileName; + + var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4")); + File.Move(startPath, finalPath); + AppLog.Write($"Recording saved: {finalPath}"); + _notifications.Info("Recording saved", + Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time."); + } + catch (Exception ex) + { + AppLog.Write(ex, "Recording: rename-on-stop failed"); + _notifications.Error("Recording saved", + $"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}"); + } + } + + private static string UniquePath(string path) + { + if (!File.Exists(path)) return path; + var dir = Path.GetDirectoryName(path)!; + var name = Path.GetFileNameWithoutExtension(path); + var ext = Path.GetExtension(path); + for (var i = 2; ; i++) + { + var candidate = Path.Combine(dir, $"{name}-{i}{ext}"); + if (!File.Exists(candidate)) return candidate; + } + } + + private void ChooseRecordFolder() + { + var dialog = new Microsoft.Win32.OpenFolderDialog + { + Title = "Choose a folder for local recordings", + InitialDirectory = _recordFolder ?? DefaultRecordFolder(), + }; + if (dialog.ShowDialog() == true) + { + _recordFolder = dialog.FolderName; + _layoutStore.SaveRecordFolder(_recordFolder); + OnPropertyChanged(nameof(RecordFolderDisplay)); + _notifications.Info("Record folder set", _recordFolder); + } + } + + private void ResetRecordFolder() + { + _recordFolder = null; + _layoutStore.SaveRecordFolder(null); + OnPropertyChanged(nameof(RecordFolderDisplay)); + _notifications.Info("Record folder reset", RecordFolderDisplay); + } + + /// Falls back to the user's Downloads folder when no preference is set; + /// returns MyVideos if Downloads doesn't exist. + internal static string DefaultRecordFolder() + { + var downloads = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); + return Directory.Exists(downloads) + ? downloads + : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); + } + + // 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), + }; + } + + private void OnFramePumpFailed(object? sender, string message) + { + AppLog.Write($"Frame pump failed: {message}"); + _notifications.Error("The stream pipeline stopped", message); + if (IsLive) StreamStatus = StreamStatus.Error; + } + + // 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)); + } +} diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 2caad73..a024f07 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -1,9 +1,7 @@ using System.Collections.ObjectModel; -using System.IO; using System.Windows; using System.Windows.Input; using System.Windows.Threading; -using Microsoft.Win32; using ytLive.Helpers; using ytLive.Models; using ytLive.Services; @@ -327,463 +325,4 @@ public partial class MainViewModel : ViewModelBase public ICommand EndStreamCommand { get; } public ICommand ChooseRecordFolderCommand { get; } public ICommand ResetRecordFolderCommand { get; } - - /// 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) 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. - private string? StartRecordFile() - { - try - { - var folder = _recordFolder ?? DefaultRecordFolder(); - Directory.CreateDirectory(folder); - var name = RecordingFile.BuildStartName(DateTime.Now) + ".mp4"; - var path = Path.Combine(folder, name); - _activeRecordPath = path; - return path; - } - catch (Exception ex) - { - AppLog.Write(ex, "Recording: failed to prepare output path"); - return null; - } - } - - /// 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."); - StreamStatus = StreamStatus.Error; - 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."); - StreamStatus = StreamStatus.Error; - 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); - StreamStatus = StreamStatus.Error; - 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; - 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(); - _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. - private async Task FinalizeRecordingAsync() - { - var startPath = _activeRecordPath; - _activeRecordPath = null; - if (string.IsNullOrEmpty(startPath)) return; - try - { - while (_framePump.IsRunning) await Task.Delay(50); - - if (!File.Exists(startPath)) - { - AppLog.Write($"Recording: output not found for rename: {startPath}"); - return; - } - _recordLength = _liveElapsed; - var autoStem = RecordingFile.BuildFinalName(_recordStartTime, _recordLength); - var dir = Path.GetDirectoryName(startPath)!; - - // Manual rename modal: give the creator a chance to name the recording; - // null = keep the auto name (or the default recorded above). - string stem = autoStem; - var dialog = new RenameRecordingDialog(autoStem); - if (dialog.ShowDialog() == true && !string.IsNullOrEmpty(dialog.ResultFileName)) - stem = dialog.ResultFileName; - - var finalPath = UniquePath(Path.Combine(dir, stem + ".mp4")); - File.Move(startPath, finalPath); - AppLog.Write($"Recording saved: {finalPath}"); - _notifications.Info("Recording saved", - Path.GetFileName(finalPath) + " — you can rename it in your videos folder at any time."); - } - catch (Exception ex) - { - AppLog.Write(ex, "Recording: rename-on-stop failed"); - _notifications.Error("Recording saved", - $"The recording was written but couldn't be renamed: {Path.GetFileName(startPath)}"); - } - } - - private static string UniquePath(string path) - { - if (!File.Exists(path)) return path; - var dir = Path.GetDirectoryName(path)!; - var name = Path.GetFileNameWithoutExtension(path); - var ext = Path.GetExtension(path); - for (var i = 2; ; i++) - { - var candidate = Path.Combine(dir, $"{name}-{i}{ext}"); - if (!File.Exists(candidate)) return candidate; - } - } - - private void ChooseRecordFolder() - { - var dialog = new Microsoft.Win32.OpenFolderDialog - { - Title = "Choose a folder for local recordings", - InitialDirectory = _recordFolder ?? DefaultRecordFolder(), - }; - if (dialog.ShowDialog() == true) - { - _recordFolder = dialog.FolderName; - _layoutStore.SaveRecordFolder(_recordFolder); - OnPropertyChanged(nameof(RecordFolderDisplay)); - _notifications.Info("Record folder set", _recordFolder); - } - } - - private void ResetRecordFolder() - { - _recordFolder = null; - _layoutStore.SaveRecordFolder(null); - OnPropertyChanged(nameof(RecordFolderDisplay)); - _notifications.Info("Record folder reset", RecordFolderDisplay); - } - - /// Falls back to the user's Downloads folder when no preference is set; - /// returns MyVideos if Downloads doesn't exist. - internal static string DefaultRecordFolder() - { - var downloads = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads"); - return Directory.Exists(downloads) - ? downloads - : Environment.GetFolderPath(Environment.SpecialFolder.MyVideos); - } - - // 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), - }; - } - - private void OnFramePumpFailed(object? sender, string message) - { - AppLog.Write($"Frame pump failed: {message}"); - _notifications.Error("The stream pipeline stopped", message); - if (IsLive) StreamStatus = StreamStatus.Error; - } - - // 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)); - } } diff --git a/ViewModels/index.md b/ViewModels/index.md index 81bfc8f..7f72c57 100644 --- a/ViewModels/index.md +++ b/ViewModels/index.md @@ -67,5 +67,6 @@ source files under 500 lines. One roll-back commit per file; same drill (0-warning build, `verify.sh` 246+2, explicit-file scope-check, tracker in same commit, tag `refactor-commit-N`, no per-commit push). - [x] **Commit A · `refactor-commit-A`** — **core** → `MainViewModel.cs` 758 → **495**. Moved source add/remove/image block to new **`MainViewModel.Sources.cs`** (234 lines: `AddSource`, `NextSourceName`, `AddImage`, `BuildImageCandidates`, `PickImageBytes`, `AddAsset`, `AddImageSource`, `AddReusedImage`, `RemoveElement`) and WebView2 hosting (`RegisterLoadedWebSources`, `InitWebView2`, `OnWebView2PreviewBitmapChanged`) to new **`MainViewModel.Web.cs`**. Pruned now-dead usings (`Microsoft.Win32`, `NAudio.CoreAudioApi`, `System.Windows.Media.Imaging`, `Collections.ObjectModel/Specialized`). Pending: Streaming, Background, SocialsDialogViewModel, LayoutStore. +- [x] **Commit B · `refactor-commit-B`** — **Streaming** → `MainViewModel.Streaming.cs` 789 → **330** + new **`MainViewModel.Streaming.Operations.cs`** (459 lines). Moved the go-live/record/health operation bodies (`StartSession`, `BeginRecordOnly`, `BeginGoLive`, `StartRecordFile`, `PrepareAndStartLiveAsync`, `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue`, `StopStream`, `FinalizeRecordingAsync`, `UniquePath`, `ChooseRecordFolder`, `ResetRecordFolder`, `DefaultRecordFolder`, `OnFramePumpFailed`, `OnFramePumpHealthUpdated`, `ApplyHealth`, `ResetHealth`, `BuildEncoderOptions`) into the Operations partial; Streaming.cs keeps state + quality/resolution tiers + session timer + command declarations. Pruned dead usings both files. Pending: Background, SocialsDialogViewModel, LayoutStore. The partial `.cs` files land next to `MainViewModel.cs` in this folder as the split proceeds; each partial carries its own `using`s and re-declares nothing from core. \ No newline at end of file