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();
RaiseActivityStage(); // IsLive drives ShowOutputStage
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
RaiseActivityStage();
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
RaiseActivityStage();
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();
RaiseActivityStage(); // Start Recording <-> Stop Recording, and the REC clock
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.
// Live only: a local recording's status lives in the output pill now (creator, build 730), so its
// separate dot/elapsed/stop cluster would be a second, redundant set of the same facts.
public bool RunningVisible => IsLive;
/// 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; }
}