3a4e17c735
Creator on the live build: 'why do you make the youtube user icon go away?'
The TASK 42 one-surface bar had scoped the account zone to Stream only
('Record world has no YouTube identity at all'), so arming REC dropped the
avatar and the bar height bounced with it ('really fucking annoying').
Fix: ShowSignInButton / ShowAvatarButton are now plain !IsConnected /
IsConnected — app-level identity, present in both worlds, so the right
column never pops in/out and the bar height stays pinned. Test remains a
child of Stream (still stream-armed AND signed-in).
TopBarModeTests updated to the new contract: signed-out Record world shows
Sign In; flipping back to REC keeps the avatar. PillRadioTests untouched
(it only exercises the ON-AIR face). Docs corrected in-place (ai.md state
model, TASK 42 scope). Gate: clean build 0-warnings, 316/316, scope passed.
437 lines
18 KiB
C#
437 lines
18 KiB
C#
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): 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(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 ? "#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;
|
||
if (value && _onAirPillOn) OnAirPillOn = false; // record-OR-live — arming REC drops ON-AIR
|
||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||
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(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(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>
|
||
public string PrimaryStartButtonLabel =>
|
||
OnAirPillOn ? "Go Live" : "Start Recording";
|
||
|
||
/// <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 ? "#9c6f1c" : IsLive ? "#e94560" : "#22c55e";
|
||
|
||
/// <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: app red (#8f1f1f) for an error issue, amber
|
||
/// (#b8860b) for a warning. 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 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;
|
||
|
||
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 StartTestCommand { get; }
|
||
public ICommand EndStreamCommand { get; }
|
||
public ICommand SignInCommand { get; }
|
||
public ICommand ChooseRecordFolderCommand { get; }
|
||
public ICommand ResetRecordFolderCommand { get; }
|
||
}
|