feat(topbar): REC/ON-AIR pills are radio-exclusive (record-OR-live)

The record-OR-live ruling (2026-09-01) was documented in ai.md but the
'pills = radios' UI constraint never landed — both pills could be armed and
StartSession/BuildEncoderOptions would dual-encode to the YouTube ingest AND
a local file at once, streaming a concurrent disk write off the same ingested
Kbps (cripples the stream on mid-range chassis; 'the VOD is already the copy').
The pill setters now clear one another (silent mutual clear); the dual-block
ffmpeg machinery stays generic and unreachable from the UI by design.
This commit is contained in:
2026-09-20 09:56:07 -07:00
parent 5a1993b6db
commit 94e114bfdf
4 changed files with 91 additions and 9 deletions
+13 -1
View File
@@ -1,4 +1,4 @@
# HANDOFF — 2026-09-20 (cross-table z-order bug fixed: webcam between sources survives reload; defaults/current split queued as TASK 37)
# HANDOFF — 2026-09-20 (cross-table z-order bug fixed; REC/ON-AIR pills now radio-exclusive; defaults/current split queued as TASK 37)
## Branch / Commit State
@@ -36,6 +36,18 @@ shape `scene[0-4].layerList.[default|current].elementList`; config-file vs secon
decided (`TASKS/task-37-defaults-current-split.md` has both + facts). **Process rule established:**
capture out-of-scope needed work in TASKS.md rather than bolting it onto the in-flight change.
## ✅ Committed — REC/ON-AIR pills are radio-exclusive (2026-09-20)
**Gap found:** the record-OR-live ruling (2026-09-01) was documented but the "pills = radios" UI
constraint never landed — the two pills were independent toggles, so a creator COULD arm both and
`StartSession`/`BuildEncoderOptions` would happily dual-encode (RTMP + `-f mp4`) — the exact thing the
ruling forbids (a concurrent disk write streams from the same sustained ingest Kbps and cripples the
stream on mid-range chassis; "the VOD is already the copy"). Fix: mutual clear in both pill setters
(`RecordPillOn`/`OnAirPillOn` in `MainViewModel.Streaming.cs`) — arming one clears the other (silent
mutual clear, creator's choice). The dual-block ffmpeg machinery stays generic/unreachable by design.
Test `PillRadioTests` (RealApp + temp DB): REC→ON-AIR clears REC; ON-AIR→REC clears ON-AIR; single
pill still arms Start. **310/310 pass.**
## ✅ Committed earlier — layer-list drag-to-reorder persists (2026-09-18, `f91bf87` + below)
`StagedScene.Elements` reorder (RemoveAt/Insert) bypasses SceneGraph's mutation surface, so the drop
+6 -2
View File
@@ -33,8 +33,10 @@ public partial class MainViewModel : ViewModelBase
private double _recDotPulse = 1.0;
private TimeSpan _liveElapsed;
// ─── Recording (TASK 18): independent REC / ON-AIR pills. The pills are
// ─── intent; IsRecording / IsLive are reality. ON-AIR needs a sign-in.
// ─── 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;
@@ -85,6 +87,7 @@ public partial class MainViewModel : ViewModelBase
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));
}
@@ -97,6 +100,7 @@ public partial class MainViewModel : ViewModelBase
{
if (value && !CanToggleOnAir) return; // needs 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));
}
+8 -6
View File
@@ -596,7 +596,8 @@ anullsrc` silence in the TASK 8 audio milestone) + explicit `-map 0:v -map 1:a`
the engine runs record-only (no RTMP) or stream-only — **stream+record simultaneously is OUT by creator
ruling (2026-09-01): the VOD is already the copy, and dual-encoding drags mid-range chassis and degrades
BOTH outputs ("we're not them"). The old "cheap on NVENC" claim was an unverified assumption; the
UI constraint (pills = radios) lands as a small change, the dual-block machinery stays generic.**
UI constraint is landed (2026-09-20): the REC/ON-AIR pills are radio-exclusive — arming one clears the
other — while the dual-block machinery stays generic.**
`FfmpegEncoder.StartAsync` throws unless at least one output is enabled.
**Encoder choice is probed from the binary's `-encoders` listing** (`FfmpegEncoderPicker`, pure):
NVENC → QSV → AMF → OpenH264 fallback, **never libx264** (GPL; see Licensing). `EncoderOptions.VideoEncoder`
@@ -611,13 +612,13 @@ declare intent: **REC pill** (local file, works signed-out) and **ON-AIR pill**
until `IsConnected`). 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` (ON-AIR setter no-ops if
`!CanToggleOnAir`), `ShowPrimaryStartButton` = **always the idle face** (`IsOffline && !IsRecording`,
- **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`,
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
Sign In button is gone (TASK 30's single-button rule, completed). `StartSession()` routes: ON-AIR on
→ GoLive dialog then stream (± record); REC on or **nothing armed** → `BeginRecordOnly()` (unarmed
→ GoLive dialog then stream; REC on or **nothing armed** → `BeginRecordOnly()` (unarmed
Start lights the REC pill and records — no dead-end no-ops; local recording needs no account).
`IsEditMode` also requires `!IsRecording` (lock scrubbing while recording).
**Top bar order (creator spec 2026-09-01):** `[sign light] REC [pill] [sign light] ON-AIR [pill]`
@@ -640,8 +641,9 @@ green when a session is actually recording, the ON-AIR dot when actually live.
`OnFramePumpFailed` + `IsRecording` setter made internal (test-only, InternalsVisibleTo).
Test: `SessionTeardownTests`.
- `EncoderOptions.StreamEnabled/RecordEnabled/RecordPath` gate outputs; record+stream is one ffmpeg with two
output blocks. Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` now always
returns an options (with flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
output blocks (the dual-block machinery is unreachable from the UI since the pills are radio-exclusive).
Rest of the engine (FramePump/encoder) is unchanged — `BuildEncoderOptions` always returns an options
(flags from the pills + `_activeRecordPath`), so record-only reaches the pump.
### Live audio capture (TASK 4 ship step 4 — shipped 2026-08-12; game audio bar 2026-08-13; **TASK 8 audio milestone: real stream audio + filters + duck + TRAX, shipped 2026-08-14**, plan in TASKS.md)
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.IO;
using Microsoft.Data.Sqlite;
using Xunit;
using ytLive.Services;
using ytLive.ViewModels;
namespace ytLive.Tests;
/// <summary>
/// Record-OR-live (creator ruling 2026-09-01): the REC and ON-AIR pills are
/// radio-exclusive — arming one clears the other, so a session can never dual-encode
/// to both the YouTube ingest and a local file at once (a concurrent disk write
/// streamed from the same sustained Kbps cripples the stream on mid-range chassis).
/// The dual-block encoder machinery in FfmpegArgs stays generic; the constraint
/// lives at the pills.
/// </summary>
[Collection("RealApp")]
public sealed class PillRadioTests
{
private readonly RealAppHost _app;
public PillRadioTests(RealAppHost app) => _app = app;
[Fact]
public void Arming_One_Output_Pill_Clears_The_Other()
{
_app.Run(Run);
}
private void Run()
{
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-pillradio-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb;
try
{
using (var schema = new LayoutStore(tempDb)) { }
SqliteConnection.ClearAllPools();
var vm = new MainViewModel();
vm.IsConnected = true; // the ON-AIR setter requires a sign-in
// Arm REC first, then ON-AIR → REC must clear.
vm.RecordPillOn = true;
vm.OnAirPillOn = true;
Assert.False(vm.RecordPillOn, "arming ON-AIR must clear the REC pill");
Assert.True(vm.OnAirPillOn);
// Arm REC while ON-AIR is lit → ON-AIR must clear.
vm.RecordPillOn = true;
Assert.False(vm.OnAirPillOn, "arming REC must clear the ON-AIR pill");
Assert.True(vm.RecordPillOn);
// A single armed pill still makes the Start button actionable.
Assert.True(vm.CanStartSession);
}
finally
{
MainViewModel.LayoutPathOverride = null;
SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
}
}
}