From e1006a04ab59ec800f22f297b44d519d9bf01e94 Mon Sep 17 00:00:00 2001 From: gramps Date: Sun, 30 Aug 2026 08:45:36 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20Streaming=20out=20of=20Main?= =?UTF-8?q?ViewModel=20=E2=80=94=20Commit=209/12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MainViewModel.Streaming.cs (787 lines) takes the full go-live/record flow (StartSession/BeginRecordOnly/BeginGoLive/PrepareAndStartLiveAsync/StopStream, record-file naming + rename-on-stop), health pipeline (FramePump.Handle + poll, report-by-exception banner), session timer, REC/ON-AIR start/end UI, the resolution quality dropdown over the 1920x1080 master, live window visual tokens (UpdateLiveVisuals), record-folder commands, broadcast pull-out (CurrentBroadcastId/BroadcastForm) and the stream-state setters (StreamStatus/StreamVisibility) + their fields. Core drops 1890 -> 1119 lines. Cross-partial references (IsConnected, IsEditMode, BrandFlash*, _audioMixer, ctor wiring) are safe on one partial class. Zero behavior change; build 0 warnings; 246 pass, only the 2 known pre-existing failures (audio-mix gain + round-clip grab). Docs: index.md Streaming row added + core row trimmed in the same commit. --- ViewModels/MainViewModel.Streaming.cs | 789 ++++++++++++++++++++++++++ ViewModels/MainViewModel.cs | 771 ------------------------- ViewModels/index.md | 5 +- 3 files changed, 792 insertions(+), 773 deletions(-) create mode 100644 ViewModels/MainViewModel.Streaming.cs 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)