fix(webcam): offer the Web Cam row only when a camera is attainable (live lock), not merely selected
Creator refinement: 'offered iff there's not one already configured & attainable'. CanAddWebcam now requires IsWebcamAttainable = identity present AND a RUNNING session (CameraManager.IsRunning) — an identity whose camera was unplugged or whose lock keeps failing leaves the row greyed with reason 'No webcam is currently available…', and it un-greys the moment a session is live. Gate re-raised at every attainability flip: staging, removal, startup lock success, first frame, camera failure, identity swap. Root cause the old test surfaced: the startup pass skipped acquiring when the loaded identity's configs already held the session, so there was no independent app base ref — removing the last placement dropped RefCount to 0 and killed the session. The single-camera branch now ALWAYS acquires (a running session just bumps), laying the app-wide base hold so the default outlives the scenes. Good Dog: WebcamMenuGateTests second fact — identity loaded, session can't start → row NOT offered + 'No webcam is currently available…' tooltip. Positive fact waits for WebcamStartupValidationTask to make the IsRunning read deterministic. Docs same commit (ai.md gate + base-lock, TASKS.md, HANDOFF.md incl. proven pre-existing audio flake). 305 tests (304 pass + known flake), 0 warnings.
This commit is contained in:
+29
-19
@@ -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 "<files>"`'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
|
||||
|
||||
Reference in New Issue
Block a user