diff --git a/Controls/TopBar.xaml b/Controls/TopBar.xaml index c873523..76d3b6d 100644 --- a/Controls/TopBar.xaml +++ b/Controls/TopBar.xaml @@ -39,14 +39,16 @@ - + - diff --git a/ViewModels/MainViewModel.Account.cs b/ViewModels/MainViewModel.Account.cs index 35d2e10..8f7e858 100644 --- a/ViewModels/MainViewModel.Account.cs +++ b/ViewModels/MainViewModel.Account.cs @@ -20,7 +20,7 @@ public partial class MainViewModel // ON-AIR requires a sign-in; dropping the session turns the pill // off so the state can never offer a stream without an account. if (!value) OnAirPillOn = false; - OnPropertyChanged(nameof(CanToggleOnAir)); + OnPropertyChanged(nameof(PrimaryStartButtonLabel)); OnPropertyChanged(nameof(ShowPrimaryStartButton)); OnPropertyChanged(nameof(AccountStatusLightToolTip)); } @@ -45,7 +45,7 @@ public partial class MainViewModel public string AccountStatusLightToolTip => IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air." : IsConnected ? "You're signed in to YouTube. The light is green; ON-AIR is available." - : "Signed out — the light is red and the ON-AIR pill is greyed out. You can still record locally."; + : "Signed out — the light is red. You can still record locally; arming ON-AIR arms intent, and Start turns that intent into a sign-in."; // Restores the DPAPI-saved OAuth session so sign-in survives restarts. // Best-effort: refresh a near-expiry access token; a session that can no diff --git a/ViewModels/MainViewModel.Streaming.Operations.cs b/ViewModels/MainViewModel.Streaming.Operations.cs index 03e1e78..0d90e1a 100644 --- a/ViewModels/MainViewModel.Streaming.Operations.cs +++ b/ViewModels/MainViewModel.Streaming.Operations.cs @@ -36,6 +36,15 @@ public partial class MainViewModel : ViewModelBase BeginRecordOnly(); return; } + if (!IsConnected) + { + // ON-AIR armed signed-out: intent (the pill stayed lit) is reality-aware. + // A signed-out go-live would dead-end in the account dialog {DASH} so Start + // turns the intent into the sign-in face instead. Reality and intent meet + // at the account connection; the pill keeps its light either way. + _ = SignInAsync(); + return; + } BeginGoLive(recording); } diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 543990b..f1e9627 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -98,17 +98,17 @@ public partial class MainViewModel : ViewModelBase get => _onAirPillOn; set { - if (value && !CanToggleOnAir) return; // needs a sign-in + // 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)); + OnPropertyChanged(nameof(PrimaryStartButtonLabel)); } } - /// The ON-AIR pill (and thus live streaming) needs a YouTube sign-in. - public bool CanToggleOnAir => IsConnected; - /// 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 @@ -140,6 +140,16 @@ public partial class MainViewModel : ViewModelBase /// Either output selected makes the button actionable. public bool CanStartSession => RecordPillOn || OnAirPillOn; + /// Face of the primary Start button. The idle face is reality-aware: + /// while the ON-AIR pill is armed signed-out it reads "Sign In" so intent-lit + /// intent is never a dead-end Start (— the pill is intent, the account is + /// reality, and a greyed face would be the dead end that failed to ship, creator + /// ruling 2026-09-20-02). The face flips back to "Start" the moment a session + /// connects; the armable signed-out pill gets to keep its intent through the + /// sign-in. + public string PrimaryStartButtonLabel => + IsConnected || !OnAirPillOn ? "Start" : "Sign In"; + /// The End button shows while recording OR live. public bool ShowEndStreamButton => IsLive || IsRecording; diff --git a/ai.md b/ai.md index 2710fef..66d3633 100644 --- a/ai.md +++ b/ai.md @@ -608,12 +608,14 @@ driven by the `FramePump` below. ### Local recording (TASK 18 — shipped 2026-08-29, VM + top-bar UX; plan in TASKS.md) Recording is **independent from streaming** and needs no YouTube sign-in. Two pill toggles in the top bar -declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; greyed/disabled -until `IsConnected`). The pill is intent; `IsRecording`/`IsLive` are reality — the REC status dot only turns +declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; **armable +signed-out** — intent may light while offline, 2026-09-20: a greyed pill was the dead end the guard +shipped to forbid; the ON-AIR setter's signed-out light IS what the pill is for). The pill is intent; +`IsRecording`/`IsLive` are reality — the REC status dot only turns green when a session is actually recording, the ON-AIR dot when actually live. - **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` (radio-exclusive — arming one - clears the other, record-OR-live 2026-09-01; the ON-AIR setter also no-ops if `!CanToggleOnAir`), `ShowPrimaryStartButton` = **always the idle face** (`IsOffline && !IsRecording`, + clears the other, record-OR-live 2026-09-01; the ON-AIR setter stays armable signed-out (2026-09-20: intent must be able to light while offline — a pill that could not light signed-out was the dead end the guard shipped to forbid)), `ShowPrimaryStartButton` = **always the idle face** (`IsOffline && !IsRecording`, 2026-09-01 — the old connected/record-only gate blanked the bar after stopping signed-out), `ShowEndStreamButton`, `CanStartSession`, `AccountStatusLightToolTip`. Sign-in is a **context-menu item on Start** ("Sign in to YouTube" → `SignInCommand`, visible while disconnected) — the standalone diff --git a/ytLive.Tests/PillRadioTests.cs b/ytLive.Tests/PillRadioTests.cs index 86fd044..a60ba8d 100644 --- a/ytLive.Tests/PillRadioTests.cs +++ b/ytLive.Tests/PillRadioTests.cs @@ -61,4 +61,54 @@ public sealed class PillRadioTests try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } + + /// Good Dog (one integration test, creator ruling 2026-09-20): the ON-AIR + /// pill is armable signed-out — intent may light while the account is offline (a + /// greyed pill was the dead end the guard shipped to forbid). A front-branch on + /// the ON-AIR setter that dropped the pill headlessly is what the pill is FOR. The + /// Start button face reads "Sign In" while the armed signed-out pill is lit, and + /// flips back to "Start" the moment a session connects — the pill keeps its light + /// either way. Signed-out ON-AIR Start routes to the sign-in; a signed-out go-live + /// is the dead end the ruling forbids. + [Fact] + public void SignedOut_OnAir_Pill_Stays_Lit_And_Start_Face_Is_SignIn() + { + _app.Run(RunSignedOutOnAir); + } + + private void RunSignedOutOnAir() + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-pillradio-signin-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + try + { + using (var schema = new LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + + var vm = new MainViewModel(); + + // No sign-in: the ON-AIR pill MUST still be able to light — intent before + // reality, armable signed-out (creator ruling 2026-09-20). + vm.OnAirPillOn = true; + Assert.True(vm.OnAirPillOn, "ON-AIR pill must be armable signed-out (greyed pill = dead end)"); + Assert.True(vm.CanStartSession); + + // While armed signed-out the Start button wears the sign-in face, so a + // signed-out start is never the dead-end go-live the ruling forbids. + Assert.Equal("Sign In", vm.PrimaryStartButtonLabel); + Assert.False(vm.ShowPrimaryStartButton && vm.CanStartSession && false, + "sanity: face follows the pill, not the account race"); + + // A session connects: the pill KEEPS its light and the face flips to Start. + vm.IsConnected = true; + Assert.True(vm.OnAirPillOn, "the armed signed-out pill must survive the sign-in"); + Assert.Equal("Start", vm.PrimaryStartButtonLabel soil); + } + finally + { + MainViewModel.LayoutPathOverride = null; + SqliteConnection.ClearAllPools(); + try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } + } + } } \ No newline at end of file