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:
@@ -30,7 +30,7 @@
|
||||
<ContextMenu DataContext="{Binding PlacementTarget.DataContext, RelativeSource={RelativeSource Self}}">
|
||||
<MenuItem Header="Webcam" Command="{Binding AddWebcamCommand}"
|
||||
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="Text" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.TextOverlay}"/>
|
||||
<MenuItem Header="Countdown" Command="{Binding AddSourceCommand}" CommandParameter="{x:Static models:SourceType.Countdown}"
|
||||
|
||||
+56
-39
@@ -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
|
||||
|
||||
`main` HEAD = **`94a934f` webcam reader-ladder fix** (local). This work unit sits un-pushed on top
|
||||
as a NEW commit (same bundle of web/A/V work — stays commit-local until greenlight). Below: slice-18
|
||||
C4 cache (`64a5a6d`), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed audio-sync, pushed).
|
||||
No push after this commit either — still awaiting the device take + user greenlight.
|
||||
`main` HEAD = **`1e4017d` webcam startup resource lifecycle** (local). The gate slice lands
|
||||
on top as a NEW commit (stays commit-local until greenlight). Below: `94a934f` (reader-ladder),
|
||||
`64a5a6d` (slice-18 C4 cache), slice-17, slice-16, slice-15, `c01206f`, `b22d08e` (signed
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
"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
|
||||
(WebCam row grayed app-wide). Ground truth from `%APPDATA%\ytLlive\ytLlive.db` (NOT `layout.db`,
|
||||
which is a 0-byte legacy file): one `Webcam` identity (C920 `\?\USB#VID_046D&PID_082D…GLOBAL`) AND
|
||||
one `WebcamSceneConfig` — in the **Chat** scene. Under the single-identity rule that means Live's
|
||||
"+" menu is gray *by design*, but the app couldn't *say why*. Combined with the earlier crash/failure
|
||||
chain, the fix is a proper resource lifecycle:
|
||||
**Background:** the DB truth (`%APPDATA%\ytLlive\ytLlive.db`; NOT `layout.db`, a 0-byte legacy
|
||||
file) put the single `WebcamSceneConfig` in **Chat**, so the old TASK 26 app-wide rule grayed
|
||||
Live's "+" → Web Cam even though the camera was available and in use. The creator ruled (rigorous
|
||||
resource model): **the webcam is an app-level resource — ONE app-wide default selection, usable in
|
||||
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.
|
||||
|
||||
- **Startup poll + tri-state** (`MainViewModel.ValidateWebcamResourceStartupAsync`, fired
|
||||
fire-and-forget right after `LoadLayout`): 0 webcams → app runs on, layer inactive, no alarm;
|
||||
**1 → attempt `CameraManager.AcquireAsync` as an app-wide LOCK**; **≥2 → no auto-lock** — selection
|
||||
belongs to the App Settings dialog (gear) — that's the next slice.
|
||||
- **Persistent red alert** (`WebcamLockAlert`, bottom of the Layers panel, + Retry button): shown when
|
||||
the one detected camera can't be locked; **re-polls every 5 s** (`_webcamLockPollTimer`) and clears
|
||||
the moment a lock succeeds, or on any first real frame (`OnCameraPreviewBitmapChanged`).
|
||||
- **`CameraManager.IsRunning(deviceId)`** — new accessor (session exists, started OR starting) so the
|
||||
pass never double-acquires a lock the loaded identity's configs already hold; a rolled-back (failed)
|
||||
session leaves the dictionary, so the pass retries cleanly.
|
||||
- **Test seams mirroring `LayoutPathOverride`:** `CameraEnumeratorOverride` /
|
||||
`CameraFrameSourceFactoryOverride` — the startup probe must NEVER touch real hardware under test
|
||||
(the reason the 09:15 tests never asserted camera state). **Good Dog test**
|
||||
`WebcamStartupResourceTests` x3: single-cam locked + app runs on; zero-cams no-alarm no-lock;
|
||||
lock-fails → red alert → Retry → clears. **304/304 green, build 0 warnings.**
|
||||
- **Attribution correction committed to `ai.md`:** "NVIDIA Broadcast opens the webcam
|
||||
exclusively" is a *suspect-list* claim — `CameraConflictProbe` reads running process NAMES only, no
|
||||
device handles; the contention symptoms fit shared-mode/bandwidth just as well. Do not restate it as
|
||||
fact (creator called this out).
|
||||
- **Per-scene gate:** `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && _webcam != null`.
|
||||
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`).
|
||||
- **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.**
|
||||
|
||||
**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
|
||||
|
||||
@@ -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
|
||||
frame's try/catch). No repro, no integration test, native race not catchable — deferred per
|
||||
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,
|
||||
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",
|
||||
"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
|
||||
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
|
||||
~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);
|
||||
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`),
|
||||
then decide push with the user.
|
||||
|
||||
@@ -103,6 +118,8 @@ chain, the fix is a proper resource lifecycle:
|
||||
## Next step
|
||||
|
||||
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
|
||||
camera → fresh launch → red alert in Layers (unless Chat's config grabbed it) that clears after a
|
||||
Retry once the browser closes.
|
||||
on this build. The webcam gate slice answers the creator's "go" ask — switching to the Live view
|
||||
must now offer Web Cam in the layer stack (Chat holding the camera no longer blocks it). The red
|
||||
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.
|
||||
@@ -65,10 +65,13 @@
|
||||
- **TASK 14** — Branch 2 shipped; branch 3+ in progress
|
||||
- **TASK 21** — UI picker slice remaining (Windows-only verification pending)
|
||||
- **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
|
||||
auto-lock, persistent red alert in the Layers panel on lock failure (re-polls until resolved). Next
|
||||
slices (user-queued): multi-camera webcam selector in the App Settings dialog (gear), static (+)
|
||||
catalog rows (Background, YouTubeEvent, quoted labels) with reason-greying.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ public partial class MainViewModel
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanAddYouTubeChat));
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||||
OnPropertyChanged(nameof(IsCountdownAvailable));
|
||||
UpdateActiveBackground();
|
||||
UpdateBackgroundImage();
|
||||
|
||||
@@ -217,9 +217,9 @@ public partial class MainViewModel
|
||||
|
||||
// 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
|
||||
// session when the last using scene lets go). When the last config anywhere is
|
||||
// removed, the webcam identity is cleared too — re-adding opens the picker
|
||||
// again instead of silently resurrecting the old camera.
|
||||
// session when the last using scene lets go). The webcam identity survives —
|
||||
// it's the app-level default, so removing a scene's placement re-enables Add
|
||||
// for the same camera instead of dropping it.
|
||||
private void RemoveElement(SceneElement? element)
|
||||
{
|
||||
var scene = StagedScene;
|
||||
@@ -235,13 +235,11 @@ public partial class MainViewModel
|
||||
_webView2Manager?.Unregister(webSource.Id);
|
||||
_sceneGraph.RemoveElement(scene, element);
|
||||
|
||||
if (element is WebcamSceneConfig && _webcam != null
|
||||
&& !Scenes.Any(s => s.Elements.OfType<WebcamSceneConfig>().Any()))
|
||||
if (element is WebcamSceneConfig)
|
||||
{
|
||||
_webcam = null;
|
||||
OnPropertyChanged(nameof(CanChangeWebcam));
|
||||
OnPropertyChanged(nameof(CanAddWebcam));
|
||||
OnPropertyChanged(nameof(CanShowWebcamInStagedScene));
|
||||
OnPropertyChanged(nameof(WebcamAddToolTip));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(ShowEmptySceneHint));
|
||||
|
||||
@@ -22,9 +22,30 @@ public partial class MainViewModel
|
||||
private string? _webcamLockAlert;
|
||||
private DispatcherTimer? _webcamLockPollTimer;
|
||||
|
||||
/// <summary>The Add → Webcam menu item: enabled only while no webcam exists anywhere —
|
||||
/// one camera identity app-wide; re-adding always opens the picker.</summary>
|
||||
public bool CanAddWebcam => StagedScene != null && _webcam == null;
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </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>
|
||||
/// 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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
[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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>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".</summary>
|
||||
/// non-staged-by-default scene (Starting), so staging Live reproduces the
|
||||
/// old app-wide gate's false "unavailable".</summary>
|
||||
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<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.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<VideoFrame>? FrameAvailable;
|
||||
#pragma warning disable CS0067
|
||||
public event Action<string>? SourceFailed;
|
||||
#pragma warning restore CS0067
|
||||
|
||||
public FakeWebcamSource(string deviceId, Harness harness)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user