feat(webcam): app-default gate slice — per-scene offer, Add places default directly, identity survives removal

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.
This commit is contained in:
2026-09-17 08:56:04 -07:00
parent 1e4017df03
commit 9d00955004
9 changed files with 210 additions and 114 deletions
+1 -1
View File
@@ -30,7 +30,7 @@
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}"> <ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}" <MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
IsEnabled="{Binding CanAddWebcam}" IsEnabled="{Binding CanAddWebcam}"
ToolTip="One webcam at a time — it's already in your stream"/> ToolTip="{Binding WebcamAddToolTip}"/>
<MenuItem Header="Image" Command="{Binding AddImageCommand}"/> <MenuItem Header="Image" Command="{Binding AddImageCommand}"/>
<MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/> <MenuItem Header="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}" <MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
+56 -39
View File
@@ -1,11 +1,12 @@
# HANDOFF — 2026-09-17 (webcam resource lifecycle, startup slice; committed locally — no push) # HANDOFF — 2026-09-17 (webcam app-default gate slice; committed locally — no push)
## Branch / Commit State ## Branch / Commit State
`main` HEAD = **`94a934f` webcam reader-ladder fix** (local). This work unit sits un-pushed on top `main` HEAD = **`1e4017d` webcam startup resource lifecycle** (local). The gate slice lands
as a NEW commit (same bundle of web/A/V work — stays commit-local until greenlight). Below: slice-18 on top as a NEW commit (stays commit-local until greenlight). Below: `94a934f` (reader-ladder),
C4 cache (`64a5a6d`), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed audio-sync, pushed). `64a5a6d` (slice-18 C4 cache), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed
No push after this commit either — still awaiting the device take + user greenlight. audio-sync, pushed). No push after this commit either — still awaiting the device take + user
greenlight.
## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive ## ⚠️ Branding (2026-09-14, creator-corrected): product = **llamacasty**, internals = ytLive
@@ -13,36 +14,48 @@ Product is **llamacasty**; repo path, csproj `AssemblyName`/`RootNamespace`, DB/
(`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text: (`%APPDATA%\ytLlive\...`), most code names are legacy **ytLive/ytLlive**. User-facing text:
"llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…). "llamacasty" / UI labels use the catalog names ("Web Cam", "YouTube Chat", "Countdown Timer"…).
## ✅ Committed locally — webcam resource lifecycle (startup slice) ## ✅ Committed — webcam app-default gate slice (creator-visible goal: Web Cam offered when switching to Live)
**Incident review (the reason this exists):** the creator couldn't add a webcam to the Live scene **Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
(WebCam row grayed app-wide). Ground truth from `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`, file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
which is a 0-byte legacy file): one `Webcam` identity (C920 `\?\USB#VID_046D&PID_082D…GLOBAL`) AND Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
one `WebcamSceneConfig` — in the **Chat** scene. Under the single-identity rule that means Live's resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
"+" menu is gray *by design*, but the app couldn't *say why*. Combined with the earlier crash/failure every scene that can host it; it is NOT blocked by other scenes' placements.** Scope of this slice:
chain, the fix is a proper resource lifecycle: the availability layer. TASK 26's app-wide gate is **superseded** by creator directive.
- **Startup poll + tri-state** (`MainViewModel.ValidateWebcamResourceStartupAsync`, fired - **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`.
fire-and-forget right after `LoadLayout`): 0 webcams → app runs on, layer inactive, no alarm; A scene already hosting the webcam stays gray (one webcam per stream); another scene holding it
**1 → attempt `CameraManager.AcquireAsync` as an app-wide LOCK**; **≥2 → no auto-lock** — selection does NOT gray the row elsewhere.
belongs to the App Settings dialog (gear) — that's the next slice. - **Add places the default directly:** when `_webcam != null`, "Add Webcam" creates the placement
- **Persistent red alert** (`WebcamLockAlert`, bottom of the Layers panel, + Retry button): shown when with NO picker; the Windows picker runs only for the initial selection (`_webcam == null`;
the one detected camera can't be locked; **re-polls every 5 s** (`_webcamLockPollTimer`) and clears choosing there swaps app-wide via the existing `SwapWebcamIdentityAsync`).
the moment a lock succeeds, or on any first real frame (`OnCameraPreviewBitmapChanged`). - **Identity survives last-placement removal:** `RemoveElement` no longer nulls `_webcam` (that was
- **`CameraManager.IsRunning(deviceId)`** — new accessor (session exists, started OR starting) so the the TASK 26 rule). `LayoutStore` writes the singleton whenever it's non-null, so it persists.
pass never double-acquires a lock the loaded identity's configs already hold; a rolled-back (failed) - **Startup adopts a solo camera as the app default** (in `ValidateWebcamResourceStartupAsync`,
session leaves the dictionary, so the pass retries cleanly. after the successful single-cam lock): a clean/empty layout now offers the Web Cam layer in
- **Test seams mirroring `LayoutPathOverride`:** `CameraEnumeratorOverride` / Live/Chat immediately; `CameraManager.IsRunning` skip means a loaded identity already holding a
`CameraFrameSourceFactoryOverride` — the startup probe must NEVER touch real hardware under test session is never overwritten.
(the reason the 09:15 tests never asserted camera state). **Good Dog test** - **Dynamic why-gray tooltip:** `WebcamAddToolTip` (raised alongside the gates) — "Already in this
`WebcamStartupResourceTests` x3: single-cam locked + app runs on; zero-cams no-alarm no-lock; scene — one webcam per stream. A second camera means you've graduated to OBS." / "No webcam
lock-fails → red alert → Retry → clears. **304/304 green, build 0 warnings.** detected…" / "Adds the app default webcam to this scene."
- **Attribution correction committed to `ai.md`:** "NVIDIA Broadcast opens the webcam - **Good Dog integration test** `WebcamMenuGateTests` rewritten for the new model (real app + temp
exclusively" is a *suspect-list* claim — `CameraConflictProbe` reads running process NAMES only, no DB + camera seams): Chat's identity does NOT gray Live; Add in Live places the same `wc-1`
device handles; the contention symptoms fit shared-mode/bandwidth just as well. Do not restate it as without a picker; scene-with-placement stays gray; identity survives both removals (DB row count
fact (creator called this out). keeps 1). `WebcamStartupResourceTests` test 1 strengthened: after startup the single camera is
adopted → `CanAddWebcam` true. **304/304 green, build 0 warnings.**
**Task docs:** `TASKS.md` Open items + `ai.md` Webcam section updated to match. **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
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
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` (mirror `LayoutPathOverride`).
Attribution correction committed: "NVIDIA Broadcast opens the webcam exclusively" is a *suspect-list*
claim — `CameraConflictProbe` reads process names only, no device handles; do not restate as fact.
## ⚠️ Open items ## ⚠️ Open items
@@ -51,18 +64,20 @@ chain, the fix is a proper resource lifecycle:
reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the reader+capture while the frame thread sits in `TryAcquireLatestFrame`/`Marshal.Copy` (outside the
frame's try/catch). No repro, no integration test, native race not catchable — deferred per frame's try/catch). No repro, no integration test, native race not catchable — deferred per
spin-guard. Record a second occurrence's pattern. spin-guard. Record a second occurrence's pattern.
- **Multi-camera slice (user-queued):** when ≥2 webcams exist, add the web-cam selection text + - **Multi-camera selector slice (user-queued):** when ≥2 webcams exist, add web-cam selection text +
control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings, control to the **App Settings** dialog (gear icon, `OverlayHost` — left-click opens settings,
right-click the context menu). Selecting + successfully allocating/locking activates the WebCam right-click the context menu). Selecting + successfully allocating/locking activates the WebCam
layer. Currently ≥2 cameras → nothing shows in settings. layer. **Currently ≥2 cameras → nothing shows in settings.** Also: the "Change Webcam" picker
should carry the OBS one-liner (one webcam per stream — a second means OBS).
- **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam", - **Static (+) catalog slice (user-queued):** all catalog rows always listed (Background, "Web Cam",
"Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level "Countdown Timer", "Web Resource", "YouTube Chat", "YouTube Event"…), gray only for (1) system-level
unavailability (persistent alert), (2) max already allocated, (3) not available for the current unavailability (persistent alert), (2) max already allocated, (3) not available for the current
scene; tooltips must name the reason (e.g. "already in Chat"). Static labels only, no new code-behind. scene; tooltips must name the reason. Static labels only, no new code-behind.
- **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band - **Device re-verify (one take session):** 1. slice-18 cache verdicts (NR/WH hits, stalls, band
~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED ~60); 2. webcam: fresh launch with the browser holding the camera must show the webcam or a NAMED
chip (now also the persistent red alert, and the alert must clear after the browser closes + retry); chip (now also the persistent red alert, and the alert must clear after the browser closes + retry);
3. verify the Chat scene still owns the webcam (so Live's + is intentionally gray with a reason). 3. verify Live now OFFERS the Web Cam layer even while Chat holds the camera (the exact creator
goal: "webcam offered in the layer stack when I switch over to the Live view").
- **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`), - **No push yet** — after the take verdict, re-measure the clap offset (`/tmp/opencode/avsync.py`),
then decide push with the user. then decide push with the user.
@@ -103,6 +118,8 @@ chain, the fix is a proper resource lifecycle:
## Next step ## Next step
The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take The multi-camera App Settings selector slice (+ static catalog rows after), then the creator's take
on this build. The alert is the new expected behavior to eyeball on the take: browser holds the on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
camera → fresh launch → red alert in Layers (unless Chat's config grabbed it) that clears after a must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
Retry once the browser closes. Layers alert remains the new expected behavior to eyeball on the take: browser holds the camera →
fresh launch → alert (unless Chat's config grabbed it) that clears after a Retry once the browser
closes.
+7 -4
View File
@@ -65,10 +65,13 @@
- **TASK 14** — Branch 2 shipped; branch 3+ in progress - **TASK 14** — Branch 2 shipped; branch 3+ in progress
- **TASK 21** — UI picker slice remaining (Windows-only verification pending) - **TASK 21** — UI picker slice remaining (Windows-only verification pending)
- **TASK 32–36** — all queued future work (2026-09-01) - **TASK 32–36** — all queued future work (2026-09-01)
- **Webcam resource lifecycle (2026-09-17)** — startup slice shipped: OS poll at start, single-camera - **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). Next auto-lock, persistent red alert in the Layers panel on lock failure, re-polls until resolved); gate
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+) slice shipped same day — webcam = app-level default, one per stream per scene, Add places the
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying. 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.
--- ---
+1
View File
@@ -49,6 +49,7 @@ public partial class MainViewModel
OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanAddYouTubeChat)); OnPropertyChanged(nameof(CanAddYouTubeChat));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
OnPropertyChanged(nameof(IsCountdownAvailable)); OnPropertyChanged(nameof(IsCountdownAvailable));
UpdateActiveBackground(); UpdateActiveBackground();
UpdateBackgroundImage(); UpdateBackgroundImage();
+5 -7
View File
@@ -217,9 +217,9 @@ public partial class MainViewModel
// Removes an element from the active scene. For a webcam config this drops the // Removes an element from the active scene. For a webcam config this drops the
// scene's usage and releases one camera reference (CameraManager stops the // scene's usage and releases one camera reference (CameraManager stops the
// session when the last using scene lets go). When the last config anywhere is // session when the last using scene lets go). The webcam identity survives —
// removed, the webcam identity is cleared too — re-adding opens the picker // it's the app-level default, so removing a scene's placement re-enables Add
// again instead of silently resurrecting the old camera. // for the same camera instead of dropping it.
private void RemoveElement(SceneElement? element) private void RemoveElement(SceneElement? element)
{ {
var scene = StagedScene; var scene = StagedScene;
@@ -235,13 +235,11 @@ public partial class MainViewModel
_webView2Manager?.Unregister(webSource.Id); _webView2Manager?.Unregister(webSource.Id);
_sceneGraph.RemoveElement(scene, element); _sceneGraph.RemoveElement(scene, element);
if (element is WebcamSceneConfig && _webcam != null if (element is WebcamSceneConfig)
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
{ {
_webcam = null;
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(CanShowWebcamInStagedScene)); OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
OnPropertyChanged(nameof(WebcamAddToolTip));
} }
OnPropertyChanged(nameof(ShowEmptySceneHint)); OnPropertyChanged(nameof(ShowEmptySceneHint));
+45 -20
View File
@@ -22,9 +22,30 @@ public partial class MainViewModel
private string? _webcamLockAlert; private string? _webcamLockAlert;
private DispatcherTimer? _webcamLockPollTimer; private DispatcherTimer? _webcamLockPollTimer;
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere — /// <summary>
/// one camera identity app-wide; re-adding always opens the picker.</summary> /// Add → Webcam menu item: the webcam is an app-level resource (one camera —
public bool CanAddWebcam => StagedScene != null && _webcam == null; /// 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).
/// </summary>
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null;
/// <summary>
/// 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.
/// </summary>
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.";
}
}
/// <summary> /// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no /// 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); var started = await _cameraManager.AcquireAsync(device.Id);
if (started) 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; WebcamLockAlert = null;
return; return;
} }
@@ -237,33 +267,28 @@ public partial class MainViewModel
WebcamLockAlert = $"Web Cam unavailable: {reason}"; WebcamLockAlert = $"Web Cam unavailable: {reason}";
} }
// Adds the webcam to the active scene. The creator ALWAYS picks from the // Adds the webcam to the active scene. The webcam is an app-level resource:
// cameras Windows has registered — never silently resurrects the previous // once a camera is the app default, adding the layer just places it — the
// camera (which is what happened after deleting one scene's webcam while // picker runs only for the initial selection. Each scene's placement config
// another scene still used it). Picking a different camera than the current // is independent (webcam.{scene}.config); the camera device is Windows' own
// app-wide one swaps it everywhere, so the single-identity model stays honest; // singleton and stays where we put it.
// each scene's placement config is independent (webcam.{scene}.config).
private async Task AddWebcamToStagedSceneAsync() private async Task AddWebcamToStagedSceneAsync()
{ {
var scene = StagedScene; var scene = StagedScene;
if (scene == null || scene.WebcamConfig != null) return; 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) 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 }; _webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam)); OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam)); OnPropertyChanged(nameof(CanAddWebcam));
} OnPropertyChanged(nameof(WebcamAddToolTip));
else if (_webcam.DeviceId != device.Id)
{
await SwapWebcamIdentityAsync(device);
} }
var config = new WebcamSceneConfig var config = new WebcamSceneConfig
+23 -18
View File
@@ -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 - **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 packs — no NuGet package, no capability manifest (unpackaged desktop app works; the Windows privacy
camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working. camera toggle still applies). `EnableWindowsTargeting` keeps WSL builds working.
- **One webcam, many scenes (schema v3):** a singleton `Webcam` row holds the identity - **One webcam, many scenes (schema v3) — app-level default (2026-09-17):** a singleton `Webcam`
(`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig` (position/size/clip/mirror/ row holds the identity (`Id`/`DeviceId`/`Name`); each scene gets its own `WebcamSceneConfig`
border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at most once, the webcam (position/size/clip/mirror/border/`IsVisible`). `Scene.Elements` holds images (`Source`) and, at
(`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager` refcounts capture most once, the webcam (`WebcamSceneConfig`); `Scene.WebcamConfig` is the accessor. `CameraManager`
sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps it; the last refcounts capture sessions by `DeviceId` (a session starts at `RefCount = 1`; repeat acquire bumps
release stops + disposes). **"Add Webcam" ALWAYS opens the Windows camera picker** — the creator is it; the last release stops + disposes). **The webcam is an app-level resource — ONE selection (the
never silently handed the previous camera (which used to happen after deleting one scene's webcam app default), usable in every scene that can host it.** "Add Webcam" places the existing default
while another scene still used it; that identity survived, so re-adding bypassed the choice). DIRECTLY (no picker); the Windows camera picker runs only for the initial selection
Picking a different camera than the current app-wide one swaps it everywhere via (`_webcam == null`). Choosing a different camera in the picker swaps it app-wide via
`SwapWebcamIdentityAsync` (the same path "Change Webcam…" uses), keeping the singleton honest; `SwapWebcamIdentityAsync` (same path "Change Webcam…" uses). **Per-scene gate, not app-wide**
picking the same one just places the config. The Add Webcam menu item greys out whenever a webcam (TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`
exists **anywhere** — one camera identity app-wide (`CanAddWebcam` = `StagedScene != null && _webcam == null`, — a scene holding its own placement greys the row (one webcam per stream), but ANOTHER scene
TASK 26; the old per-staged-scene gate let a second picker run from a scene that lacked the config). holding one does not, so Chat's camera never greys Live; the greyed-row tooltip carries the reason
**Removing the last webcam config anywhere clears the identity** (`_webcam = null`, raising (`WebcamAddToolTip`, incl. the gentle "a second camera means you've graduated to OBS" line).
`CanAddWebcam`/`CanChangeWebcam`), which also drops "Change Webcam…" and re-enables Add. 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 - **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 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 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 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 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 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` `CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
drive the probe without real hardware (0-cam no-alarm, 1-cam locked, lock-fail → alert → clears on drive the probe without real hardware (0-cam no-alarm, 1-cam locked → identity adopted, lock-fail
retry). **Attribution correction:** the "NVIDIA Broadcast opens the webcam exclusively" failure → 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 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`). 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): The **YouTube Chat layer follows the same one-per-layout rule** (TASK 27):
+68 -24
View File
@@ -1,25 +1,29 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Threading.Tasks;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Xunit; using Xunit;
using ytLive.Models; using ytLive.Services;
using ytLive.ViewModels; using ytLive.ViewModels;
namespace ytLive.Tests; namespace ytLive.Tests;
/// <summary> /// <summary>
/// TASK 26 integration: the (+) menu's Webcam row gates on the app-wide webcam /// The webcam is an app-level resource with ONE selection (the app default):
/// identity — one camera per app, so the row must stay disabled while ANY scene /// placing it in one scene must NOT grey it in the others (per-scene max, not
/// shows the webcam (not just the staged one), and re-enable only after the /// app-wide), "Add Webcam" places the existing default directly (no picker —
/// last config anywhere is removed (which also drops the identity). /// the picker only runs for the initial selection), and removing the last
/// Real-App + temp-DB pattern (see BackgroundHealIntegrationTests). /// 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).
/// </summary> /// </summary>
[Collection("RealApp")] [Collection("RealApp")]
public sealed class WebcamMenuGateTests public sealed class WebcamMenuGateTests
{ {
[Fact] [Fact]
public void CanAddWebcam_Gates_On_The_AppWide_Webcam_Identity() public void WebcamIsAppDefault_AddPlacesSameCamera_AnyScene_IdentitySurvivesRemoval()
{ {
_app.Run(Run); _app.Run(Run);
} }
@@ -32,6 +36,8 @@ public sealed class WebcamMenuGateTests
{ {
var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db"); var tempDb = Path.Combine(Path.GetTempPath(), $"ytLlive-wcgate-{Guid.NewGuid():N}.db");
MainViewModel.LayoutPathOverride = tempDb; MainViewModel.LayoutPathOverride = tempDb;
MainViewModel.CameraEnumeratorOverride = new FakeEnumerator();
MainViewModel.CameraFrameSourceFactoryOverride = id => new FakeFrameSource(id);
var window = default(MainWindow); var window = default(MainWindow);
try try
{ {
@@ -42,22 +48,30 @@ public sealed class WebcamMenuGateTests
window = new MainWindow(); window = new MainWindow();
var vm = (MainViewModel)window.DataContext; var vm = (MainViewModel)window.DataContext;
var starting = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Starting)); var starting = vm.Scenes.Single(s => s.Name == "Starting");
var live = vm.Scenes.Single(s => SceneCatalog.Is(s.Name, SceneCatalog.Live)); var live = vm.Scenes.Single(s => s.Name == "Live");
vm.StagedScene = live; vm.StagedScene = live;
Assert.True(live.WebcamConfig == null, "Live must not carry the webcam config"); Assert.Null(live.WebcamConfig);
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.True(vm.CanAddWebcam, 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(); vm.SaveLayoutNow();
using (var connection = new SqliteConnection($"Data Source={tempDb}")) using (var connection = new SqliteConnection($"Data Source={tempDb}"))
@@ -65,21 +79,23 @@ public sealed class WebcamMenuGateTests
connection.Open(); connection.Open();
using var webcams = connection.CreateCommand(); using var webcams = connection.CreateCommand();
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;"; webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
Assert.Equal(0L, (long)webcams.ExecuteScalar()!); Assert.Equal(1L, (long)webcams.ExecuteScalar()!);
} }
} }
finally finally
{ {
window?.Close(); window?.Close();
MainViewModel.LayoutPathOverride = null; MainViewModel.LayoutPathOverride = null;
MainViewModel.CameraEnumeratorOverride = null;
MainViewModel.CameraFrameSourceFactoryOverride = null;
SqliteConnection.ClearAllPools(); SqliteConnection.ClearAllPools();
try { File.Delete(tempDb); } catch { /* best-effort cleanup */ } try { File.Delete(tempDb); } catch { /* best-effort cleanup */ }
} }
} }
/// <summary>Five canonical scenes + a webcam identity used by exactly one /// <summary>Five canonical scenes + a webcam identity used by exactly one
/// non-staged-by-default scene (Starting), so staging any other screen /// non-staged-by-default scene (Starting), so staging Live reproduces the
/// reproduces the old per-scene gate's false "available".</summary> /// old app-wide gate's false "unavailable".</summary>
private static void SeedDatabaseWithWebcam(string path) private static void SeedDatabaseWithWebcam(string path)
{ {
using var connection = new SqliteConnection($"Data Source={path}"); using var connection = new SqliteConnection($"Data Source={path}");
@@ -120,4 +136,32 @@ public sealed class WebcamMenuGateTests
config.ExecuteNonQuery(); config.ExecuteNonQuery();
} }
} }
private sealed class FakeEnumerator : ICameraEnumerator
{
public Task<IReadOnlyList<CameraDeviceInfo>> GetCamerasAsync()
=> Task.FromResult<IReadOnlyList<CameraDeviceInfo>>(new[]
{
new CameraDeviceInfo("test-camera", "Test Cam"),
});
}
private sealed class FakeFrameSource : ICameraFrameSource
{
public string DeviceId { get; }
public event Action<VideoFrame>? FrameAvailable;
#pragma warning disable CS0067
public event Action<string>? 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;
}
} }
@@ -52,6 +52,7 @@ public sealed class WebcamStartupResourceTests
Assert.Single(harness.Started); Assert.Single(harness.Started);
Assert.Equal("dev1", harness.Started[0]); Assert.Equal("dev1", harness.Started[0]);
Assert.NotNull(vm.StagedScene); 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, private void RunZero() => RunWith(sceneBoilerplate: true, cameraCount: 0, failOnStart: false,
@@ -149,7 +150,9 @@ public sealed class WebcamStartupResourceTests
public string DeviceId { get; } public string DeviceId { get; }
private readonly Harness _harness; private readonly Harness _harness;
public event Action<VideoFrame>? FrameAvailable; public event Action<VideoFrame>? FrameAvailable;
#pragma warning disable CS0067
public event Action<string>? SourceFailed; public event Action<string>? SourceFailed;
#pragma warning restore CS0067
public FakeWebcamSource(string deviceId, Harness harness) public FakeWebcamSource(string deviceId, Harness harness)
{ {