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:
+13
-1
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user