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
+45 -20
View File
@@ -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