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