feat(webcam): resource lifecycle startup slice — poll-on-start, single-cam lock, persistent Layers alert
The creator couldn't add a webcam to Live (grayed app-wide) and nothing in the app explained why. Ground truth from the live DB: one Webcam identity AND one WebcamSceneConfig in the Chat scene — so the gray was the single-identity rule working, but the reason was unobservable. This slice makes the webcam a resource the app validates and locks, mirroring how OBS reserves its devices. At startup we enumerate the OS once (ValidateWebcamResourceStartupAsync, fired after LoadLayout, stored as WebcamStartupValidationTask for tests to await): - 0 webcams -> app runs on, layer inactive, no alarm - exactly 1 -> attempt CameraManager.AcquireAsync as an app-wide lock; on failure show a persistent red alert at the bottom of the Layers panel (WebcamLockAlert + Retry) that re-polls every 5s and clears itself the moment the camera locks, or on any first real frame - >=2 -> deliberately no auto-lock; camera selection belongs to the App Settings dialog (gear) — next slice CameraManager.IsRunning(deviceId) tells the pass a session already exists (started OR still starting) so loaded identity configs count as the lock and the pass never double-acquires. Test seams mirror LayoutPathOverride: CameraEnumeratorOverride / CameraFrameSourceFactoryOverride so the startup probe never touches real hardware under test. Good Dog test: WebcamStartupResourceTests x3 — single-cam locked + app runs on, zero-cams no-alarm/no-lock, lock-fails -> red alert -> Retry -> clears. Full suite 304/304, build 0 warnings, scope-check passed. Docs in-commit: TASKS Open items + ai.md Webcam section + HANDOFF rewrite. Also corrects the record: 'NVIDIA Broadcast opens the webcam exclusively' was a suspect-list claim (CameraConflictProbe reads process names only, no handles) — not restated as fact.
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media.Imaging;
|
||||
using System.Windows.Threading;
|
||||
using ytLive;
|
||||
using ytLive.Helpers;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
|
||||
@@ -16,6 +19,8 @@ public partial class MainViewModel
|
||||
private readonly CameraManager _cameraManager;
|
||||
private Webcam? _webcam;
|
||||
private string? _webcamError;
|
||||
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>
|
||||
@@ -45,6 +50,37 @@ public partial class MainViewModel
|
||||
private set => SetProperty(ref _webcamError, value);
|
||||
}
|
||||
|
||||
// Startup resource validation keeps its task for tests to await (the real
|
||||
// app fires it fire-and-forget from the ctor).
|
||||
internal Task? WebcamStartupValidationTask { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Red alert shown at the bottom of the Layers panel when the one-and-only
|
||||
/// detected webcam could NOT be locked at startup. Persistent: while set, a
|
||||
/// light re-poll re-runs the lock check so the moment the camera frees up
|
||||
/// (another app closed) the alert clears itself; a Retry button does it now.
|
||||
/// </summary>
|
||||
public string? WebcamLockAlert
|
||||
{
|
||||
get => _webcamLockAlert;
|
||||
private set
|
||||
{
|
||||
if (!SetProperty(ref _webcamLockAlert, value)) return;
|
||||
if (value == null)
|
||||
{
|
||||
_webcamLockPollTimer?.Stop();
|
||||
}
|
||||
else if (_webcamLockPollTimer == null)
|
||||
{
|
||||
_webcamLockPollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(5) };
|
||||
_webcamLockPollTimer.Tick += (_, _) => _ = ValidateWebcamResourceStartupAsync();
|
||||
_webcamLockPollTimer.Start();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ICommand RetryWebcamLockCommand { get; }
|
||||
|
||||
// Webcam size safeguard: no more than half the frame in any dimension
|
||||
// (960x540 over the 1920x1080 master), and no less than 10% of it
|
||||
// (192x108). Enforced at resize and on layout load. The Chat scene is
|
||||
@@ -135,6 +171,7 @@ public partial class MainViewModel
|
||||
{
|
||||
if (_webcam?.DeviceId != deviceId) return;
|
||||
WebcamError = null;
|
||||
WebcamLockAlert = null; // the camera is provably alive — alert resolved.
|
||||
foreach (var scene in Scenes)
|
||||
foreach (var config in scene.Elements.OfType<WebcamSceneConfig>())
|
||||
config.VideoImageSource = bitmap;
|
||||
@@ -148,6 +185,58 @@ public partial class MainViewModel
|
||||
WebcamError = $"Webcam offline: {message}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs exactly once at startup (and on Retry): enumerate the OS webcams,
|
||||
/// then tri-state:
|
||||
/// 0 → no alarm, layer inactive.
|
||||
/// 1 → try to lock; failure → persistent red alert (re-poll every 5 s).
|
||||
/// ≥2 → leave to the settings-dialog selector (next slice).
|
||||
/// Re-running after a failure re-enumerates then retries the lock; a real
|
||||
/// frame arriving (first-frame proof) also clears the alert.
|
||||
/// </summary>
|
||||
internal async Task ValidateWebcamResourceStartupAsync()
|
||||
{
|
||||
IReadOnlyList<CameraDeviceInfo> cameras;
|
||||
try
|
||||
{
|
||||
cameras = await _cameraEnumerator.GetCamerasAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
AppLog.Write($"WebcamResource: enumeration failed: {ex.Message}");
|
||||
WebcamLockAlert = "Web Cam unavailable — couldn't enumerate cameras.";
|
||||
return;
|
||||
}
|
||||
|
||||
var count = cameras?.Count ?? 0;
|
||||
if (count != 1)
|
||||
{
|
||||
// 0: no camera; ≥2: settings-dialog selector (next slice).
|
||||
WebcamLockAlert = null;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var started = await _cameraManager.AcquireAsync(device.Id);
|
||||
if (started)
|
||||
{
|
||||
WebcamLockAlert = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var reason = WebcamError ?? "the camera could not be locked.";
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user