From 5841bd4c744780a232a835218a61629e7950b2ad Mon Sep 17 00:00:00 2001 From: Jonathan Martins Date: Tue, 15 Sep 2026 15:05:06 -0300 Subject: [PATCH 1/3] Fix webcam recording freeze and empty device list The screen recorder opened the webcam via getUserMedia() a second time when recording started, even though the HUD's floating webcam preview already had it open. Many UVC webcams only support a single open handle at the OS/driver level, so the second open froze the existing preview and could silently fail to deliver frames to the recorder, leaving the final recording without a webcam layer. The recorder now reuses the preview's already-open track via MediaStreamTrack.clone() instead of requesting the device again. Also fix useVideoDevices/useMicrophoneDevices never prompting for camera/microphone permission when enumerateDevices() returns zero entries up front (as opposed to entries with blank labels), which left the device pickers permanently empty on fresh app profiles. Co-Authored-By: Claude Sonnet 5 --- src/components/launch/LaunchWindow.tsx | 7 ++- .../launch/hooks/useWebcamPreviewOverlay.ts | 12 ++++- src/hooks/useMicrophoneDevices.ts | 6 ++- src/hooks/useScreenRecorder.ts | 51 +++++++++++++------ src/hooks/useVideoDevices.ts | 6 ++- 5 files changed, 62 insertions(+), 20 deletions(-) diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 66cbe608b..74e919b13 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -56,6 +56,10 @@ export function LaunchWindow() { function LaunchWindowContent() { const t = useScopedT("launch"); const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); + // Shared with useWebcamPreviewOverlay so the recorder can reuse the HUD's + // already-open webcam preview track instead of opening the camera a second + // time when recording starts (see useScreenRecorder's prepareWebcamRecorder). + const activeWebcamPreviewStreamRef = useRef(null); const { recording, @@ -79,7 +83,7 @@ function LaunchWindowContent() { countdownDelay, setCountdownDelay, preparePermissions, - } = useScreenRecorder(); + } = useScreenRecorder({ activeWebcamPreviewStreamRef }); const { elapsed, formatTime } = useRecordingTimer(recording, paused); const hudContentRef = useRef(null); @@ -151,6 +155,7 @@ function LaunchWindowContent() { showWebcamControls, webcamPopoverOpen: openId === "webcam", hudOverlayMousePassthroughSupported, + activeWebcamPreviewStreamRef, }); useEffect(() => { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 680e3dac0..2dbf2d22f 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -4,18 +4,22 @@ import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; +type MutableStreamRef = { current: MediaStream | null }; + export function useWebcamPreviewOverlay({ webcamEnabled, webcamDeviceId, showWebcamControls, webcamPopoverOpen, hudOverlayMousePassthroughSupported, + activeWebcamPreviewStreamRef: externalPreviewStreamRef, }: { webcamEnabled: boolean; webcamDeviceId?: string; showWebcamControls: boolean; webcamPopoverOpen: boolean; hudOverlayMousePassthroughSupported: boolean | null; + activeWebcamPreviewStreamRef?: MutableStreamRef; }) { const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); @@ -239,6 +243,9 @@ export function useWebcamPreviewOverlay({ } previewStreamRef.current = previewStream; + if (externalPreviewStreamRef) { + externalPreviewStreamRef.current = previewStream; + } attachPreviewStreamToNode(webcamPreviewRef.current); attachPreviewStreamToNode(recordingWebcamPreviewRef.current); } catch (error) { @@ -264,8 +271,11 @@ export function useWebcamPreviewOverlay({ if (previewStreamRef.current === previewStream) { previewStreamRef.current = null; } + if (externalPreviewStreamRef && externalPreviewStreamRef.current === previewStream) { + externalPreviewStreamRef.current = null; + } }; - }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); + }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId, externalPreviewStreamRef]); return { showFloatingWebcamPreview, diff --git a/src/hooks/useMicrophoneDevices.ts b/src/hooks/useMicrophoneDevices.ts index 0d251b6c6..f10664438 100644 --- a/src/hooks/useMicrophoneDevices.ts +++ b/src/hooks/useMicrophoneDevices.ts @@ -37,8 +37,12 @@ export function useMicrophoneDevices(enabled: boolean = true, preferredDeviceId? groupId: device.groupId, })); + // Chromium can report zero audio inputs at all (not just unlabeled ones) + // until getUserMedia() has been called at least once for this app's + // profile. Probe with getUserMedia whenever we don't yet have a labeled + // device list, not only when placeholder entries are present. const needsLabelPermission = - audioInputs.length > 0 && audioInputs.every((device) => !device.label.trim()); + audioInputs.length === 0 || audioInputs.every((device) => !device.label.trim()); if (needsLabelPermission && !hasRequestedMicrophoneLabels) { hasRequestedMicrophoneLabels = true; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index 459652415..d388f4d04 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -373,7 +373,10 @@ async function createAudioInputDeviceSnapshot(): Promise< return audioInputs.length > 0 ? audioInputs : null; } -export function useScreenRecorder(): UseScreenRecorderReturn { +export function useScreenRecorder( + options?: { activeWebcamPreviewStreamRef?: { current: MediaStream | null } }, +): UseScreenRecorderReturn { + const activeWebcamPreviewStreamRef = options?.activeWebcamPreviewStreamRef; const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); @@ -1017,21 +1020,37 @@ export function useScreenRecorder(): UseScreenRecorderReturn { } try { - webcamStream.current = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - } - : { - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - }, - audio: false, - }); + // Reuse the already-open HUD webcam preview track when possible instead of + // calling getUserMedia() a second time for the same physical device. Many + // UVC webcams only allow a single open handle at the OS/driver level, so a + // second concurrent getUserMedia() call for the same camera can freeze the + // existing preview stream and/or silently fail to deliver frames to the + // recorder. MediaStreamTrack.clone() shares the same underlying capture + // session instead of opening the device again. + const existingPreviewStream = activeWebcamPreviewStreamRef?.current ?? null; + const existingPreviewTrack = existingPreviewStream?.getVideoTracks()[0] ?? null; + const existingPreviewTrackMatchesDevice = + existingPreviewTrack != null && + existingPreviewTrack.readyState === "live" && + (!webcamDeviceId || existingPreviewTrack.getSettings().deviceId === webcamDeviceId); + + webcamStream.current = existingPreviewTrackMatchesDevice + ? new MediaStream([existingPreviewTrack.clone()]) + : await navigator.mediaDevices.getUserMedia({ + video: webcamDeviceId + ? { + deviceId: { exact: webcamDeviceId }, + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + } + : { + width: { ideal: WEBCAM_WIDTH }, + height: { ideal: WEBCAM_HEIGHT }, + frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, + }, + audio: false, + }); const mimeType = selectWebcamMimeType(); webcamChunks.current = []; diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts index 6f7862ffb..13c7f5123 100644 --- a/src/hooks/useVideoDevices.ts +++ b/src/hooks/useVideoDevices.ts @@ -41,8 +41,12 @@ export function useVideoDevices(enabled: boolean = true) { groupId: device.groupId, })); + // Chromium can report zero video inputs at all (not just unlabeled ones) + // until getUserMedia() has been called at least once for this app's + // profile. Probe with getUserMedia whenever we don't yet have a labeled + // device list, not only when placeholder entries are present. const needsLabelPermission = - videoInputs.length > 0 && videoInputs.every((device) => !device.label.trim()); + videoInputs.length === 0 || videoInputs.every((device) => !device.label.trim()); if (needsLabelPermission && !hasRequestedVideoLabels) { permissionStream = await navigator.mediaDevices.getUserMedia({ From 270ae0e72f9472fbb55a2797024d966da3e3ac09 Mon Sep 17 00:00:00 2001 From: Jonathan Martins Date: Tue, 15 Sep 2026 15:26:24 -0300 Subject: [PATCH 2/3] Coordinate webcam acquisition through a shared coordinator Address CodeRabbit review feedback on PR #971: the device picker's label-unlock probe, the HUD's live preview, and the recorder could each independently call getUserMedia() for the webcam, racing each other when acquisitions overlapped (e.g. the preview's getUserMedia() still in flight when prepareWebcamRecorder ran, before its resolved stream was visible anywhere to reuse). Introduce a small refcounted coordinator (src/lib/sharedWebcamStream.ts) that dedupes concurrent acquisitions for the same device and only releases the underlying track once every consumer has released its reference, so at most one getUserMedia() call against the physical camera is ever in flight. Co-Authored-By: Claude Sonnet 5 --- src/components/launch/LaunchWindow.tsx | 7 +- .../launch/hooks/useWebcamPreviewOverlay.ts | 37 +++------ src/hooks/useScreenRecorder.ts | 58 +++++--------- src/hooks/useVideoDevices.ts | 13 ++-- src/lib/sharedWebcamStream.ts | 77 +++++++++++++++++++ 5 files changed, 114 insertions(+), 78 deletions(-) create mode 100644 src/lib/sharedWebcamStream.ts diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 74e919b13..66cbe608b 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -56,10 +56,6 @@ export function LaunchWindow() { function LaunchWindowContent() { const t = useScopedT("launch"); const { openId, requestClose, requestOpen } = useLaunchPopoverCoordinator(); - // Shared with useWebcamPreviewOverlay so the recorder can reuse the HUD's - // already-open webcam preview track instead of opening the camera a second - // time when recording starts (see useScreenRecorder's prepareWebcamRecorder). - const activeWebcamPreviewStreamRef = useRef(null); const { recording, @@ -83,7 +79,7 @@ function LaunchWindowContent() { countdownDelay, setCountdownDelay, preparePermissions, - } = useScreenRecorder({ activeWebcamPreviewStreamRef }); + } = useScreenRecorder(); const { elapsed, formatTime } = useRecordingTimer(recording, paused); const hudContentRef = useRef(null); @@ -155,7 +151,6 @@ function LaunchWindowContent() { showWebcamControls, webcamPopoverOpen: openId === "webcam", hudOverlayMousePassthroughSupported, - activeWebcamPreviewStreamRef, }); useEffect(() => { diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 2dbf2d22f..020866d91 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -1,25 +1,22 @@ import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; +import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream"; import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; const DEFAULT_WEBCAM_PREVIEW_OFFSET = { x: 0, y: 0 }; -type MutableStreamRef = { current: MediaStream | null }; - export function useWebcamPreviewOverlay({ webcamEnabled, webcamDeviceId, showWebcamControls, webcamPopoverOpen, hudOverlayMousePassthroughSupported, - activeWebcamPreviewStreamRef: externalPreviewStreamRef, }: { webcamEnabled: boolean; webcamDeviceId?: string; showWebcamControls: boolean; webcamPopoverOpen: boolean; hudOverlayMousePassthroughSupported: boolean | null; - activeWebcamPreviewStreamRef?: MutableStreamRef; }) { const [showFloatingWebcamPreview, setShowFloatingWebcamPreview] = useState(true); const [webcamPreviewOffset, setWebcamPreviewOffset] = useState(DEFAULT_WEBCAM_PREVIEW_OFFSET); @@ -214,6 +211,7 @@ export function useWebcamPreviewOverlay({ useEffect(() => { let mounted = true; + let acquisition: Promise | null = null; const startPreview = async () => { if (!shouldStreamWebcamPreview) { @@ -221,31 +219,15 @@ export function useWebcamPreviewOverlay({ } try { - const previewStream = await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - } - : { - width: { ideal: 320 }, - height: { ideal: 320 }, - frameRate: { ideal: 24, max: 30 }, - }, - audio: false, - }); + acquisition = acquireSharedWebcamStream(webcamDeviceId); + const previewStream = await acquisition; if (!mounted) { - previewStream.getTracks().forEach((track) => track.stop()); + releaseSharedWebcamStream(acquisition); return; } previewStreamRef.current = previewStream; - if (externalPreviewStreamRef) { - externalPreviewStreamRef.current = previewStream; - } attachPreviewStreamToNode(webcamPreviewRef.current); attachPreviewStreamToNode(recordingWebcamPreviewRef.current); } catch (error) { @@ -267,15 +249,14 @@ export function useWebcamPreviewOverlay({ videoElement.pause(); videoElement.srcObject = null; }); - previewStream?.getTracks().forEach((track) => track.stop()); + if (acquisition) { + releaseSharedWebcamStream(acquisition); + } if (previewStreamRef.current === previewStream) { previewStreamRef.current = null; } - if (externalPreviewStreamRef && externalPreviewStreamRef.current === previewStream) { - externalPreviewStreamRef.current = null; - } }; - }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId, externalPreviewStreamRef]); + }, [attachPreviewStreamToNode, shouldStreamWebcamPreview, webcamDeviceId]); return { showFloatingWebcamPreview, diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index d388f4d04..59f2032d6 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -2,6 +2,7 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { getEffectiveRecordingDurationMs } from "@/lib/mediaTiming"; +import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream"; import { getVideoExtensionForMimeType, isWebmMimeType, @@ -33,9 +34,6 @@ const AUDIO_BITRATE_VOICE = 128_000; const AUDIO_BITRATE_SYSTEM = 192_000; const MIC_GAIN_BOOST = 1.4; const WEBCAM_BITRATE = 8_000_000; -const WEBCAM_WIDTH = 1280; -const WEBCAM_HEIGHT = 720; -const WEBCAM_FRAME_RATE = 30; const WEBCAM_SUFFIX = "-webcam"; const MICROPHONE_FALLBACK_ERROR_TOAST_ID = "recording-microphone-fallback-error"; const MICROPHONE_SIDECAR_ERROR_TOAST_ID = "recording-microphone-sidecar-error"; @@ -373,10 +371,7 @@ async function createAudioInputDeviceSnapshot(): Promise< return audioInputs.length > 0 ? audioInputs : null; } -export function useScreenRecorder( - options?: { activeWebcamPreviewStreamRef?: { current: MediaStream | null } }, -): UseScreenRecorderReturn { - const activeWebcamPreviewStreamRef = options?.activeWebcamPreviewStreamRef; +export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); const [paused, setPaused] = useState(false); const [starting, setStarting] = useState(false); @@ -1020,37 +1015,24 @@ export function useScreenRecorder( } try { - // Reuse the already-open HUD webcam preview track when possible instead of - // calling getUserMedia() a second time for the same physical device. Many - // UVC webcams only allow a single open handle at the OS/driver level, so a - // second concurrent getUserMedia() call for the same camera can freeze the - // existing preview stream and/or silently fail to deliver frames to the - // recorder. MediaStreamTrack.clone() shares the same underlying capture - // session instead of opening the device again. - const existingPreviewStream = activeWebcamPreviewStreamRef?.current ?? null; - const existingPreviewTrack = existingPreviewStream?.getVideoTracks()[0] ?? null; - const existingPreviewTrackMatchesDevice = - existingPreviewTrack != null && - existingPreviewTrack.readyState === "live" && - (!webcamDeviceId || existingPreviewTrack.getSettings().deviceId === webcamDeviceId); - - webcamStream.current = existingPreviewTrackMatchesDevice - ? new MediaStream([existingPreviewTrack.clone()]) - : await navigator.mediaDevices.getUserMedia({ - video: webcamDeviceId - ? { - deviceId: { exact: webcamDeviceId }, - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - } - : { - width: { ideal: WEBCAM_WIDTH }, - height: { ideal: WEBCAM_HEIGHT }, - frameRate: { ideal: WEBCAM_FRAME_RATE, max: WEBCAM_FRAME_RATE }, - }, - audio: false, - }); + // Route through the shared webcam coordinator instead of calling + // getUserMedia() directly. Many UVC webcams only allow a single open + // handle at the OS/driver level, so a second concurrent getUserMedia() + // call for the same camera (e.g. while the HUD preview's own acquisition + // is still in flight) can freeze the existing stream and/or silently fail + // to deliver frames to the recorder. Awaiting the coordinator means we + // either join the preview's in-flight/resolved acquisition or, if nothing + // else is using the camera, become the sole owner of a fresh one. + // MediaStreamTrack.clone() lets the recorder keep an independent track + // after releasing our reference to the shared acquisition. + const acquisition = acquireSharedWebcamStream(webcamDeviceId); + try { + const sharedStream = await acquisition; + const sharedTrack = sharedStream.getVideoTracks()[0]; + webcamStream.current = sharedTrack ? new MediaStream([sharedTrack.clone()]) : sharedStream; + } finally { + releaseSharedWebcamStream(acquisition); + } const mimeType = selectWebcamMimeType(); webcamChunks.current = []; diff --git a/src/hooks/useVideoDevices.ts b/src/hooks/useVideoDevices.ts index 13c7f5123..a4ee791de 100644 --- a/src/hooks/useVideoDevices.ts +++ b/src/hooks/useVideoDevices.ts @@ -1,4 +1,5 @@ import { useEffect, useState } from "react"; +import { acquireSharedWebcamStream, releaseSharedWebcamStream } from "@/lib/sharedWebcamStream"; export interface VideoDevice { deviceId: string; @@ -24,7 +25,7 @@ export function useVideoDevices(enabled: boolean = true) { const loadDevices = async () => { const loadId = ++activeLoadId; - let permissionStream: MediaStream | null = null; + let permissionAcquisition: Promise | null = null; try { if (mounted && loadId === activeLoadId) { @@ -49,10 +50,8 @@ export function useVideoDevices(enabled: boolean = true) { videoInputs.length === 0 || videoInputs.every((device) => !device.label.trim()); if (needsLabelPermission && !hasRequestedVideoLabels) { - permissionStream = await navigator.mediaDevices.getUserMedia({ - video: true, - audio: false, - }); + permissionAcquisition = acquireSharedWebcamStream(); + await permissionAcquisition; allDevices = await navigator.mediaDevices.enumerateDevices(); videoInputs = allDevices .filter((device) => device.kind === "videoinput") @@ -91,7 +90,9 @@ export function useVideoDevices(enabled: boolean = true) { console.error("Error loading video devices:", error); } } finally { - permissionStream?.getTracks().forEach((track) => track.stop()); + if (permissionAcquisition) { + releaseSharedWebcamStream(permissionAcquisition); + } if (mounted && loadId === activeLoadId) { setIsLoading(false); } diff --git a/src/lib/sharedWebcamStream.ts b/src/lib/sharedWebcamStream.ts new file mode 100644 index 000000000..5b7294b95 --- /dev/null +++ b/src/lib/sharedWebcamStream.ts @@ -0,0 +1,77 @@ +const WEBCAM_WIDTH_IDEAL = 1280; +const WEBCAM_HEIGHT_IDEAL = 720; + +interface WebcamAcquisition { + promise: Promise; + deviceId: string | undefined; + refCount: number; +} + +let active: WebcamAcquisition | null = null; + +function buildVideoConstraints(deviceId: string | undefined): MediaTrackConstraints { + return deviceId + ? { + deviceId: { exact: deviceId }, + width: { ideal: WEBCAM_WIDTH_IDEAL }, + height: { ideal: WEBCAM_HEIGHT_IDEAL }, + } + : { + width: { ideal: WEBCAM_WIDTH_IDEAL }, + height: { ideal: WEBCAM_HEIGHT_IDEAL }, + }; +} + +/** + * Many UVC webcams only support a single open handle at the OS/driver level, + * so two concurrent getUserMedia() calls for the same physical camera can + * freeze one another or silently fail to deliver frames. This coordinator + * dedupes concurrent webcam acquisitions across every consumer (the device + * picker's label-unlock probe, the HUD's live preview, and the recorder) so + * only one open request against the camera is ever in flight, and the + * underlying track is only stopped once every consumer has released it. + */ +export function acquireSharedWebcamStream(deviceId?: string): Promise { + if (active && (!deviceId || !active.deviceId || active.deviceId === deviceId)) { + active.refCount += 1; + return active.promise; + } + + const acquisition: WebcamAcquisition = { + deviceId, + refCount: 1, + promise: null as unknown as Promise, + }; + acquisition.promise = navigator.mediaDevices + .getUserMedia({ video: buildVideoConstraints(deviceId), audio: false }) + .catch((error: unknown) => { + if (active === acquisition) { + active = null; + } + throw error; + }); + active = acquisition; + return acquisition.promise; +} + +/** Releases one reference obtained from {@link acquireSharedWebcamStream}. */ +export function releaseSharedWebcamStream(acquisitionPromise: Promise): void { + if (!active || active.promise !== acquisitionPromise) { + return; + } + + active.refCount -= 1; + if (active.refCount > 0) { + return; + } + + const acquisition = active; + active = null; + void acquisition.promise + .then((stream) => { + stream.getTracks().forEach((track) => track.stop()); + }) + .catch(() => { + // Acquisition failed; nothing to stop. + }); +} From 37c6111fa44aaae5b88bf4dee5c8719a386ffa9f Mon Sep 17 00:00:00 2001 From: Jonathan Martins Date: Tue, 15 Sep 2026 15:58:09 -0300 Subject: [PATCH 3/3] Fix device-identity and settle-ordering bugs in webcam coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address further CodeRabbit review feedback on PR #971's coordinator: - A device-less (generic) acquisition was treated as reusable by any later request for a *specific* device, so a caller that asked for camera B could silently get whatever camera the generic probe had already opened. Reuse now requires an exact device match; only a generic (no deviceId) request may reuse any open acquisition. - Switching to a different device while an acquisition was still in flight replaced the single `active` slot outright, orphaning the old acquisition's eventual release call and leaking its camera track. Acquisitions are now tracked in a set instead of a single slot, so unrelated devices can be in flight concurrently and each is released independently. - Releasing the last reference before getUserMedia() had settled cleared the slot immediately, so a fast unmount/remount could start a second competing getUserMedia() call for the same device while the first was still pending — the exact race this coordinator exists to prevent. A release that lands before settling now just marks the acquisition for a deferred stop; a new compatible acquire in the meantime cancels that and reuses the same in-flight request. Co-Authored-By: Claude Sonnet 5 --- src/lib/sharedWebcamStream.ts | 70 +++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/src/lib/sharedWebcamStream.ts b/src/lib/sharedWebcamStream.ts index 5b7294b95..adeb93a49 100644 --- a/src/lib/sharedWebcamStream.ts +++ b/src/lib/sharedWebcamStream.ts @@ -5,9 +5,11 @@ interface WebcamAcquisition { promise: Promise; deviceId: string | undefined; refCount: number; + settled: boolean; + pendingStop: boolean; } -let active: WebcamAcquisition | null = null; +const acquisitions = new Set(); function buildVideoConstraints(deviceId: string | undefined): MediaTrackConstraints { return deviceId @@ -22,52 +24,88 @@ function buildVideoConstraints(deviceId: string | undefined): MediaTrackConstrai }; } +/** An unspecified request may reuse any open camera; a specific device may only reuse a match. */ +function findCompatibleAcquisition(deviceId: string | undefined): WebcamAcquisition | undefined { + for (const acquisition of acquisitions) { + if (!deviceId || acquisition.deviceId === deviceId) { + return acquisition; + } + } + return undefined; +} + /** * Many UVC webcams only support a single open handle at the OS/driver level, * so two concurrent getUserMedia() calls for the same physical camera can * freeze one another or silently fail to deliver frames. This coordinator * dedupes concurrent webcam acquisitions across every consumer (the device * picker's label-unlock probe, the HUD's live preview, and the recorder) so - * only one open request against the camera is ever in flight, and the - * underlying track is only stopped once every consumer has released it. + * only one open request per distinct device is ever in flight, and a track is + * only stopped once every consumer holding it has released their reference + * *and* its getUserMedia() call has actually settled — a release that lands + * while the call is still pending just marks it for a deferred stop, so a new + * compatible acquire() in the meantime can cancel that and reuse the same + * in-flight request instead of starting a competing one. */ export function acquireSharedWebcamStream(deviceId?: string): Promise { - if (active && (!deviceId || !active.deviceId || active.deviceId === deviceId)) { - active.refCount += 1; - return active.promise; + const existing = findCompatibleAcquisition(deviceId); + if (existing) { + existing.refCount += 1; + existing.pendingStop = false; + return existing.promise; } const acquisition: WebcamAcquisition = { deviceId, refCount: 1, + settled: false, + pendingStop: false, promise: null as unknown as Promise, }; acquisition.promise = navigator.mediaDevices .getUserMedia({ video: buildVideoConstraints(deviceId), audio: false }) - .catch((error: unknown) => { - if (active === acquisition) { - active = null; + .then((stream) => { + acquisition.settled = true; + if (acquisition.pendingStop) { + acquisitions.delete(acquisition); + stream.getTracks().forEach((track) => track.stop()); } + return stream; + }) + .catch((error: unknown) => { + acquisition.settled = true; + acquisitions.delete(acquisition); throw error; }); - active = acquisition; + acquisitions.add(acquisition); return acquisition.promise; } /** Releases one reference obtained from {@link acquireSharedWebcamStream}. */ export function releaseSharedWebcamStream(acquisitionPromise: Promise): void { - if (!active || active.promise !== acquisitionPromise) { + let target: WebcamAcquisition | undefined; + for (const acquisition of acquisitions) { + if (acquisition.promise === acquisitionPromise) { + target = acquisition; + break; + } + } + if (!target) { + return; + } + + target.refCount -= 1; + if (target.refCount > 0) { return; } - active.refCount -= 1; - if (active.refCount > 0) { + if (!target.settled) { + target.pendingStop = true; return; } - const acquisition = active; - active = null; - void acquisition.promise + acquisitions.delete(target); + void target.promise .then((stream) => { stream.getTracks().forEach((track) => track.stop()); })