using System.Collections.ObjectModel; using System.Windows; using System.Windows.Input; using System.Windows.Threading; 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; } }