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:
2026-09-17 09:16:56 -07:00
parent 9d00955004
commit 75723acc1b
5 changed files with 135 additions and 75 deletions
+35 -21
View File
@@ -23,17 +23,22 @@ public partial class MainViewModel
private DispatcherTimer? _webcamLockPollTimer;
/// <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).
/// Add → Webcam menu item. The row is offered iff the SCENE has no webcam
/// config yet AND a camera is attainable — an app default is selected and
/// its session is RUNNING right now (identity alone is not enough: the
/// device may have been unplugged, or the lock may still be failing, in
/// which case the row stays grey and the persistent alert says why).
/// Per-scene max is per-scene: another scene holding its own placement does
/// NOT grey this one.
/// </summary>
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && _webcam != null;
public bool CanAddWebcam => StagedScene is { WebcamConfig: null } && IsWebcamAttainable;
private bool IsWebcamAttainable => _webcam != null && _cameraManager.IsRunning(_webcam.DeviceId);
/// <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.
/// reason gets the gentle "graduated to OBS" line; no attainable camera gets
/// its own wording. Shown as the row's tooltip.
/// </summary>
public string WebcamAddToolTip
{
@@ -41,12 +46,20 @@ public partial class MainViewModel
{
if (StagedScene?.WebcamConfig != null)
return "Already in this scene — one webcam per stream. A second camera means you've graduated to OBS.";
if (_webcam == null)
return "No webcam detected — plug one in, or allow camera access in Windows.";
if (!IsWebcamAttainable)
return "No webcam is currently available — plug one in, or allow camera access in Windows.";
return "Adds the app default webcam to this scene.";
}
}
// Re-raise the rows that read live CameraManager session state: the offer
// flips the moment a session starts (lock acquired, first frame) or dies.
private void RaiseWebcamGateChanged()
{
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(WebcamAddToolTip));
}
/// <summary>
/// Right-click-on-preview → "Show Webcam": offered when the active scene has no
/// webcam config (add one) or hides it (unhide — keeps the config row).
@@ -193,6 +206,7 @@ public partial class MainViewModel
if (_webcam?.DeviceId != deviceId) return;
WebcamError = null;
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
RaiseWebcamGateChanged();
foreach (var scene in Scenes)
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
config.VideoImageSource = bitmap;
@@ -204,6 +218,7 @@ public partial class MainViewModel
{
if (_webcam?.DeviceId != deviceId) return;
WebcamError = $"Webcam offline: {message}";
RaiseWebcamGateChanged();
}
/// <summary>
@@ -239,26 +254,23 @@ public partial class MainViewModel
var device = cameras![0];
// A session already started/starting for this device (loaded identity)
// means we already hold the lock — nothing to do.
if (_cameraManager.IsRunning(device.Id))
{
WebcamLockAlert = null;
return;
}
// Lay the app-wide base lock. AcquireAsync on an already-running session
// (loaded identity's configs) just bumps its refcount — that bump is the
// app's own hold, so removing EVERY scene's placement keeps the session
// alive and the Web Cam row offered (the app default outlives the scenes).
var started = await _cameraManager.AcquireAsync(device.Id);
if (started)
{
// The single camera becomes the app default so the Web Cam layer is
// offered in the capabled scenes (Live/Chat) even on a clean layout.
// The camera is now provably attainable: adopt the new identity when
// there wasn't one, then re-raise the offer either way
// (RaiseWebcamGateChanged) so an existing identity whose session was
// down un-greys the row the moment the lock is real.
if (_webcam == null)
{
_webcam = new Webcam { DeviceId = device.Id, Name = device.DisplayName };
OnPropertyChanged(nameof(CanChangeWebcam));
OnPropertyChanged(nameof(CanAddWebcam));
OnPropertyChanged(nameof(WebcamAddToolTip));
}
RaiseWebcamGateChanged();
WebcamLockAlert = null;
return;
}
@@ -344,6 +356,8 @@ public partial class MainViewModel
break;
}
}
RaiseWebcamGateChanged();
}
// "Change Webcam…" from the webcam's context menu: picker, then swap the