diff --git a/HANDOFF.md b/HANDOFF.md index ae5b62c..84a44f7 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -23,33 +23,41 @@ resource model): **the webcam is an app-level resource — ONE app-wide default every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice: the availability layer. TASK 26's app-wide gate is **superseded** by creator directive. -- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`. +- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`. A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it does NOT gray the row elsewhere. -- **Add places the default directly:** when `_webcam != null`, "Add Webcam" creates the placement - with NO picker; the Windows picker runs only for the initial selection (`_webcam == null`; - choosing there swaps app-wide via the existing `SwapWebcamIdentityAsync`). +- **Attainable = a live lock, not a saved identity** (creator refinement): `IsWebcamAttainable` = + `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`. An identity whose camera is + unplugged / can't start leaves the row greyed (tooltip "No webcam is currently available…"), and + it un-greys the moment a session is running (startup lock, first frame, picker + acquire). +- **Add places the default directly:** when attainable, "Add Webcam" creates the placement with NO + picker; the Windows picker runs only for the initial selection (`_webcam == null`). - **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists. -- **Startup adopts a solo camera as the app default** (in `ValidateWebcamResourceStartupAsync`, - after the successful single-cam lock): a clean/empty layout now offers the Web Cam layer in - Live/Chat immediately; `CameraManager.IsRunning` skip means a loaded identity already holding a - session is never overwritten. -- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised alongside the gates) — "Already in this - scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam - detected…" / "Adds the app default webcam to this scene." -- **Good Dog integration test** `WebcamMenuGateTests` rewritten for the new model (real app + temp - DB + camera seams): Chat's identity does NOT gray Live; Add in Live places the same `wc-1` - without a picker; scene-with-placement stays gray; identity survives both removals (DB row count - keeps 1). `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is - adopted → `CanAddWebcam` true. **304/304 green, build 0 warnings.** +- **Startup lays an app-wide BASE lock:** the single-camera branch ALWAYS acquires (the old + `IsRunning` skip is gone) — on an already-running session it just bumps the refcount, and that ref + is the app's own hold. Result: removing every scene's placement leaves `RefCount = 1`, the session + stays alive, and the row stays offered (the app default outlives the scenes). It still **adopts a + solo camera as the app default** when no identity exists, so a clean layout offers Web Cam at once. +- **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised wherever the gate can flip: staging, + removal, startup lock success, first frame, camera failure, identity swap) — "Already in this + scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam is + currently available — plug one in, or allow camera access in Windows." / "Adds the app default + webcam to this scene." +- **Good Dog integration tests** `WebcamMenuGateTests` (real app + temp DB + camera seams): (1) Chat's + identity does NOT gray Live; Add in Live places the same `wc-1` without a picker; scene-with- + placement stays gray; identity survives both removals (DB row count keeps 1, session kept by the + base lock). (2) **Negation:** identity loaded but its session can't start → row NOT offered. + `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is adopted → + `CanAddWebcam` true. **305 tests (304 pass + the known audio flake, see Landmines); build 0 warnings.** **Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated (supersession recorded). ## ✅ Earlier committed — webcam resource lifecycle (startup slice, `1e4017d`) Startup poll + tri-state (`ValidateWebcamResourceStartupAsync` after `LoadLayout`): 0 → run on, -no alarm; **1 → `AcquireAsync` as app-wide lock**; **≥2 → no auto-lock** (App Settings selector +no alarm; **1 → `AcquireAsync` as app-wide lock** (now always runs — see base-lock note above); +**≥2 → no auto-lock** (App Settings selector next). Lock failure → **persistent red alert** (`WebcamLockAlert` in the Layers panel + Retry), re-polls every 5 s (`_webcamLockPollTimer`), clears on lock success or any first real frame. `CameraManager.IsRunning(deviceId)` = session exists (started OR starting). Camera test seams @@ -98,8 +106,10 @@ claim — `CameraConflictProbe` reads process names only, no device handles; do - Build/tests: **Windows dotnet host** (`/mnt/c/Program Files/dotnet/dotnet.exe`). 0 warnings — only `./scripts/verify.sh ""`'s clean build counts. Running `ytLive.csproj` alone does NOT rebuild `ytLive.Tests.dll` — run the Tests csproj before `vstest`. Known audio flake: - `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` (float precision) — re-run if it trips alone; - unrelated to camera work. + `Mix_HonorsProviderGains_AndGameMute_KillsTheLoopback` — a stale loopback sample still in the pipe + when the mute flips (phase 2's `Assert.Equal(0f, f, 6)` sees 0.4). **Confirmed pre-existing and + currently deterministic in this environment: it fails on clean `9d00955` (stash test) and on the + working tree, alone and in-suite.** Unrelated to camera work; do not "fix" it inside a webcam slice. - **Assert footguns in this repo's xUnit:** `Assert.Null(x, "msg")`/`Assert.Single(coll, "msg")` do NOT take a message — the 2-arg overloads mean something else (predicate/item). Empty message = 1 arg. - The webcam's real state lives in `%APPDATA%\ytLlive\ytLlive.db` (`layout.db` there is a 0-byte diff --git a/TASKS.md b/TASKS.md index 86ab290..e94630c 100644 --- a/TASKS.md +++ b/TASKS.md @@ -68,10 +68,11 @@ - **Webcam resource lifecycle (2026-09-17)** — startup slice shipped (OS poll at start, single-camera auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate slice shipped same day — webcam = app-level default, one per stream per scene, Add places the - default directly, identity survives last-placement removal, startup adopts a solo camera as default. - TASK 26's app-wide gate superseded by creator directive. Next slices (user-queued): multi-camera - webcam selector in the App Settings dialog (gear), static (+) catalog rows (Background, - YouTubeEvent, quoted labels) with reason-greying. + default directly, offered only when a camera is ATTAINABLE (identity + live session, not just a + saved identity), app base lock keeps the session after the last placement is removed, startup + adopts a solo camera as default. TASK 26's app-wide gate superseded by creator directive. Next + slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+) + catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying. --- diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs index 341deb8..858ab4e 100644 --- a/ViewModels/MainViewModel.Webcam.cs +++ b/ViewModels/MainViewModel.Webcam.cs @@ -23,17 +23,22 @@ public partial class MainViewModel private DispatcherTimer? _webcamLockPollTimer; /// - /// Add → Webcam menu item: the webcam is an app-level resource (one camera — - /// the app default). The row is offered in any scene that doesn't already - /// host it, wherever a camera is selected; it is NOT blocked by other scenes - /// holding their own placement (per-scene max, not app-wide). + /// Add → Webcam menu item. The row is offered iff the SCENE has no webcam + /// config yet AND a camera is attainable — an app default is selected and + /// its session is RUNNING right now (identity alone is not enough: the + /// device may have been unplugged, or the lock may still be failing, in + /// which case the row stays grey and the persistent alert says why). + /// Per-scene max is per-scene: another scene holding its own placement does + /// NOT grey this one. /// - public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null; + public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable; + + private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId); /// /// Why the Webcam row is greyed, when it is — never a silent bool. The max-1 - /// reason gets the gentle "graduated to OBS" line; no camera at all gets its - /// own wording. Shown as the row's tooltip. + /// reason gets the gentle "graduated to OBS" line; no attainable camera gets + /// its own wording. Shown as the row's tooltip. /// public string WebcamAddToolTip { @@ -41,12 +46,20 @@ public partial class MainViewModel { if (StagedScene?.WebcamConfig != null) return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS."; - if (_webcam == null) - return "No webcam detected — plug one in, or allow camera access in Windows."; + if (!IsWebcamAttainable) + return "No webcam is currently available — plug one in, or allow camera access in Windows."; return "Adds the app default webcam to this scene."; } } + // Re-raise the rows that read live CameraManager session state: the offer + // flips the moment a session starts (lock acquired, first frame) or dies. + private void RaiseWebcamGateChanged() + { + OnPropertyChanged(nameof(CanAddWebcam)); + OnPropertyChanged(nameof(WebcamAddToolTip)); + } + /// /// Right-click-on-preview → "Show Webcam": offered when the active scene has no /// webcam config (add one) or hides it (unhide — keeps the config row). @@ -193,6 +206,7 @@ public partial class MainViewModel if (_webcam?.DeviceId != deviceId) return; WebcamError = null; WebcamLockAlert = null; // the camera is provably alive — alert resolved. + RaiseWebcamGateChanged(); foreach (var scene in Scenes) foreach (var config in scene.Elements.OfType()) config.VideoImageSource = bitmap; @@ -204,6 +218,7 @@ public partial class MainViewModel { if (_webcam?.DeviceId != deviceId) return; WebcamError = $"Webcam offline: {message}"; + RaiseWebcamGateChanged(); } /// @@ -239,26 +254,23 @@ public partial class MainViewModel var device = cameras![0]; - // A session already started/starting for this device (loaded identity) - // means we already hold the lock — nothing to do. - if (_cameraManager.IsRunning(device.Id)) - { - WebcamLockAlert = null; - return; - } - + // Lay the app-wide base lock. AcquireAsync on an already-running session + // (loaded identity's configs) just bumps its refcount — that bump is the + // app's own hold, so removing EVERY scene's placement keeps the session + // alive and the Web Cam row offered (the app default outlives the scenes). var started = await _cameraManager.AcquireAsync(device.Id); if (started) { - // The single camera becomes the app default so the Web Cam layer is - // offered in the capabled scenes (Live/Chat) even on a clean layout. + // The camera is now provably attainable: adopt the new identity when + // there wasn't one, then re-raise the offer either way + // (RaiseWebcamGateChanged) so an existing identity whose session was + // down un-greys the row the moment the lock is real. if (_webcam == null) { _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; OnPropertyChanged(nameof(CanChangeWebcam)); - OnPropertyChanged(nameof(CanAddWebcam)); - OnPropertyChanged(nameof(WebcamAddToolTip)); } + RaiseWebcamGateChanged(); WebcamLockAlert = null; return; } @@ -344,6 +356,8 @@ public partial class MainViewModel break; } } + + RaiseWebcamGateChanged(); } // "Change Webcam…" from the webcam's context menu: picker, then swap the diff --git a/ai.md b/ai.md index 672a9d9..f6f59ee 100644 --- a/ai.md +++ b/ai.md @@ -418,12 +418,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr DIRECTLY (no picker); the Windows camera picker runs only for the initial selection (`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via `SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide** - (TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null` + (TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable` — a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene - holding one does not, so Chat's camera never greys Live; the greyed-row tooltip carries the reason - (`WebcamAddToolTip`, incl. the gentle "a second camera means you've graduated to OBS" line). + holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a + saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)` + — an identity whose device was unplugged, or whose session can't start, leaves the row greyed with + `WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The + max-1 reason gets the gentle "a second camera means you've graduated to OBS" line. Removing a scene's placement **keeps the identity** (it's the app default; `_webcam` is never - nulled by removal) — Add stays offered for the same camera. + nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock + (below). - **Startup resource lifecycle (2026-09-17):** the webcam is a resource the app *allocates and locks*, validated at startup — this answers "why is the WebCam row greyed" from inside the app instead of a DB spelunk. `MainViewModel.ValidateWebcamResourceStartupAsync` (fired fire-and-forget @@ -433,14 +437,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr (`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s (`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** → deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **next slice**. - A session already running for the device (loaded identity's configs) counts as the lock, so the - pass never double-acquires: `CameraManager.IsRunning(deviceId)` = a session exists (started OR - still starting — a mid-start session is not "locked yet", it's in flight), and a rolled-back - (failed) session leaves the dictionary so the pass can retry. After a successful single-cam lock, - the pass **adopts the camera as the app default** if no identity exists yet (fresh layout) — the - Web Cam layer is then offered in Live/Chat immediately (`CanAddWebcam` true); `IsRunning` skip - means a loaded identity whose session is already up never overwrites it. Test seams mirror - `LayoutPathOverride`: + The single-camera branch **always acquires** (it no longer skips when configs already hold the + session): that acquire is the app's own BASE ref, so removing every scene's placement leaves + `RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered, + i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps + the refcount. `CameraManager.IsRunning(deviceId)` = a session exists (started OR still starting — + a mid-start session is not "locked yet", it's in flight), and a rolled-back (failed) session leaves + the dictionary so the pass can retry. After a successful single-cam lock, the pass **adopts the + camera as the app default** if no identity exists yet (fresh layout) — the Web Cam layer is then + offered in Live/Chat immediately (`CanAddWebcam` true); an already-loaded identity is never + overwritten. Test seams mirror `LayoutPathOverride`: `CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests` drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail → alert → clears on retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure diff --git a/ytLive.Tests/WebcamMenuGateTests.cs b/ytLive.Tests/WebcamMenuGateTests.cs index 31158bb..b4b29b9 100644 --- a/ytLive.Tests/WebcamMenuGateTests.cs +++ b/ytLive.Tests/WebcamMenuGateTests.cs @@ -4,6 +4,7 @@ using System.IO; using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; +using ytLive.Models; using ytLive.Services; using ytLive.ViewModels; @@ -28,29 +29,19 @@ public sealed class WebcamMenuGateTests _app.Run(Run); } + [Fact] + public void WebcamNotOffered_WhenIdentityExists_ButNoCameraIsAttainable() + { + _app.Run(RunNotAttainable); + } + private readonly RealAppHost _app; public WebcamMenuGateTests(RealAppHost app) => _app = app; private void Run() - { - var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db"); - MainViewModel.LayoutPathOverride = tempDb; - MainViewModel.CameraEnumeratorOverride = new FakeEnumerator(); - MainViewModel.CameraFrameSourceFactoryOverride = id => new FakeFrameSource(id); - var window = default(MainWindow); - try + => RunWith(failLock: false, sentinel: (vm, starting, live) => { - using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } - SqliteConnection.ClearAllPools(); - SeedDatabaseWithWebcam(tempDb); - - window = new MainWindow(); - var vm = (MainViewModel)window.DataContext; - - var starting = vm.Scenes.Single(s => s.Name == "Starting"); - var live = vm.Scenes.Single(s => s.Name == "Live"); - vm.StagedScene = live; Assert.Null(live.WebcamConfig); Assert.True(vm.CanAddWebcam, @@ -74,13 +65,45 @@ public sealed class WebcamMenuGateTests Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered"); vm.SaveLayoutNow(); - using (var connection = new SqliteConnection($"Data Source={tempDb}")) + using (var connection = new SqliteConnection($"Data Source={MainViewModel.LayoutPathOverride}")) { connection.Open(); using var webcams = connection.CreateCommand(); webcams.CommandText = "SELECT COUNT(*) FROM Webcam;"; Assert.Equal(1L, (long)webcams.ExecuteScalar()!); } + }); + + private void RunNotAttainable() + => RunWith(failLock: true, sentinel: (vm, starting, live) => + { + vm.StagedScene = live; + Assert.Null(live.WebcamConfig); + Assert.False(vm.CanAddWebcam, + "identity is selected but its session can't start — the row is NOT offered (not attainable)"); + Assert.Equal("No webcam is currently available — plug one in, or allow camera access in Windows.", + vm.WebcamAddToolTip); + }); + + private void RunWith(bool failLock, Action sentinel) + { + var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db"); + MainViewModel.LayoutPathOverride = tempDb; + MainViewModel.CameraEnumeratorOverride = new FakeEnumerator(); + MainViewModel.CameraFrameSourceFactoryOverride = id => new FakeFrameSource(id, failLock); + var window = default(MainWindow); + try + { + using (var schema = new ytLive.Services.LayoutStore(tempDb)) { } + SqliteConnection.ClearAllPools(); + SeedDatabaseWithWebcam(tempDb); + + window = new MainWindow(); + var vm = (MainViewModel)window.DataContext; + // Deterministic: the offer reads live session state, so wait for the + // startup lock before asserting anything camera-flavoured. + vm.WebcamStartupValidationTask!.GetAwaiter().GetResult(); + sentinel(vm, vm.Scenes.Single(s => s.Name == "Starting"), vm.Scenes.Single(s => s.Name == "Live")); } finally { @@ -149,15 +172,21 @@ public sealed class WebcamMenuGateTests private sealed class FakeFrameSource : ICameraFrameSource { public string DeviceId { get; } + private readonly bool _failLock; public event Action? FrameAvailable; #pragma warning disable CS0067 public event Action? SourceFailed; #pragma warning restore CS0067 - public FakeFrameSource(string deviceId) => DeviceId = deviceId; + public FakeFrameSource(string deviceId, bool failLock = false) + { + DeviceId = deviceId; + _failLock = failLock; + } public Task StartAsync() { + if (_failLock) throw new InvalidOperationException("Camera is in use by another app."); FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16])); return Task.CompletedTask; }