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)); } }