From 9d009550047864fee2803dea963c08872435aaec Mon Sep 17 00:00:00 2001 From: gramps Date: Thu, 17 Sep 2026 08:56:04 -0700 Subject: [PATCH] =?UTF-8?q?feat(webcam):=20app-default=20gate=20slice=20?= =?UTF-8?q?=E2=80=94=20per-scene=20offer,=20Add=20places=20default=20direc?= =?UTF-8?q?tly,=20identity=20survives=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat's camera no longer greys Web Cam in Live (per-scene max, not app-wide); TASK 26 superseded by creator directive (app-level resource model). Add Webcam now places the existing app default without the picker; the picker runs only for the initial selection. Removing the last placement keeps the identity (_webcam never nulled) so Add stays offered. Startup pass adopts a solo camera as the app default, so a clean layout offers the layer in Live/Chat at once. Dynamic WebcamAddToolTip names the why (incl. the 'graduated to OBS' line). Good Dog: WebcamMenuGateTests rewritten (real app + temp DB + camera seams) — identity in Chat does not gray Live; Add in Live places same wc-1 no picker; scene-with-placement stays gray; identity survives both removals (DB row 1). WebcamStartupResourceTests single-lock fact asserts CanAddWebcam after adoption. Docs same commit (ai.md supersession, TASKS.md, HANDOFF.md). 304/304, 0 warnings. --- Controls/LeftPanel.xaml | 2 +- HANDOFF.md | 95 +++++++++++++--------- TASKS.md | 11 ++- ViewModels/MainViewModel.Scenes.cs | 1 + ViewModels/MainViewModel.Sources.cs | 12 ++- ViewModels/MainViewModel.Webcam.cs | 65 ++++++++++----- ai.md | 41 ++++++---- ytLive.Tests/WebcamMenuGateTests.cs | 94 +++++++++++++++------ ytLive.Tests/WebcamStartupResourceTests.cs | 3 + 9 files changed, 210 insertions(+), 114 deletions(-) diff --git a/Controls/LeftPanel.xaml b/Controls/LeftPanel.xaml index 1087ff7..95167e7 100644 --- a/Controls/LeftPanel.xaml +++ b/Controls/LeftPanel.xaml @@ -30,7 +30,7 @@ + ToolTip="{Binding WebcamAddToolTip}"/> s.Elements.OfType().Any())) + if (element is WebcamSceneConfig) { - _webcam = null; - OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); + OnPropertyChanged(nameof(WebcamAddToolTip)); } OnPropertyChanged(nameof(ShowEmptySceneHint)); diff --git a/ViewModels/MainViewModel.Webcam.cs b/ViewModels/MainViewModel.Webcam.cs index 9e3e62e..341deb8 100644 --- a/ViewModels/MainViewModel.Webcam.cs +++ b/ViewModels/MainViewModel.Webcam.cs @@ -22,9 +22,30 @@ public partial class MainViewModel private string? _webcamLockAlert; private DispatcherTimer? _webcamLockPollTimer; - /// The Add → Webcam menu item: enabled only while no webcam exists anywhere — - /// one camera identity app-wide; re-adding always opens the picker. - public bool CanAddWebcam => StagedScene != null && _webcam == null; + /// + /// 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). + /// + public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null; + + /// + /// 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. + /// + public string WebcamAddToolTip + { + get + { + 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."; + return "Adds the app default webcam to this scene."; + } + } /// /// Right-click-on-preview → "Show Webcam": offered when the active scene has no @@ -229,6 +250,15 @@ public partial class MainViewModel 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. + if (_webcam == null) + { + _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; + OnPropertyChanged(nameof(CanChangeWebcam)); + OnPropertyChanged(nameof(CanAddWebcam)); + OnPropertyChanged(nameof(WebcamAddToolTip)); + } WebcamLockAlert = null; return; } @@ -237,33 +267,28 @@ public partial class MainViewModel WebcamLockAlert = $"Web Cam unavailable: {reason}"; } - // Adds the webcam to the active scene. The creator ALWAYS picks from the - // cameras Windows has registered — never silently resurrects the previous - // camera (which is what happened after deleting one scene's webcam while - // another scene still used it). Picking a different camera than the current - // app-wide one swaps it everywhere, so the single-identity model stays honest; - // each scene's placement config is independent (webcam.{scene}.config). + // Adds the webcam to the active scene. The webcam is an app-level resource: + // once a camera is the app default, adding the layer just places it — the + // picker runs only for the initial selection. Each scene's placement config + // is independent (webcam.{scene}.config); the camera device is Windows' own + // singleton and stays where we put it. private async Task AddWebcamToStagedSceneAsync() { var scene = StagedScene; if (scene == null || scene.WebcamConfig != null) return; - var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) - { - Owner = Application.Current.MainWindow - }; - if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; - var device = dialog.PickedDevice; - if (_webcam == null) { + var dialog = new CameraPickerDialog(new CameraPickerViewModel(_cameraEnumerator)) + { + Owner = Application.Current.MainWindow + }; + if (dialog.ShowDialog() != true || dialog.PickedDevice == null) return; + var device = dialog.PickedDevice; _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName }; OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanAddWebcam)); - } - else if (_webcam.DeviceId != device.Id) - { - await SwapWebcamIdentityAsync(device); + OnPropertyChanged(nameof(WebcamAddToolTip)); } var config = new WebcamSceneConfig diff --git a/ai.md b/ai.md index 413311b..672a9d9 100644 --- a/ai.md +++ b/ai.md @@ -408,21 +408,22 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr - **TFM:** `net8.0-windows10.0.19041.0` (app + tests) pulls the WinRT projection from the SDK reference packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working. -- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity - (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/ - border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam - (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture - sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last - release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is - never silently handed the previous camera (which used to happen after deleting one scene's webcam - while another scene still used it; that identity survived, so re-adding bypassed the choice). - Picking a different camera than the current app-wide one swaps it everywhere via - `SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest; - picking the same one just places the config. The Add Webcam menu item greys out whenever a webcam - exists **anywhere** — one camera identity app-wide (`CanAddWebcam` = `StagedScene != null && _webcam == null`, - TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config). - **Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising - `CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add. +- **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam` + row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` + (position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at + most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` + refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps + it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the + app default), usable in every scene that can host it.** "Add Webcam" places the existing default + 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` + — 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). + 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. - **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 @@ -435,10 +436,14 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr 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. Test seams mirror `LayoutPathOverride`: + (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`: `CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests` - drive the probe without real hardware (0-cam no-alarm, 1-cam locked, lock-fail → alert → clears on - retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure + 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 below is a *suspect-list* claim — `CameraConflictProbe` reads only running process names, no device handles; contention symptoms fit shared-mode/bandwidth just as well (see `MyMistakes.md`). The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27): diff --git a/ytLive.Tests/WebcamMenuGateTests.cs b/ytLive.Tests/WebcamMenuGateTests.cs index 069ea2e..31158bb 100644 --- a/ytLive.Tests/WebcamMenuGateTests.cs +++ b/ytLive.Tests/WebcamMenuGateTests.cs @@ -1,25 +1,29 @@ using System; +using System.Collections.Generic; using System.IO; -using System.Linq; +using System.Threading.Tasks; using Microsoft.Data.Sqlite; using Xunit; -using ytLive.Models; +using ytLive.Services; using ytLive.ViewModels; namespace ytLive.Tests; /// -/// TASK 26 integration: the (+) menu's Webcam row gates on the app-wide webcam -/// identity — one camera per app, so the row must stay disabled while ANY scene -/// shows the webcam (not just the staged one), and re-enable only after the -/// last config anywhere is removed (which also drops the identity). -/// Real-App + temp-DB pattern (see BackgroundHealIntegrationTests). +/// The webcam is an app-level resource with ONE selection (the app default): +/// placing it in one scene must NOT grey it in the others (per-scene max, not +/// app-wide), "Add Webcam" places the existing default directly (no picker — +/// the picker only runs for the initial selection), and removing the last +/// placement keeps the identity so the layer stays offered. +/// Real-App + temp-DB + camera-seam pattern (camera seams are required: the +/// startup resource pass and Add both acquire, and must never touch real +/// hardware under test). /// [Collection("RealApp")] public sealed class WebcamMenuGateTests { [Fact] - public void CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity() + public void WebcamIsAppDefault_AddPlacesSameCamera_AnyScene_IdentitySurvivesRemoval() { _app.Run(Run); } @@ -32,6 +36,8 @@ public sealed class WebcamMenuGateTests { 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 { @@ -42,22 +48,30 @@ public sealed class WebcamMenuGateTests window = new MainWindow(); var vm = (MainViewModel)window.DataContext; - var starting = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)); - var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); + var starting = vm.Scenes.Single(s => s.Name == "Starting"); + var live = vm.Scenes.Single(s => s.Name == "Live"); vm.StagedScene = live; - Assert.True(live.WebcamConfig == null, "Live must not carry the webcam config"); - Assert.False(vm.CanAddWebcam, - "webcam exists in another scene — Add Webcam must be greyed out app-wide"); - - vm.StagedScene = starting; - Assert.False(vm.CanAddWebcam, "staged scene itself has the webcam"); - - var config = starting.WebcamConfig!; - vm.RemoveSourceCommand.Execute(config); - + Assert.Null(live.WebcamConfig); Assert.True(vm.CanAddWebcam, - "last webcam config anywhere was removed — identity cleared, picker may run again"); + "webcam exists in Starting but that is NOT a block — the app default stays offered in Live"); + + // Add Webcam must place the existing app default without opening a picker. + vm.AddWebcamCommand.Execute(null); + Assert.NotNull(live.WebcamConfig); + Assert.Equal("wc-1", live.WebcamConfig!.WebcamId); + + // Per-scene max: the scene that already hosts the webcam stays greyed. + vm.StagedScene = starting; + Assert.False(vm.CanAddWebcam, "a scene already hosting the webcam must not offer it again"); + + // Removing placements keeps the app-level identity alive. + vm.RemoveSourceCommand.Execute(starting.WebcamConfig!); + Assert.True(vm.CanAddWebcam, "removing a placement re-enables Add for the same app default"); + + vm.StagedScene = live; + vm.RemoveSourceCommand.Execute(live.WebcamConfig!); + Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered"); vm.SaveLayoutNow(); using (var connection = new SqliteConnection($"Data Source={tempDb}")) @@ -65,21 +79,23 @@ public sealed class WebcamMenuGateTests connection.Open(); using var webcams = connection.CreateCommand(); webcams.CommandText = "SELECT COUNT(*) FROM Webcam;"; - Assert.Equal(0L, (long)webcams.ExecuteScalar()!); + Assert.Equal(1L, (long)webcams.ExecuteScalar()!); } } finally { window?.Close(); MainViewModel.LayoutPathOverride = null; + MainViewModel.CameraEnumeratorOverride = null; + MainViewModel.CameraFrameSourceFactoryOverride = null; SqliteConnection.ClearAllPools(); try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } } } /// Five canonical scenes + a webcam identity used by exactly one - /// non-staged-by-default scene (Starting), so staging any other screen - /// reproduces the old per-scene gate's false "available". + /// non-staged-by-default scene (Starting), so staging Live reproduces the + /// old app-wide gate's false "unavailable". private static void SeedDatabaseWithWebcam(string path) { using var connection = new SqliteConnection($"Data Source={path}"); @@ -120,4 +136,32 @@ public sealed class WebcamMenuGateTests config.ExecuteNonQuery(); } } -} + + private sealed class FakeEnumerator : ICameraEnumerator + { + public Task> GetCamerasAsync() + => Task.FromResult>(new[] + { + new CameraDeviceInfo("test-camera", "Test Cam"), + }); + } + + private sealed class FakeFrameSource : ICameraFrameSource + { + public string DeviceId { get; } + public event Action? FrameAvailable; +#pragma warning disable CS0067 + public event Action? SourceFailed; +#pragma warning restore CS0067 + + public FakeFrameSource(string deviceId) => DeviceId = deviceId; + + public Task StartAsync() + { + FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16])); + return Task.CompletedTask; + } + + public Task StopAsync() => Task.CompletedTask; + } +} \ No newline at end of file diff --git a/ytLive.Tests/WebcamStartupResourceTests.cs b/ytLive.Tests/WebcamStartupResourceTests.cs index a2cf597..0b01093 100644 --- a/ytLive.Tests/WebcamStartupResourceTests.cs +++ b/ytLive.Tests/WebcamStartupResourceTests.cs @@ -52,6 +52,7 @@ public sealed class WebcamStartupResourceTests Assert.Single(harness.Started); Assert.Equal("dev1", harness.Started[0]); Assert.NotNull(vm.StagedScene); + Assert.True(vm.CanAddWebcam, "the single camera becomes the app default — the Web Cam layer is offered"); }); private void RunZero() => RunWith(sceneBoilerplate: true, cameraCount: 0, failOnStart: false, @@ -149,7 +150,9 @@ public sealed class WebcamStartupResourceTests public string DeviceId { get; } private readonly Harness _harness; public event Action? FrameAvailable; +#pragma warning disable CS0067 public event Action? SourceFailed; +#pragma warning restore CS0067 public FakeWebcamSource(string deviceId, Harness harness) {