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; /// Latches once the broadcast reaches live so the per-poll broadcast /// diagnostic stops logging (2026-10-04). Reset at broadcast creation. private bool _broadcastSeenLive; private bool _youtubeBroadcastLive; private string? _reusableStreamUrl; private ReusableStream? _reusableStream; private string? _healthIssueBanner; private string _healthIssueBackground = "Transparent"; private string _windowTitle = "LlamaCasty"; private string _topBarBackground = ThemeBrush.Hex("Brush.SurfaceContainer"); 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): REC / ON-AIR pills — the pills are intent; // ─── IsRecording / IsLive are reality. ON-AIR needs a sign-in, and the // ─── pills are radio-exclusive (record-OR-live, creator ruling 2026-09-01): // ─── arming one clears the other. // Local MP4 is the armed output at launch (creator ruling 2026-11-12: "pos = ON (default)"). // Before that both were unarmed, so the output stage opened with nothing selected. private bool _recordPillOn = true; private bool _onAirPillOn; private bool _isRecording; /// TASK 41: the current session is a private TEST broadcast — the real /// go-live pipeline runs (broadcast create, reusable stream, RTMP push, chat /// poll), but the visuals say TEST (top-bar gold + badge), no "last live" stamp /// is written, and the Test drawer is open. One flag flips the whole face of the /// session; cleared on stop / rollback. private bool _isTestStream; /// Resolved record folder — the configured choice, or Downloads (0th case). private readonly FramePump _framePump; public StreamStatus StreamStatus { get => _streamStatus; set { if (SetProperty(ref _streamStatus, value)) { _youtubeBroadcastLive = false; OnPropertyChanged(nameof(IsOffline)); OnPropertyChanged(nameof(CanChangeQuality)); 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(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); OnPropertyChanged(nameof(PrimaryStartCommand)); OnPropertyChanged(nameof(ShowEndStreamButton)); OnPropertyChanged(nameof(EndButtonText)); OnPropertyChanged(nameof(ShowTestButton)); OnPropertyChanged(nameof(AccountStatusLightToolTip)); OnPropertyChanged(nameof(ShowChatInactiveMessage)); OnPropertyChanged(nameof(ShowPreviewPlaceholder)); RaiseTopBarModes(); UpdateLiveVisuals(); } } } public bool IsOffline => StreamStatus == StreamStatus.Offline; public bool IsLive => StreamStatus == StreamStatus.Streaming; public bool CanChangeQuality => IsOffline && !IsRecording; public bool IsLivePrivate => IsLive && string.Equals(StreamVisibility, "Private", StringComparison.OrdinalIgnoreCase); public string RecDotBrush => !IsRecording ? ThemeBrush.Hex("Brush.OnSurfaceDisabled") : ThemeBrush.Hex("Brush.Primary"); public string RecTextBrush => (IsLive || IsRecording) ? ThemeBrush.Hex("Brush.OnSurface") : ThemeBrush.Hex("Brush.OnSurfaceDisabled"); public double RecDotOpacity => IsRecording ? _recDotPulse : 0.55; public string OnAirBrush => !IsLive ? ThemeBrush.Hex("Brush.OnSurfaceDisabled") : IsLivePrivate ? ThemeBrush.Hex("Brush.Positive") : ThemeBrush.Hex("Brush.Primary"); // ─── REC / ON-AIR pills (TASK 18) ─── public bool RecordPillOn { get => _recordPillOn; set { if (!SetProperty(ref _recordPillOn, value)) return; if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR RaiseOutputStage(); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); OnPropertyChanged(nameof(PrimaryStartCommand)); OnPropertyChanged(nameof(CanStartSession)); RaiseTopBarModes(); } } public bool OnAirPillOn { get => _onAirPillOn; set { // Armable signed-out (creator ruling 2026-09-20: a greyed pill was a // dead end — intent must be able to light while offline). The pill is // intent, not reality; the Start face turns this into a sign-in. if (!SetProperty(ref _onAirPillOn, value)) return; if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC RaiseOutputStage(); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); OnPropertyChanged(nameof(PrimaryStartCommand)); OnPropertyChanged(nameof(CanStartSession)); RaiseTopBarModes(); } } /// Whether the session is actively recording — the REC status light /// turns green only when this is true (the pill is intent, this is reality). /// Internal setter is a test seam (same pattern as LayoutPathOverride) so the /// failure-rollback path can be exercised without running the real pipeline. public bool IsRecording { get => _isRecording; internal set { if (!SetProperty(ref _isRecording, value)) return; OnPropertyChanged(nameof(CanChangeQuality)); OnPropertyChanged(nameof(RecDotBrush)); OnPropertyChanged(nameof(RecTextBrush)); OnPropertyChanged(nameof(RecDotOpacity)); OnPropertyChanged(nameof(IsEditMode)); OnPropertyChanged(nameof(ShowEndStreamButton)); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(PrimaryStartKind)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); OnPropertyChanged(nameof(PrimaryStartCommand)); OnPropertyChanged(nameof(EndButtonText)); OnPropertyChanged(nameof(ShowTestButton)); RaiseTopBarModes(); UpdateSessionTimer(); } } /// The primary button is ALWAYS the idle face of the top bar (2026-09-01): /// stopping a session used to hide every start affordance (pills clear on stop, /// and the old gate needed a sign-in or a lit pill — signed-out creators found a /// blank bar). No dead ends: an unarmed Start records (local needs no account); /// sign-in lives in the Start context menu. public bool ShowPrimaryStartButton => IsOffline && !IsRecording && !IsOffAirAfterDrop; /// Either output selected makes the button actionable. public bool CanStartSession => RecordPillOn || OnAirPillOn; /// Face of the Go button names the armed world (top bar redesign): the /// bar renders only ONE world at a time, so the button never has to explain /// itself — it says exactly what pressing it will do. A signed-out ON-AIR Start /// routes to the sign-in inside StartSession (Start — the account step shows up /// beside it as the Sign In button); a signed-out go-live stays the dead-end the /// creator ruling 2026-09-20-02 forbids. /// Creator ruling 2026-10-30: EDIT decides whether the ON-AIR start is the real /// livestream or the test one, which is why the separate TEST button is gone. public SessionStartKind PrimaryStartKind => SessionStartDecision.For(RecordPillOn, OnAirPillOn); public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind); private ICommand? _primaryStartCommand; /// The button's action follows its label — and a signed-out ON-AIR press starts the /// SIGN-IN flow instead of disabling the control (which is what binding straight to /// did: its CanExecute needs a connection, so the button the /// creator was told to press could not be pressed). public ICommand PrimaryStartCommand => _primaryStartCommand ??= new RelayCommand(_ => StartFromPrimaryButton(), _ => ShowPrimaryStartButton); private void StartFromPrimaryButton() { switch (SessionStartDecision.RouteFor(PrimaryStartKind, IsConnected)) { case SessionStartDecision.Route.SignIn: _ = SignInAsync(); return; case SessionStartDecision.Route.Test: BeginTestStream(); return; default: StartSession(); return; } } /// 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" — and while a test broadcast runs, /// "End Test" so the creator knows exactly what they're stopping. public string EndButtonText => IsRecording && !IsLive ? "Stop Recording" : IsTestStream ? "End Test" : "End Live Stream"; /// TASK 41 — Test is a child of Stream (creator ruling: "Test should only /// proc when we've selected stream"): a dry-run of the livestream, so it exists /// ONLY inside the armed ON-AIR world, signed-in and idle. You can't dry-run a /// recording, so the Record world never offers it. public bool ShowTestButton => ShowStreamActions && IsConnected; public bool CanStartTest => ShowTestButton; // ─── Top bar worlds / reality line (top bar redesign) ─── // // The bar is ONE surface rendered by the FIRST decision — Record or Stream — // and each world is the whole bar. Idle shows the mode switch + the armed // world's actions; the account zone exists only inside the Stream world. While // running, the switch and the worlds retire and a single reality line // (● word elapsed) + End replace them, so nothing mid-session can be re-armed. /// Idle — the mode switch is present and actionable. public bool ShowIdleWorld => IsOffline && !IsRecording; /// Running — the reality line (dot + word + elapsed) + End replace the switch. public bool RunningVisible => IsLive || IsRecording; /// Record world: the switch sits on REC and Go says "Start Recording". /// No Test — YouTube's stream actions don't exist here. The account zone IS /// here: it's app-level identity, not a stream-world widget (creator 2026-09-23). public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn; /// Stream world: the switch sits on ON-AIR; Go says "Go Live" and Test /// appears. public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn; /// Account zone is WORLD-INDEPENDENT (creator 2026-09-23): Sign In / /// avatar stay put in Record and Stream worlds alike, so the right column never /// pops in and out — which pins the bar height (no more bounce between worlds). public bool ShowSignInButton => !IsConnected; /// The avatar — right-click Change Account / Logout. Always visible when /// signed in, Record or Stream. public bool ShowAvatarButton => IsConnected; /// The reality word: TEST while a test broadcast runs, LIVE while on the /// air, REC during a local-record session. public string RunningText => IsReconnecting ? "RECONNECTING" : IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC"; /// Measured facts only while reconnecting: how long, and which attempt. No countdown — /// YouTube's cutoff is not a constant we can promise (spec revised 2026-11-05). public string ReconnectDetailText => IsReconnecting ? $"0:{ReconnectElapsedSeconds:00} · attempt {Math.Max(1, ReconnectAttempt)} of {StreamRetryPolicy.Default.Attempts}" : string.Empty; /// Reality dot color, one meaning per mode: gold = test, red = live, /// green = recording. The pre-spec pulsing lamp never shipped; the word + bar /// color carry the state and the dot just pins it. public string RunningDotBrush => IsReconnecting ? ThemeBrush.Hex("Brush.Attention") : IsTestStream ? ThemeBrush.Hex("Brush.TestContainer") : IsLive ? ThemeBrush.Hex("Brush.Primary") : ThemeBrush.Hex("Brush.Positive"); /// Raises every flag the top bar renders, so any state change re-renders /// exactly the world that is now true. private void RaiseTopBarModes() { OnPropertyChanged(nameof(ShowIdleWorld)); OnPropertyChanged(nameof(RunningVisible)); OnPropertyChanged(nameof(ShowRecordAction)); OnPropertyChanged(nameof(ShowStreamActions)); OnPropertyChanged(nameof(ShowSignInButton)); OnPropertyChanged(nameof(ShowAvatarButton)); OnPropertyChanged(nameof(ShowTestButton)); OnPropertyChanged(nameof(CanStartTest)); OnPropertyChanged(nameof(RunningText)); OnPropertyChanged(nameof(RunningDotBrush)); OnPropertyChanged(nameof(PrimaryStartButtonLabel)); } /// TASK 41 — the current session is a test broadcast (drives the TEST /// badge + gold top bar + "End Test" face). Internal setter is a test seam like /// IsRecording: flips it before /// goes Streaming so the visuals initialize amber, and the stop/rollback path /// clears it. public bool IsTestStream { get => _isTestStream; internal set { if (!SetProperty(ref _isTestStream, value)) return; OnPropertyChanged(nameof(EndButtonText)); RaiseTopBarModes(); UpdateLiveVisuals(); } } 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: the error surface for an error issue, the warning surface /// for a warning (both tokens). 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); } internal void ApplyBroadcastLifecycleStatus(string? lifeCycleStatus) { if (!IsLive) return; if (!string.Equals(lifeCycleStatus, "live", StringComparison.OrdinalIgnoreCase)) { if (!_broadcastSeenLive) _healthPollTimer.Interval = TimeSpan.FromSeconds(2); return; } _broadcastSeenLive = true; _youtubeBroadcastLive = true; _healthPollTimer.Interval = TimeSpan.FromSeconds(30); UpdateLiveVisuals(); } public string PreviewGlowBrush { get => _previewGlowBrush; set => SetProperty(ref _previewGlowBrush, value); } public Thickness PreviewGlowThickness { get => _previewGlowThickness; set => SetProperty(ref _previewGlowThickness, value); } /// When the current/last streaming session started — the window a session report covers. /// Null when no session has run since launch. private DateTime? _sessionStartedAtUtc; 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; /// /// The shipped ladder. The rate on each tier is the creator-facing target the encoder is asked to /// hold (CBR — see FfmpegArgs.AddRateControl), between YouTube's H.264 recommended value /// (17 Mbps at 1080p60, 14 at 1080p30, 8 at 720p) and the practitioner sweet spot most guides land /// on for YouTube. The old flat 8 Mbps was inherited from TASK 4 as a "mainstream ceiling" — an /// unmeasured round number that produced a 5.1 Mbps average. Vertical mirrors its landscape pair: /// 1080×1920 is the same pixel count as 1920×1080. /// public QualityOption[] QualityOptions { get; } = { new("1080p60", 60, 12.0, 1920, 1080), new("1080p30", 30, 10.0, 1920, 1080), new("720p60", 60, 6.0, 1280, 720), new("720p30", 30, 6.0, 1280, 720), new("Vertical 1080p30", 30, 10.0, 1080, 1920), new("Vertical 1080p60", 60, 12.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 (!CanChangeQuality) { OnPropertyChanged(nameof(SelectedQuality)); return; } if (!HasProOutput && value.RequiresPro) { OnPropertyChanged(nameof(SelectedQuality)); return; } if (SetProperty(ref _selectedQuality, value)) { ApplyStreamQuality(value); OnPropertyChanged(nameof(RatedMbps)); OnPropertyChanged(nameof(RateHint)); RefreshRateProposal(); } } } // 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 = EffectiveQuality(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; RebuildQualityDimRects(); 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)); // TASK 57 (the "vv" feature): the tier decides which camera placement // is live — the vertical tier brings each scene's own vertical placement // in, 16:9 puts the desktop layout back exactly where it was. OnPropertyChanged(nameof(IsVerticalTier)); OnPropertyChanged(nameof(VerticalBand)); OnPropertyChanged(nameof(CanToggleClipShape)); if (ResolveVerticalCameraLayouts(VerticalCameraLayout.IsVerticalTier(quality))) ScheduleSave(); } public ICommand StartStreamCommand { get; } public ICommand StartTestCommand { get; } public ICommand EndStreamCommand { get; } public ICommand SignInCommand { get; } public ICommand ChooseRecordFolderCommand { get; } public ICommand ResetRecordFolderCommand { get; } }