diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs
new file mode 100644
index 0000000..2caad73
--- /dev/null
+++ b/ViewModels/MainViewModel.Streaming.cs
@@ -0,0 +1,789 @@
+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;
+using ytLive.Services.Encoder;
+
+namespace ytLive.ViewModels;
+
+public partial class MainViewModel : ViewModelBase
+{
+
+ private readonly DispatcherTimer _liveTimer;
+ private readonly DispatcherTimer _healthPollTimer;
+
+ private StreamStatus _streamStatus = StreamStatus.Offline;
+ private StreamHealth _currentHealth = new();
+ private string _streamTitle = string.Empty;
+ private string _streamDescription = string.Empty;
+ private string _streamVisibility = "Private";
+ private string? _currentBroadcastId;
+ private string? _reusableStreamUrl;
+ private ReusableStream? _reusableStream;
+ private string? _healthIssueBanner;
+ private string _healthIssueBackground = "Transparent";
+ private string _windowTitle = "LlamaCasty";
+ private string _topBarBackground = "#16213e";
+ private string _previewGlowBrush = "Transparent";
+ private Thickness _previewGlowThickness = new(0);
+ private string _liveElapsedText = "00:00:00";
+ private double _recDotPulse = 1.0;
+ private TimeSpan _liveElapsed;
+
+ // ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
+ // ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
+ private bool _recordPillOn;
+ private bool _onAirPillOn;
+ private bool _isRecording;
+ private string? _recordFolder;
+
+ /// Resolved record folder — the configured choice, or Downloads (0th case).
+ public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
+
+ private string? _activeRecordPath;
+ private DateTime _recordStartTime;
+ private TimeSpan _recordLength;
+
+ private readonly FramePump _framePump;
+
+ public StreamStatus StreamStatus
+ {
+ get => _streamStatus;
+ set
+ {
+ if (SetProperty(ref _streamStatus, value))
+ {
+ OnPropertyChanged(nameof(IsOffline));
+ OnPropertyChanged(nameof(IsLive));
+ OnPropertyChanged(nameof(IsEditMode));
+ OnPropertyChanged(nameof(RecDotBrush));
+ OnPropertyChanged(nameof(RecTextBrush));
+ OnPropertyChanged(nameof(RecDotOpacity));
+ OnPropertyChanged(nameof(OnAirBrush));
+ OnPropertyChanged(nameof(IsLivePrivate));
+ OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(ShowLogInButton));
+ OnPropertyChanged(nameof(ShowEndStreamButton));
+ OnPropertyChanged(nameof(EndButtonText));
+ OnPropertyChanged(nameof(AccountStatusLightToolTip));
+ OnPropertyChanged(nameof(ShowChatInactiveMessage));
+ OnPropertyChanged(nameof(ShowPreviewPlaceholder));
+ UpdateLiveVisuals();
+ }
+ }
+ }
+
+ public bool IsOffline => StreamStatus == StreamStatus.Offline;
+ public bool IsLive => StreamStatus == StreamStatus.Streaming;
+ public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
+ public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
+ public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
+ public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
+ public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
+
+ // ─── REC / ON-AIR pills (TASK 18) ───
+ public bool RecordPillOn
+ {
+ get => _recordPillOn;
+ set
+ {
+ if (!SetProperty(ref _recordPillOn, value)) return;
+ OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(CanStartSession));
+ }
+ }
+
+ public bool OnAirPillOn
+ {
+ get => _onAirPillOn;
+ set
+ {
+ if (value && !CanToggleOnAir) return; // needs a sign-in
+ if (!SetProperty(ref _onAirPillOn, value)) return;
+ OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(CanStartSession));
+ }
+ }
+
+ /// The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.
+ public bool CanToggleOnAir => IsConnected;
+
+ /// Whether the session is actively recording — the REC status light
+ /// turns green only when this is true (the pill is intent, this is reality).
+ public bool IsRecording
+ {
+ get => _isRecording;
+ private set
+ {
+ if (!SetProperty(ref _isRecording, value)) return;
+ OnPropertyChanged(nameof(RecDotBrush));
+ OnPropertyChanged(nameof(RecTextBrush));
+ OnPropertyChanged(nameof(RecDotOpacity));
+ OnPropertyChanged(nameof(IsEditMode));
+ OnPropertyChanged(nameof(ShowEndStreamButton));
+ OnPropertyChanged(nameof(ShowPrimaryStartButton));
+ OnPropertyChanged(nameof(EndButtonText));
+ UpdateSessionTimer();
+ }
+ }
+
+ /// The primary button shows when there's something to start, but never
+ /// while a session is running — once recording/live the ONLY control is the End
+ /// button.
+ public bool ShowPrimaryStartButton =>
+ IsOffline && !IsRecording && !IsLive
+ && (IsConnected || (RecordPillOn && !OnAirPillOn));
+
+ /// Either output selected makes the button actionable.
+ public bool CanStartSession => RecordPillOn || OnAirPillOn;
+
+ /// The End button shows while recording OR live.
+ public bool ShowEndStreamButton => IsLive || IsRecording;
+
+ /// The one button whose label changes: while recording it reads
+ /// "Stop Recording", while live "End Stream".
+ public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
+
+ public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
+
+ public StreamHealth CurrentHealth
+ {
+ get => _currentHealth;
+ set => SetProperty(ref _currentHealth, value);
+ }
+
+ /// Report-by-exception YouTube health (TASK 5 item 3): non-null only
+ /// while a warning/error configuration issue exists — the banner's text.
+ /// good/ok/noData keep it null so nothing renders.
+ public string? HealthIssueBanner
+ {
+ get => _healthIssueBanner;
+ private set => SetProperty(ref _healthIssueBanner, value);
+ }
+
+ /// Banner background: app red (#8f1f1f) for an error issue, amber
+ /// (#b8860b) for a warning. Transparent when no banner is shown.
+ public string HealthIssueBackground
+ {
+ get => _healthIssueBackground;
+ private set => SetProperty(ref _healthIssueBackground, value);
+ }
+
+ public string StreamTitle
+ {
+ get => _streamTitle;
+ set => SetProperty(ref _streamTitle, value);
+ }
+
+ public string StreamDescription
+ {
+ get => _streamDescription;
+ set => SetProperty(ref _streamDescription, value);
+ }
+
+ public string StreamVisibility
+ {
+ get => _streamVisibility;
+ set
+ {
+ if (SetProperty(ref _streamVisibility, value))
+ {
+ OnPropertyChanged(nameof(RecDotBrush));
+ OnPropertyChanged(nameof(RecTextBrush));
+ OnPropertyChanged(nameof(OnAirBrush));
+ OnPropertyChanged(nameof(IsLivePrivate));
+ }
+ }
+ }
+
+ public string WindowTitle
+ {
+ get => _windowTitle;
+ set => SetProperty(ref _windowTitle, value);
+ }
+
+ public string TopBarBackground
+ {
+ get => _topBarBackground;
+ set => SetProperty(ref _topBarBackground, value);
+ }
+
+ public string PreviewGlowBrush
+ {
+ get => _previewGlowBrush;
+ set => SetProperty(ref _previewGlowBrush, value);
+ }
+
+ public Thickness PreviewGlowThickness
+ {
+ get => _previewGlowThickness;
+ set => SetProperty(ref _previewGlowThickness, value);
+ }
+
+ public string LiveElapsedText
+ {
+ get => _liveElapsedText;
+ set => SetProperty(ref _liveElapsedText, value);
+ }
+
+ // ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
+
+ /// The broadcast id of the current/last session; null until Go Live
+ /// succeeds. Gates the pull-out's Update button (nothing remote to patch).
+ public string? CurrentBroadcastId => _currentBroadcastId;
+
+ public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
+
+ // ─── Resolution quality dropdown (bottom bar) ───
+ // Tiers offered to the creator before going live. First = default. The
+ // dropdown is disabled while live because YouTube stream resolution is
+ // immutable after stream creation (see TASKS.md compliance notes).
+
+ // The composition master frame. Every tier is an output rect + target
+ // resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
+ // use the full frame; the vertical tier uses a centered 9:16 window and
+ // the preview dims the cropped strips (semi-crop).
+ private const double MasterFrameWidth = 1920;
+ private const double MasterFrameHeight = 1080;
+
+ public QualityOption[] QualityOptions { get; } =
+ {
+ new("1080p60", 60, 8.0, 1920, 1080),
+ new("1080p30", 30, 8.0, 1920, 1080),
+ new("720p60", 60, 6.0, 1280, 720),
+ new("720p30", 30, 6.0, 1280, 720),
+ new("Vertical 1080p60", 60, 8.0, 1080, 1920),
+ };
+
+ public string ResolutionHelp { get; } =
+ "Pick the highest resolution your upload bandwidth can comfortably sustain. " +
+ "Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
+ "then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
+
+ private QualityOption _selectedQuality;
+ public QualityOption SelectedQuality
+ {
+ get => _selectedQuality;
+ set
+ {
+ if (SetProperty(ref _selectedQuality, value))
+ ApplyStreamQuality(value);
+ }
+ }
+
+ // Output frame of the selected tier over the 1920x1080 master. The strips
+ // outside it are dimmed in the preview so the cropped area stays visible.
+ public double OutputRectX { get; private set; }
+ public double OutputRectY { get; private set; }
+ public double OutputRectWidth { get; private set; }
+ public double OutputRectHeight { get; private set; }
+ public bool IsOutputCropped { get; private set; }
+ public string ResolutionBadgeText { get; private set; } = string.Empty;
+ public ObservableCollection DimRects { get; } = new();
+
+ private void ApplyStreamQuality(QualityOption quality)
+ {
+ var health = CurrentHealth;
+ health.CurrentBitrate = quality.Bitrate;
+ health.FPS = quality.Fps;
+ OnPropertyChanged(nameof(CurrentHealth));
+
+ var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
+ var rectW = quality.Width * scale;
+ var rectH = quality.Height * scale;
+ var rectX = (MasterFrameWidth - rectW) / 2;
+ var rectY = (MasterFrameHeight - rectH) / 2;
+
+ OutputRectX = rectX;
+ OutputRectY = rectY;
+ OutputRectWidth = rectW;
+ OutputRectHeight = rectH;
+
+ DimRects.Clear();
+ if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
+ if (rectX + rectW < MasterFrameWidth)
+ DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
+ if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
+ if (rectY + rectH < MasterFrameHeight)
+ DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
+
+ IsOutputCropped = DimRects.Count > 0;
+ ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
+
+ OnPropertyChanged(nameof(OutputRectX));
+ OnPropertyChanged(nameof(OutputRectY));
+ OnPropertyChanged(nameof(OutputRectWidth));
+ OnPropertyChanged(nameof(OutputRectHeight));
+ OnPropertyChanged(nameof(IsOutputCropped));
+ OnPropertyChanged(nameof(ResolutionBadgeText));
+ }
+
+ public ICommand StartStreamCommand { get; }
+ 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/MainViewModel.cs b/ViewModels/MainViewModel.cs
index 5629916..63346b8 100644
--- a/ViewModels/MainViewModel.cs
+++ b/ViewModels/MainViewModel.cs
@@ -26,43 +26,9 @@ public partial class MainViewModel : ViewModelBase
private readonly YouTubeAuthService _youtubeAuth;
private readonly YouTubeStreamService _youtubeStream;
private readonly YouTubeChatService _youtubeChat;
- private readonly DispatcherTimer _liveTimer;
- private readonly DispatcherTimer _healthPollTimer;
private SceneElement? _selectedElement;
- private StreamStatus _streamStatus = StreamStatus.Offline;
- private StreamHealth _currentHealth = new();
- private string _streamTitle = string.Empty;
- private string _streamDescription = string.Empty;
- private string _streamVisibility = "Private";
- private string? _currentBroadcastId;
- private string? _reusableStreamUrl;
- private ReusableStream? _reusableStream;
- private string? _healthIssueBanner;
- private string _healthIssueBackground = "Transparent";
- private string _windowTitle = "LlamaCasty";
- private string _topBarBackground = "#16213e";
- private string _previewGlowBrush = "Transparent";
- private Thickness _previewGlowThickness = new(0);
- private string _liveElapsedText = "00:00:00";
- private double _recDotPulse = 1.0;
- private TimeSpan _liveElapsed;
-
- // ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
- // ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
- private bool _recordPillOn;
- private bool _onAirPillOn;
- private bool _isRecording;
- private string? _recordFolder;
-
- /// Resolved record folder — the configured choice, or Downloads (0th case).
- public string RecordFolderDisplay => _recordFolder ?? DefaultRecordFolder();
-
- private string? _activeRecordPath;
- private DateTime _recordStartTime;
- private TimeSpan _recordLength;
-
private bool _isSettingsOpen;
private bool _isBugOpen;
private bool _isFeatureOpen;
@@ -87,7 +53,6 @@ public partial class MainViewModel : ViewModelBase
// tier's FPS and paces frames into the encoder. The RTMP URL seam stays null
// until the live-stream create flow (TASK 5) supplies the reusable stream URL.
private readonly Func _rtmpUrlProvider;
- private readonly FramePump _framePump;
private readonly TransitionService _transition = new();
private readonly ChatBoxRenderer _chatBoxRenderer;
@@ -176,106 +141,6 @@ public partial class MainViewModel : ViewModelBase
&& !el.IsDraggable && !el.IsWebcam
&& el is not Source { Type: SourceType.Countdown or SourceType.WebSource or SourceType.ChatBox };
- public StreamStatus StreamStatus
- {
- get => _streamStatus;
- set
- {
- if (SetProperty(ref _streamStatus, value))
- {
- OnPropertyChanged(nameof(IsOffline));
- OnPropertyChanged(nameof(IsLive));
- OnPropertyChanged(nameof(IsEditMode));
- OnPropertyChanged(nameof(RecDotBrush));
- OnPropertyChanged(nameof(RecTextBrush));
- OnPropertyChanged(nameof(RecDotOpacity));
- OnPropertyChanged(nameof(OnAirBrush));
- OnPropertyChanged(nameof(IsLivePrivate));
- OnPropertyChanged(nameof(ShowPrimaryStartButton));
- OnPropertyChanged(nameof(ShowLogInButton));
- OnPropertyChanged(nameof(ShowEndStreamButton));
- OnPropertyChanged(nameof(EndButtonText));
- OnPropertyChanged(nameof(AccountStatusLightToolTip));
- OnPropertyChanged(nameof(ShowChatInactiveMessage));
- OnPropertyChanged(nameof(ShowPreviewPlaceholder));
- UpdateLiveVisuals();
- }
- }
- }
-
- public bool IsOffline => StreamStatus == StreamStatus.Offline;
- public bool IsLive => StreamStatus == StreamStatus.Streaming;
- public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase);
- public string RecDotBrush => !IsRecording ? "#555555" : "#e94560";
- public string RecTextBrush => (IsLive || IsRecording) ? "#ffffff" : "#888888";
- public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55;
- public string OnAirBrush => !IsLive ? "#555555" : IsLivePrivate ? "#22c55e" : "#e94560";
-
- // ─── REC / ON-AIR pills (TASK 18) ───
- public bool RecordPillOn
- {
- get => _recordPillOn;
- set
- {
- if (!SetProperty(ref _recordPillOn, value)) return;
- OnPropertyChanged(nameof(ShowPrimaryStartButton));
- OnPropertyChanged(nameof(CanStartSession));
- }
- }
-
- public bool OnAirPillOn
- {
- get => _onAirPillOn;
- set
- {
- if (value && !CanToggleOnAir) return; // needs a sign-in
- if (!SetProperty(ref _onAirPillOn, value)) return;
- OnPropertyChanged(nameof(ShowPrimaryStartButton));
- OnPropertyChanged(nameof(CanStartSession));
- }
- }
-
- /// The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.
- public bool CanToggleOnAir => IsConnected;
-
- /// Whether the session is actively recording — the REC status light
- /// turns green only when this is true (the pill is intent, this is reality).
- public bool IsRecording
- {
- get => _isRecording;
- private set
- {
- if (!SetProperty(ref _isRecording, value)) return;
- OnPropertyChanged(nameof(RecDotBrush));
- OnPropertyChanged(nameof(RecTextBrush));
- OnPropertyChanged(nameof(RecDotOpacity));
- OnPropertyChanged(nameof(IsEditMode));
- OnPropertyChanged(nameof(ShowEndStreamButton));
- OnPropertyChanged(nameof(ShowPrimaryStartButton));
- OnPropertyChanged(nameof(EndButtonText));
- UpdateSessionTimer();
- }
- }
-
- /// The primary button shows when there's something to start, but never
- /// while a session is running — once recording/live the ONLY control is the End
- /// button.
- public bool ShowPrimaryStartButton =>
- IsOffline && !IsRecording && !IsLive
- && (IsConnected || (RecordPillOn && !OnAirPillOn));
-
- /// Either output selected makes the button actionable.
- public bool CanStartSession => RecordPillOn || OnAirPillOn;
-
- /// The End button shows while recording OR live.
- public bool ShowEndStreamButton => IsLive || IsRecording;
-
- /// The one button whose label changes: while recording it reads
- /// "Stop Recording", while live "End Stream".
- public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : "End Stream";
-
- public bool ShowLogInButton => !IsConnected && IsOffline && !IsRecording;
-
public bool ShowChatInactiveMessage => !IsLive;
public bool ShowPreviewPlaceholder => !ShowEmptySceneHint && ActiveBackgroundImage == null && BackgroundImage == null;
@@ -306,86 +171,6 @@ public partial class MainViewModel : ViewModelBase
private set => SetProperty(ref _brandFlashActive, value);
}
- public StreamHealth CurrentHealth
- {
- get => _currentHealth;
- set => SetProperty(ref _currentHealth, value);
- }
-
- /// Report-by-exception YouTube health (TASK 5 item 3): non-null only
- /// while a warning/error configuration issue exists — the banner's text.
- /// good/ok/noData keep it null so nothing renders.
- public string? HealthIssueBanner
- {
- get => _healthIssueBanner;
- private set => SetProperty(ref _healthIssueBanner, value);
- }
-
- /// Banner background: app red (#8f1f1f) for an error issue, amber
- /// (#b8860b) for a warning. Transparent when no banner is shown.
- public string HealthIssueBackground
- {
- get => _healthIssueBackground;
- private set => SetProperty(ref _healthIssueBackground, value);
- }
-
- public string StreamTitle
- {
- get => _streamTitle;
- set => SetProperty(ref _streamTitle, value);
- }
-
- public string StreamDescription
- {
- get => _streamDescription;
- set => SetProperty(ref _streamDescription, value);
- }
-
- public string StreamVisibility
- {
- get => _streamVisibility;
- set
- {
- if (SetProperty(ref _streamVisibility, value))
- {
- OnPropertyChanged(nameof(RecDotBrush));
- OnPropertyChanged(nameof(RecTextBrush));
- OnPropertyChanged(nameof(OnAirBrush));
- OnPropertyChanged(nameof(IsLivePrivate));
- }
- }
- }
-
- public string WindowTitle
- {
- get => _windowTitle;
- set => SetProperty(ref _windowTitle, value);
- }
-
- public string TopBarBackground
- {
- get => _topBarBackground;
- set => SetProperty(ref _topBarBackground, value);
- }
-
- public string PreviewGlowBrush
- {
- get => _previewGlowBrush;
- set => SetProperty(ref _previewGlowBrush, value);
- }
-
- public Thickness PreviewGlowThickness
- {
- get => _previewGlowThickness;
- set => SetProperty(ref _previewGlowThickness, value);
- }
-
- public string LiveElapsedText
- {
- get => _liveElapsedText;
- set => SetProperty(ref _liveElapsedText, value);
- }
-
// Overlay panels
public bool IsSettingsOpen
{
@@ -431,14 +216,6 @@ public partial class MainViewModel : ViewModelBase
private set => SetProperty(ref _overlayTitle, value);
}
- // ─── Broadcast metadata pull-out (the "Text" tab on the Live screen) ───
-
- /// The broadcast id of the current/last session; null until Go Live
- /// succeeds. Gates the pull-out's Update button (nothing remote to patch).
- public string? CurrentBroadcastId => _currentBroadcastId;
-
- public Services.LiveBroadcastFormViewModel BroadcastForm { get; }
-
// Window geometry pass-through (code-behind owns the WPF specifics).
public (double Left, double Top, double Width, double Height, string State)? RestoreWindowState()
=> _layoutStore.LoadWindowState();
@@ -446,90 +223,6 @@ public partial class MainViewModel : ViewModelBase
public void PersistWindowState(double left, double top, double width, double height, string state)
=> _layoutStore.SaveWindowState(left, top, width, height, state);
- // ─── Resolution quality dropdown (bottom bar) ───
- // Tiers offered to the creator before going live. First = default. The
- // dropdown is disabled while live because YouTube stream resolution is
- // immutable after stream creation (see TASKS.md compliance notes).
-
- // The composition master frame. Every tier is an output rect + target
- // resolution over this frame (see ai.md "Resolution tiers"): 16:9 tiers
- // use the full frame; the vertical tier uses a centered 9:16 window and
- // the preview dims the cropped strips (semi-crop).
- private const double MasterFrameWidth = 1920;
- private const double MasterFrameHeight = 1080;
-
- public QualityOption[] QualityOptions { get; } =
- {
- new("1080p60", 60, 8.0, 1920, 1080),
- new("1080p30", 30, 8.0, 1920, 1080),
- new("720p60", 60, 6.0, 1280, 720),
- new("720p30", 30, 6.0, 1280, 720),
- new("Vertical 1080p60", 60, 8.0, 1080, 1920),
- };
-
- public string ResolutionHelp { get; } =
- "Pick the highest resolution your upload bandwidth can comfortably sustain. " +
- "Find your upload speed on your ISP plan or with a speed test (e.g. fast.com), " +
- "then choose a tier your upload comfortably exceeds. 1080p60 is the default.";
-
- private QualityOption _selectedQuality;
- public QualityOption SelectedQuality
- {
- get => _selectedQuality;
- set
- {
- if (SetProperty(ref _selectedQuality, value))
- ApplyStreamQuality(value);
- }
- }
-
- // Output frame of the selected tier over the 1920x1080 master. The strips
- // outside it are dimmed in the preview so the cropped area stays visible.
- public double OutputRectX { get; private set; }
- public double OutputRectY { get; private set; }
- public double OutputRectWidth { get; private set; }
- public double OutputRectHeight { get; private set; }
- public bool IsOutputCropped { get; private set; }
- public string ResolutionBadgeText { get; private set; } = string.Empty;
- public ObservableCollection DimRects { get; } = new();
-
- private void ApplyStreamQuality(QualityOption quality)
- {
- var health = CurrentHealth;
- health.CurrentBitrate = quality.Bitrate;
- health.FPS = quality.Fps;
- OnPropertyChanged(nameof(CurrentHealth));
-
- var scale = Math.Min(MasterFrameWidth / quality.Width, MasterFrameHeight / quality.Height);
- var rectW = quality.Width * scale;
- var rectH = quality.Height * scale;
- var rectX = (MasterFrameWidth - rectW) / 2;
- var rectY = (MasterFrameHeight - rectH) / 2;
-
- OutputRectX = rectX;
- OutputRectY = rectY;
- OutputRectWidth = rectW;
- OutputRectHeight = rectH;
-
- DimRects.Clear();
- if (rectX > 0) DimRects.Add(new Rect(0, 0, rectX, MasterFrameHeight));
- if (rectX + rectW < MasterFrameWidth)
- DimRects.Add(new Rect(rectX + rectW, 0, MasterFrameWidth - rectX - rectW, MasterFrameHeight));
- if (rectY > 0) DimRects.Add(new Rect(0, 0, MasterFrameWidth, rectY));
- if (rectY + rectH < MasterFrameHeight)
- DimRects.Add(new Rect(0, rectY + rectH, MasterFrameWidth, MasterFrameHeight - rectY - rectH));
-
- IsOutputCropped = DimRects.Count > 0;
- ResolutionBadgeText = $"{quality.Label} · {quality.Width}×{quality.Height}";
-
- OnPropertyChanged(nameof(OutputRectX));
- OnPropertyChanged(nameof(OutputRectY));
- OnPropertyChanged(nameof(OutputRectWidth));
- OnPropertyChanged(nameof(OutputRectHeight));
- OnPropertyChanged(nameof(IsOutputCropped));
- OnPropertyChanged(nameof(ResolutionBadgeText));
- }
-
public string BugReportText
{
get => _bugReportText;
@@ -561,10 +254,6 @@ public partial class MainViewModel : ViewModelBase
public ICommand RemoveSourceCommand { get; }
public ICommand EditElementCommand { get; }
public ICommand ToggleElementVisibilityCommand { get; }
- public ICommand StartStreamCommand { get; }
- public ICommand EndStreamCommand { get; }
- public ICommand ChooseRecordFolderCommand { get; }
- public ICommand ResetRecordFolderCommand { get; }
public ICommand OpenSettingsCommand { get; }
public ICommand OpenBugCommand { get; }
public ICommand OpenFeatureCommand { get; }
@@ -1257,349 +946,6 @@ public partial class MainViewModel : ViewModelBase
_chatFadeTimer.Start();
}
- /// 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);
- }
-
-
// Scene-element → latest frame, for the live compositor. The map mirrors the
// preview: webcam by DeviceId, live captures (the background) by CaptureKey,
// images/background by AssetId. A null frame leaves the element transparent.
@@ -1745,114 +1091,6 @@ public partial class MainViewModel : ViewModelBase
};
}
- // 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));
- }
-
// First flash shortly after go-live, then every 300s (the cadence resets on
// the first tick so the interval is 5s only for that one shot).
private void StartBrandFlashTimer()
@@ -1878,13 +1116,4 @@ public partial class MainViewModel : ViewModelBase
_brandFlashOffTimer.Start();
}
- 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 a197645..65e56b4 100644
--- a/ViewModels/index.md
+++ b/ViewModels/index.md
@@ -8,7 +8,7 @@ areas' docs in their own rows keeps this index small to read.
| File | Purpose |
|------|---------|
-| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Broadcast/stream state:** go-live (`BeginGoLive` accepts the GoLive dialog → `StreamVisibility` always "Private" → `CreateBroadcastAsync` storing `_currentBroadcastId` for the bind/transition; failure → `StreamStatus.Error` + `_framePump.StartAsync()`), End (`StopStream` — signs out, clears session/token/`_currentBroadcastId`, crash-safe), `StreamStatus`/`StreamVisibility` setters, `IsOffline`/`IsLive`/`IsLivePrivate`. **Health stats:** `CurrentHealth`/`ResetHealth`/`OnFramePumpHealthUpdated` marshal `FramePump.HealthUpdated` (encoder bitrate/FPS/dropped/duration) off the stderr thread onto the UI thread. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`, `CanStartSession`, `ShowPrimaryStartButton`/`ShowEndStreamButton`/`EndButtonText`), REC sign (`RecDotBrush`/`RecTextBrush`/`RecDotOpacity`, pulsing live via `_recDotPulse`), `ShowLogInButton`, `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` computes the output rect over the 1920×1080 master (`OutputRectX/Y/W/H`, `DimRects`, `IsOutputCropped`, `ResolutionBadgeText`). **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`/`ShowChatInactiveMessage`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`). |
+| `MainViewModel.cs` | Core. `public partial class MainViewModel : ViewModelBase`. **Source commands + element rows:** scenes are pure selection rows; sources carry edit/visibility/trash (`EditElementCommand` sets `element.IsEditing`, `ToggleElementVisibilityCommand` flips `IsVisible`, `RemoveSourceCommand`; Backdrop trash slot reserved via `HiddenBoolToVisibilityConverter` — TASK 13); shared `NextSourceName` (Image, Image2, Image3… no-space increment from actual names — used by `AddSource` + `AddReusedImage`), `AddImageCommand`. **Overlays/lifecycle:** fields + ctor wiring (all command assignments, mixer/camera/chat/hotkey/subscription hookups), `Shutdown`/`SaveLayoutNow`/`ScheduleSave`/`LoadLayout`, `SetPanel`/`ShowOverlay`/`ShowLicenses`, `CreateBrush` + shared `BarOnBrush`/`BarOffBrush` (referenced by Audio/Trax/Socials partials), `OpenUrl`/`ComposeEmail`/`SubmitBug`/`SubmitFeature`, window-geometry pass-through, `SelectedElement`/`OnSelectedElementPropertyChanged`, `ShowPreviewPlaceholder`/`ShowChatInactiveMessage`, `BrandFlash*`, WebView2 hosting (`InitWebView2`/`RegisterLoadedWebSources`/`OnWebView2PreviewBitmapChanged`), frame-pump wiring (construction + `_rtmpUrlProvider` seam + `Dispose`) — the pump's stream-state consumers live in the Streaming partial. |
| `MainViewModel.Account.cs` | Session + connected account: `LoadSavedSessionAsync` (DPAPI restore at startup), `SignInAsync`/`ChangeAccountAsync`/`SignOutYouTubeAsync`, `IsConnected`/`AccountAvatarUrl`/`AccountDisplayName`/`AccountStatusLightToolTip` via `SyncConnectedAccount` (cleared on End). |
| `MainViewModel.License.cs` | Polar license (freemium): `LicenseKey`/`LicenseMessage`/`IsLicenseValidating`/`IsLicenseEntryOpen`/`IsHubVisible`/`IsPremium`, `OfflineGracePeriod`, `ValidateLicenseAtStartupAsync`/`ActivateLicenseAsync`. |
| `MainViewModel.Scenes.cs` | Five-scene catalog: `Scenes` + `StagedScene`/`LiveScene`/`IsEditMode`, `AddScene` (canonical names only), `MissingScenes`/`ShowAddScene`/`AddSceneCommand`; scene wiring `OnScenesChanged`/`WireScene`/`UnwireScene`; transitions `SelectedTransitionType`/`TransitionDurationMs`/`TransitionToScene`/`ApplyTransitionToLive`; global hotkeys `HandleHotkey`/`OpenHotkeyConfig`/`RefreshHotkeyLabels`; scene-gating hints (`CanChangeBackground`). |
@@ -17,6 +17,7 @@ areas' docs in their own rows keeps this index small to read.
| `MainViewModel.Audio.cs` | **Mic bar:** read-only realtime meter `AudioLevel`/`MeterFillWidth`/`MeterBrush` (fill = `Math.Min(1, AudioLevelMeter.ToDisplay((float)AudioLevel) * MicVolume)`, green→yellow→red at 60%/80% zones), `MicVolume` (0.8 default; drives `MicMuted` ⇔ volume 0; unmute flash `BeginVolumeFlash`/`EndVolumeFlash`/`CancelVolumeFlash`), `MicSourceName` (left-justified inside the bar, persists via `LayoutStore.Settings`), `ToggleMicMute`/`PickMicrophone` (`MicPickerDialog`; picked device swaps the live source via `_audioMixer.RestartMic()`, loopback keeps running), status dot `MicStatus`/`MicStatusBrush`/`MicStatusToolTip` (`Models.MicStatus`: green Connected / yellow Problem / red no-device), capture starts once at startup (`StartMicCaptureAsync`) — NOT go-live. **Game audio bar:** overlaid bottom-center, always visible (show/hide gating removed 2026-08-15); `GameAudioLevel` (loopback via `OnLoopbackLevelChanged`), `GameAudioVolume`/`GameMuted`/`GameMuteText`/`ToggleGameMute`/`PushSystemVolume`, mirror volume flash `SetGameVolumeAdjusting`/`BeginGameVolumeFlash`/`EndGameVolumeFlash`/`CancelGameVolumeFlash`. Mixer handlers `OnMicLevelChanged`/`OnLoopbackLevelChanged`/`OnMicConnected`/`OnMicFailed`. |
| `MainViewModel.Trax.cs` | Background music (TASK 9, rides the loopback into the stream): `_musicPlayer` (`MusicPlayer`), `IsMusicPlaying`/`TraxError`/`TraxDotBrush`/`TraxToolTip`, interaction `TraxLeftClick`/`TraxRightClick`/`PickTraxTrack`, playback `LoadTrax`/`PlayTrax`/`PauseTrax`/`OnTraxPlaybackEnded`/`RefreshTraxUi`, `UpdateTraxLocalGain` (mirrors the game bar's mute onto `MusicPlayer.LocalGain` so the track ducks/restores with desktop audio). |
| `MainViewModel.Socials.cs` | Social bar (global resource, overlaid on every scene): `_socials`/`_socialBarFrame`/`_socialValidator` (`ISocialValidator`/`HttpSocialValidator`), `HasSocials`/`SocialBarVisible`/`SocialBarDotBrush`/`SocialBarGlowBrush`/`Socials`/`SocialBarTop`, `OpenSocialDialogCommand`/`OpenSocialDialog` (spawns `SocialsDialogViewModel` + window; saved copy replaces `_socials`), `NotifySocialsChanged`/`RenderSocialBarFrame` (`SocialBarRenderer` → immutable `_socialBarFrame` the frame pump reads), fediverse heal `HealFediverseSoftwareAsync` (static, test-facing) + `HealFediverseSoftwareInBackground`, bar edge toggle `SetSocialBarPosition`/`ToggleSocialBarPosition`. `BarOnBrush`/`BarOffBrush`/`CreateBrush` stay in core (shared tokens). |
+| `MainViewModel.Streaming.cs` | Go-live/record + health + quality tiers + session UI. **Stream state:** `StreamStatus`/`StreamVisibility` setters + `_streamStatus` field, `IsOffline`/`IsLive`/`IsLivePrivate`. **Go-live flow:** `StartSession` (routes by pill state) → `BeginRecordOnly` (signed-out local recording) or `BeginGoLive` (GoLive dialog → captures title/description/visibility + `BroadcastForm.CaptureGoLive` → `PrepareAndStartLiveAsync`: reusable stream → `CreateBroadcast` (stores `_currentBroadcastId`) → empty-metadata warn → chat poll start (liveChatId) → `_framePump.StartAsync()` + health poll; failures → `StreamStatus.Error`, never a crash); `StartRecordFile` (auto-named `ty-…-0000.mp4`) + `FinalizeRecordingAsync` (rename modal, `UniquePath`); `StopStream` (posts Offline, stops chat/audio pipe/pump, clears `_currentBroadcastId`; session/token deliberately NOT cleared — sign-out stays explicit). **Health:** `CurrentHealth`/`ResetHealth`/`ApplyHealth`/`OnFramePumpHealthUpdated` (marshal `FramePump.HealthUpdated` off the stderr thread), `PollHealthAsync`/`OnHealthPollTick`/`ApplyHealthIssue` (report-by-exception banner `HealthIssueBanner`/`HealthIssueBackground` via `StreamHealthReporter`). **Session timer:** `_liveTimer` (ctor-wired) `UpdateSessionTimer`/`OnLiveTimerTick`/`LiveElapsedText`/`_recDotPulse`. **Start/End UI:** REC/ON-AIR pills (`RecordPillOn`/`OnAirPillOn`/`CanToggleOnAir`), `ShowPrimaryStartButton`/`CanStartSession`/`ShowEndStreamButton`/`EndButtonText`/`ShowLogInButton`, REC sign `RecDotBrush`/`RecTextBrush`/`RecDotOpacity`/`OnAirBrush`, live window tokens `WindowTitle`/`TopBarBackground`/`PreviewGlow*` via `UpdateLiveVisuals`; `ChooseRecordFolder`/`ResetRecordFolder`/`_recordFolder`/`RecordFolderDisplay`/`DefaultRecordFolder`. **Resolution dropdown:** `QualityOptions`/`SelectedQuality`/`ResolutionHelp`/`ApplyStreamQuality` + `OutputRect*`/`DimRects`/`IsOutputCropped`/`ResolutionBadgeText` over the 1920×1080 master. **Pipeline bits:** `_framePump`/`_liveTimer`/`_healthPollTimer` fields, `BuildEncoderOptions`. Commands `StartStreamCommand`/`EndStreamCommand`/`ChooseRecordFolderCommand`/`ResetRecordFolderCommand`. Broadcast pull-out: `CurrentBroadcastId`/`BroadcastForm`. |
| `GoLiveViewModel.cs` | Start Stream dialog: **account row** (saved channel shown with Change Account, or Sign in to YouTube; Start gated on `IsSignedIn`/`IsBusy`) + title/description + **locked Visibility (always "Private" — ship step 7, no dropdown)**, start/cancel requests |
| **About hub (TASK 11, in `MainViewModel`)** | the About overlay is the in-app creator hub: `ChannelUrl`/`MastodonUrl`/`CoffeeUrl` (public consts, live) + `PremiumUrl` (tabled billing seam — TASK 12 → the greyed "Unlock Premium" button lights up when filled); `OpenLicensesCommand` → `ShowLicenses()` loads the shipped `THIRD-PARTY-NOTICES.txt` into `LicensesText` and sets `IsLicensesOpen` (in-app scrolling panel, never the OS viewer); `BackToAbout_Click` in `MainWindow.xaml.cs` flips it off. |
| `ReuseImageViewModel.cs` | Add Image dialog: candidate list (`ReuseImageCandidate`), reuse/new/cancel |
@@ -45,7 +46,7 @@ Progress (Commit N/12 · tag):
- [x] **Commit 6/12 · `refactor-commit-6`** — **Audio** → `MainViewModel.Audio.cs`
- [x] **Commit 7/12 · `refactor-commit-7`** — **Trax** → `MainViewModel.Trax.cs`
- [x] **Commit 8/12 · `refactor-commit-8`** — **Socials** → `MainViewModel.Socials.cs`
-- [ ] **Commit 9/12** — **Streaming** (go-live/record + health + quality tiers + media source pipeline)
+- [x] **Commit 9/12 · `refactor-commit-9`** — **Streaming** → `MainViewModel.Streaming.cs`
- [ ] **Commit 10/12** — **Chat** (YouTube chat + mock preview + chatbox render)
- [ ] **Commit 11/12** — **Overlays** (settings/bug/feature/about panels + license entry)