feat(pills): ON-AIR armable signed-out; Start face reads Sign-In while armed signed-out (Good Dog 2026-09-20 ruling, ai.md updated same commit)
This commit is contained in:
@@ -39,14 +39,16 @@
|
|||||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
||||||
Margin="6,0,16,0"
|
Margin="6,0,16,0"
|
||||||
IsChecked="{Binding RecordPillOn}"/>
|
IsChecked="{Binding RecordPillOn}"/>
|
||||||
<!-- ON-AIR group: greyed out until signed in -->
|
<!-- ON-AIR group: armable signed-out (creator ruling 2026-09-20: a
|
||||||
|
greyed pill was a dead end — the pill is intent and intent must
|
||||||
|
be able to light while offline). The Start face turns the armed
|
||||||
|
pill signed-out into a sign-in. -->
|
||||||
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
<Ellipse Width="12" Height="12" VerticalAlignment="Center"
|
||||||
Fill="{Binding OnAirBrush}"/>
|
Fill="{Binding OnAirBrush}"/>
|
||||||
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
|
<TextBlock Text="ON-AIR" Style="{StaticResource StatusSignText}"/>
|
||||||
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
<ToggleButton Style="{StaticResource PillToggle}" VerticalAlignment="Center"
|
||||||
Margin="6,0,0,0"
|
Margin="6,0,0,0"
|
||||||
IsChecked="{Binding OnAirPillOn}"
|
IsChecked="{Binding OnAirPillOn}"
|
||||||
IsEnabled="{Binding CanToggleOnAir}"
|
|
||||||
ToolTip="{Binding AccountStatusLightToolTip}"/>
|
ToolTip="{Binding AccountStatusLightToolTip}"/>
|
||||||
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
|
<Border Background="#8f1f1f" CornerRadius="3" Padding="6,1" Margin="8,0,0,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
@@ -92,7 +94,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
<!-- Primary Start button: the always-present idle action (sign-in moved
|
<!-- Primary Start button: the always-present idle action (sign-in moved
|
||||||
into its context menu — a bare bar after stopping was a dead end) -->
|
into its context menu — a bare bar after stopping was a dead end) -->
|
||||||
<Button Content="Start" Style="{StaticResource YtButton}"
|
<Button Content="{Binding PrimaryStartButtonLabel}" Style="{StaticResource YtButton}"
|
||||||
Command="{Binding StartStreamCommand}"
|
Command="{Binding StartStreamCommand}"
|
||||||
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
Visibility="{Binding ShowPrimaryStartButton, Converter={StaticResource BoolToVis}}">
|
||||||
<Button.ContextMenu>
|
<Button.ContextMenu>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public partial class MainViewModel
|
|||||||
// ON-AIR requires a sign-in; dropping the session turns the pill
|
// ON-AIR requires a sign-in; dropping the session turns the pill
|
||||||
// off so the state can never offer a stream without an account.
|
// off so the state can never offer a stream without an account.
|
||||||
if (!value) OnAirPillOn = false;
|
if (!value) OnAirPillOn = false;
|
||||||
OnPropertyChanged(nameof(CanToggleOnAir));
|
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
OnPropertyChanged(nameof(AccountStatusLightToolTip));
|
||||||
}
|
}
|
||||||
@@ -45,7 +45,7 @@ public partial class MainViewModel
|
|||||||
public string AccountStatusLightToolTip =>
|
public string AccountStatusLightToolTip =>
|
||||||
IsLive ? "Live on YouTube — the top bar is red to show the stream is on the air."
|
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."
|
: 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.
|
// Restores the DPAPI-saved OAuth session so sign-in survives restarts.
|
||||||
// Best-effort: refresh a near-expiry access token; a session that can no
|
// Best-effort: refresh a near-expiry access token; a session that can no
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
BeginRecordOnly();
|
BeginRecordOnly();
|
||||||
return;
|
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);
|
BeginGoLive(recording);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,17 +98,17 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
get => _onAirPillOn;
|
get => _onAirPillOn;
|
||||||
set
|
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 (!SetProperty(ref _onAirPillOn, value)) return;
|
||||||
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
|
if (value && _recordPillOn) RecordPillOn = false; // record-OR-live — arming ON-AIR drops REC
|
||||||
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
OnPropertyChanged(nameof(ShowPrimaryStartButton));
|
||||||
OnPropertyChanged(nameof(CanStartSession));
|
OnPropertyChanged(nameof(CanStartSession));
|
||||||
|
OnPropertyChanged(nameof(PrimaryStartButtonLabel));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The ON-AIR pill (and thus live streaming) needs a YouTube sign-in.</summary>
|
|
||||||
public bool CanToggleOnAir => IsConnected;
|
|
||||||
|
|
||||||
/// <summary>Whether the session is actively recording — the REC status light
|
/// <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).
|
/// 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
|
/// Internal setter is a test seam (same pattern as LayoutPathOverride) so the
|
||||||
@@ -140,6 +140,16 @@ public partial class MainViewModel : ViewModelBase
|
|||||||
/// <summary>Either output selected makes the button actionable.</summary>
|
/// <summary>Either output selected makes the button actionable.</summary>
|
||||||
public bool CanStartSession => RecordPillOn || OnAirPillOn;
|
public bool CanStartSession => RecordPillOn || OnAirPillOn;
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
public string PrimaryStartButtonLabel =>
|
||||||
|
IsConnected || !OnAirPillOn ? "Start" : "Sign In";
|
||||||
|
|
||||||
/// <summary>The End button shows while recording OR live.</summary>
|
/// <summary>The End button shows while recording OR live.</summary>
|
||||||
public bool ShowEndStreamButton => IsLive || IsRecording;
|
public bool ShowEndStreamButton => IsLive || IsRecording;
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
### 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
|
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
|
declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill** (streaming; **armable
|
||||||
until `IsConnected`). The pill is intent; `IsRecording`/`IsLive` are reality — the REC status dot only turns
|
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.
|
green when a session is actually recording, the ON-AIR dot when actually live.
|
||||||
|
|
||||||
- **State model (`MainViewModel`):** pills `RecordPillOn`/`OnAirPillOn` (radio-exclusive — arming one
|
- **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),
|
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
|
`ShowEndStreamButton`, `CanStartSession`, `AccountStatusLightToolTip`. Sign-in is a **context-menu
|
||||||
item on Start** ("Sign in to YouTube" → `SignInCommand`, visible while disconnected) — the standalone
|
item on Start** ("Sign in to YouTube" → `SignInCommand`, visible while disconnected) — the standalone
|
||||||
|
|||||||
@@ -61,4 +61,54 @@ public sealed class PillRadioTests
|
|||||||
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>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.</summary>
|
||||||
|
[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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user