e69db4d26c
The bar now renders ONE surface from the FIRST decision the creator makes — Record or Stream — with each world being the whole bar. Creator directive: "I wrote the fucking thing and I still can't figure-out how to do stuff", "forget this one dog plan bullshit". OBS/streaming-tool reference for the one-surface paradigm: https://obsproject.com (single mode toggle + context actions) — cited per spin-guard habit; the go-live/test pattern follows CEV (https://cev-desktop.aSean.xyz) precedent. - Mode: single segmented REC|ON-AIR switch (SegmentToggle/SegmentLabel in Themes/Controls.xaml); radio-exclusive world selection, one tap to flip. - Record world: switch + Start Recording, zero YouTube identity. - Stream world: switch + Go Live + Test + account zone right (Sign In until connected, then avatar with Change Account/Logout context menu). - Test is a child of Stream: procs only stream-armed AND signed-in. - Running: one reality line "dot word elapsed" (REC/LIVE/TEST; green/red/ gold) + End; worlds + switch retire. - Gear moved up from bottom bar, ~3 wordmark letters past the brand, single click opens Settings/Bug/Feature/About menu (TASK 40 Unit B shipped early). - Fix folded in: BeginTestStream now arms OnAirPillOn explicitly (unarmed pump booted with zero encoder outputs -> "At least one output is required" forced-stop cascade from TASK 41's pipeline). - VM: world/reality props + RaiseTopBarModes() wired into StreamStatus, pill, IsRecording, IsTestStream, IsConnected setters. Doc: ai.md top-bar model, ViewModels/index.md, Controls/index.md, TASKS.md, HANDOFF.md, new TASKS/task-42-top-bar-redesign.md; task-40 note. Tests: TopBarModeTests.cs (new Good Dog), PillRadioTests + TestStreamTests gates updated. Build 0 warnings; full suite 314/315 — single abort is the pre-existing env-dependent RealMouseDrag test (Path of Exile 2 running)
436 lines
18 KiB
C#
436 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 account zone, no Test — YouTube doesn't exist here.</summary>
|
||
public bool ShowRecordAction => ShowIdleWorld && !OnAirPillOn;
|
||
|
||
/// <summary>Stream world: the switch sits on ON-AIR; Go says "Go Live" and Test
|
||
/// appears. The account zone belongs to this world because login is a step in
|
||
/// THIS sequence.</summary>
|
||
public bool ShowStreamActions => ShowIdleWorld && OnAirPillOn;
|
||
|
||
/// <summary>Sign In appears on the right only inside the Stream world, signed-out
|
||
/// — the pending step of the stream sequence, made visible exactly when it's next.</summary>
|
||
public bool ShowSignInButton => ShowStreamActions && !IsConnected;
|
||
|
||
/// <summary>The avatar confirms WHICH account is about to stream — only in the
|
||
/// Stream world, signed-in. Right-click for Change Account / Logout.</summary>
|
||
public bool ShowAvatarButton => ShowStreamActions && 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; }
|
||
}
|