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
|
||||
|
||||
@@ -68,10 +68,11 @@
|
||||
- **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.
|
||||
default directly, offered only when a camera is ATTAINABLE (identity + live session, not just a
|
||||
saved identity), app base lock keeps the session after the last placement is removed, 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -418,12 +418,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
||||
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`
|
||||
(TASK 26 superseded by creator directive 2026-09-17): `CanAddWebcam` = `StagedScene is { WebcamConfig: null } && IsWebcamAttainable`
|
||||
— 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).
|
||||
holding one does not, so Chat's camera never greys Live. **Attainable means a live lock, not just a
|
||||
saved identity:** `IsWebcamAttainable` = `_webcam != null && CameraManager.IsRunning(_webcam.DeviceId)`
|
||||
— an identity whose device was unplugged, or whose session can't start, leaves the row greyed with
|
||||
`WebcamAddToolTip` = "No webcam is currently available…" (identity alone no longer suffices). The
|
||||
max-1 reason gets 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.
|
||||
nulled by removal) — Add stays offered for the same camera, because the app holds its own base lock
|
||||
(below).
|
||||
- **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
|
||||
@@ -433,14 +437,16 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr
|
||||
(`WebcamLockAlert`, bottom of the Layers panel with a Retry button) re-polls every 5 s
|
||||
(`_webcamLockPollTimer`) until a lock succeeds, and any first real frame also clears it; **≥2** →
|
||||
deliberately no auto-lock — selection belongs to the App Settings dialog (gear) **next slice**.
|
||||
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. 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`:
|
||||
The single-camera branch **always acquires** (it no longer skips when configs already hold the
|
||||
session): that acquire is the app's own BASE ref, so removing every scene's placement leaves
|
||||
`RefCount = 1` and the session alive — the gate stays satisfied and the Web Cam row stays offered,
|
||||
i.e. the app default outlives the scenes that render it. Acquire on a running session just bumps
|
||||
the refcount. `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. 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); an already-loaded identity is never
|
||||
overwritten. Test seams mirror `LayoutPathOverride`:
|
||||
`CameraEnumeratorOverride`/`CameraFrameSourceFactoryOverride` let `WebcamStartupResourceTests`
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Xunit;
|
||||
using ytLive.Models;
|
||||
using ytLive.Services;
|
||||
using ytLive.ViewModels;
|
||||
|
||||
@@ -28,29 +29,19 @@ public sealed class WebcamMenuGateTests
|
||||
_app.Run(Run);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WebcamNotOffered_WhenIdentityExists_ButNoCameraIsAttainable()
|
||||
{
|
||||
_app.Run(RunNotAttainable);
|
||||
}
|
||||
|
||||
private readonly RealAppHost _app;
|
||||
|
||||
public WebcamMenuGateTests(RealAppHost app) => _app = app;
|
||||
|
||||
private void Run()
|
||||
{
|
||||
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
|
||||
=> RunWith(failLock: false, sentinel: (vm, starting, live) =>
|
||||
{
|
||||
using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
SeedDatabaseWithWebcam(tempDb);
|
||||
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
|
||||
var starting = vm.Scenes.Single(s => s.Name == "Starting");
|
||||
var live = vm.Scenes.Single(s => s.Name == "Live");
|
||||
|
||||
vm.StagedScene = live;
|
||||
Assert.Null(live.WebcamConfig);
|
||||
Assert.True(vm.CanAddWebcam,
|
||||
@@ -74,13 +65,45 @@ public sealed class WebcamMenuGateTests
|
||||
Assert.True(vm.CanAddWebcam, "the app default is still selected — Add stays offered");
|
||||
|
||||
vm.SaveLayoutNow();
|
||||
using (var connection = new SqliteConnection($"Data Source={tempDb}"))
|
||||
using (var connection = new SqliteConnection($"Data Source={MainViewModel.LayoutPathOverride}"))
|
||||
{
|
||||
connection.Open();
|
||||
using var webcams = connection.CreateCommand();
|
||||
webcams.CommandText = "SELECT COUNT(*) FROM Webcam;";
|
||||
Assert.Equal(1L, (long)webcams.ExecuteScalar()!);
|
||||
}
|
||||
});
|
||||
|
||||
private void RunNotAttainable()
|
||||
=> RunWith(failLock: true, sentinel: (vm, starting, live) =>
|
||||
{
|
||||
vm.StagedScene = live;
|
||||
Assert.Null(live.WebcamConfig);
|
||||
Assert.False(vm.CanAddWebcam,
|
||||
"identity is selected but its session can't start — the row is NOT offered (not attainable)");
|
||||
Assert.Equal("No webcam is currently available — plug one in, or allow camera access in Windows.",
|
||||
vm.WebcamAddToolTip);
|
||||
});
|
||||
|
||||
private void RunWith(bool failLock, Action<MainViewModel, Scene, Scene> sentinel)
|
||||
{
|
||||
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, failLock);
|
||||
var window = default(MainWindow);
|
||||
try
|
||||
{
|
||||
using (var schema = new ytLive.Services.LayoutStore(tempDb)) { }
|
||||
SqliteConnection.ClearAllPools();
|
||||
SeedDatabaseWithWebcam(tempDb);
|
||||
|
||||
window = new MainWindow();
|
||||
var vm = (MainViewModel)window.DataContext;
|
||||
// Deterministic: the offer reads live session state, so wait for the
|
||||
// startup lock before asserting anything camera-flavoured.
|
||||
vm.WebcamStartupValidationTask!.GetAwaiter().GetResult();
|
||||
sentinel(vm, vm.Scenes.Single(s => s.Name == "Starting"), vm.Scenes.Single(s => s.Name == "Live"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -149,15 +172,21 @@ public sealed class WebcamMenuGateTests
|
||||
private sealed class FakeFrameSource : ICameraFrameSource
|
||||
{
|
||||
public string DeviceId { get; }
|
||||
private readonly bool _failLock;
|
||||
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 FakeFrameSource(string deviceId, bool failLock = false)
|
||||
{
|
||||
DeviceId = deviceId;
|
||||
_failLock = failLock;
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
{
|
||||
if (_failLock) throw new InvalidOperationException("Camera is in use by another app.");
|
||||
FrameAvailable?.Invoke(new VideoFrame(2, 2, new byte[16]));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user