diff --git a/HANDOFF.md b/HANDOFF.md index 18df973..e8663a5 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -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 diff --git a/ViewModels/MainViewModel.Streaming.cs b/ViewModels/MainViewModel.Streaming.cs index 589b2f9..543990b 100644 --- a/ViewModels/MainViewModel.Streaming.cs +++ b/ViewModels/MainViewModel.Streaming.cs @@ -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)); } diff --git a/ai.md b/ai.md index 5786c68..2710fef 100644 --- a/ai.md +++ b/ai.md @@ -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) diff --git a/ytLive.Tests/PillRadioTests.cs b/ytLive.Tests/PillRadioTests.cs new file mode 100644 index 0000000..86fd044 --- /dev/null +++ b/ytLive.Tests/PillRadioTests.cs @@ -0,0 +1,64 @@ +using System; +using System.IO; +using Microsoft.Data.Sqlite; +using Xunit; +using ytLive.Services; +using ytLive.ViewModels; + +namespace ytLive.Tests; + +/// +/// 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. +/// +[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 */ } + } + } +} \ No newline at end of file