Files
LlamaCasty/ViewModels/MainViewModel.Streaming.cs
T
gramps 0f8647790d fix(top bar): a signed-out ON-AIR press starts sign-in instead of disabling the button
Reported: EDIT on + ON-AIR on, signed out → the button reads "Test Stream" and cannot be pressed.
Cause: the primary button bound straight to StartTestCommand, whose CanExecute needs a connection.
StartSession already routed signed-out ON-AIR to the sign-in flow; the test path bypassed it.

The route is now explicit and pure: RouteFor(kind, connected) → SignIn | Record | Live | Test. The
primary command calls SignInAsync for the SignIn route, BeginTestStream for Test, StartSession
otherwise — so the label, the action and the sign-in fallback cannot drift apart. Pinned in
SessionStartDecisionTests for both live and test, connected and not.
2026-10-30 11:17:11 -07:00

497 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/// <summary>Latches once the broadcast reaches live so the per-poll broadcast
/// diagnostic stops logging (2026-10-04). Reset at broadcast creation.</summary>
private bool _broadcastSeenLive;
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.
private bool _recordPillOn;
private bool _onAirPillOn;
private bool _isRecording;
/// <summary>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.</summary>
private bool _isTestStream;
/// <summary>Resolved record folder — the configured choice, or Downloads (0th case).</summary>
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(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 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
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
OnPropertyChanged(nameof(ShowPrimaryStartButton));
OnPropertyChanged(nameof(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(CanStartSession));
RaiseTopBarModes();
}
}
/// <summary>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.</summary>
public bool IsRecording
{
get => _isRecording;
internal 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(PrimaryStartKind));
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
OnPropertyChanged(nameof(PrimaryStartCommand));
OnPropertyChanged(nameof(EndButtonText));
OnPropertyChanged(nameof(ShowTestButton));
RaiseTopBarModes();
UpdateSessionTimer();
}
}
/// <summary>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.</summary>
public bool ShowPrimaryStartButton => IsOffline && !IsRecording;
/// <summary>Either output selected makes the button actionable.</summary>
public bool CanStartSession => RecordPillOn || OnAirPillOn;
/// <summary>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.</summary>
/// <summary>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.</summary>
public SessionStartKind PrimaryStartKind =>
SessionStartDecision.For(RecordPillOn, OnAirPillOn, EditingActive);
public string PrimaryStartButtonLabel => SessionStartDecision.LabelFor(PrimaryStartKind);
private ICommand? _primaryStartCommand;
/// <summary>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
/// <see cref="StartTestCommand"/> did: its CanExecute needs a connection, so the button the
/// creator was told to press could not be pressed).</summary>
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;
}
}
/// <summary>The End button shows while recording OR live.</summary>
public bool ShowEndStreamButton => IsLive || IsRecording;
/// <summary>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.</summary>
public string EndButtonText =>
IsRecording && !IsLive ? "Stop Recording"
: IsTestStream ? "End Test"
: "End Stream";
/// <summary>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.</summary>
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.
/// <summary>Idle — the mode switch is present and actionable.</summary>
public bool ShowIdleWorld => IsOffline && !IsRecording;
/// <summary>Running — the reality line (dot + word + elapsed) + End replace the switch.</summary>
public bool RunningVisible => IsLive || IsRecording;
/// <summary>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).</summary>
public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn;
/// <summary>Stream world: the switch sits on ON-AIR; Go says "Go Live" and Test
/// appears.</summary>
public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn;
/// <summary>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).</summary>
public bool ShowSignInButton => !IsConnected;
/// <summary>The avatar — right-click Change Account / Logout. Always visible when
/// signed in, Record or Stream.</summary>
public bool ShowAvatarButton => IsConnected;
/// <summary>The reality word: TEST while a test broadcast runs, LIVE while on the
/// air, REC during a local-record session.</summary>
public string RunningText => IsTestStream ? "TEST" : IsLive ? "LIVE" : "REC";
/// <summary>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.</summary>
public string RunningDotBrush => IsTestStream
? ThemeBrush.Hex("Brush.TestContainer")
: IsLive
? ThemeBrush.Hex("Brush.Primary")
: ThemeBrush.Hex("Brush.Positive");
/// <summary>Raises every flag the top bar renders, so any state change re-renders
/// exactly the world that is now true.</summary>
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));
}
/// <summary>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: <see cref="BeginTestStream"/> flips it before <see cref="StreamStatus"/>
/// goes Streaming so the visuals initialize amber, and the stop/rollback path
/// clears it.</summary>
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);
}
/// <summary>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.</summary>
public string? HealthIssueBanner
{
get => _healthIssueBanner;
private set => SetProperty(ref _healthIssueBanner, value);
}
/// <summary>Banner background: the error surface for an error issue, the warning surface
/// for a warning (both tokens). Transparent when no banner is shown.</summary>
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) ───
/// <summary>The broadcast id of the current/last session; null until Go Live
/// succeeds. Gates the pull-out's Update button (nothing remote to patch).</summary>
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 1080p30", 30, 8.0, 1080, 1920),
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<Rect> 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;
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; }
}