diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 680e3dac0..020866d91 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -1,4 +1,5 @@ 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; @@ -210,6 +211,7 @@ export function useWebcamPreviewOverlay({ useEffect(() => { let mounted = true; + let acquisition: Promise | null = null; const startPreview = async () => { if (!shouldStreamWebcamPreview) { @@ -217,24 +219,11 @@ 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; } @@ -260,7 +249,9 @@ 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; } 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..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"; @@ -1017,21 +1015,24 @@ 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, - }); + // 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 6f7862ffb..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) { @@ -41,14 +42,16 @@ 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({ - video: true, - audio: false, - }); + permissionAcquisition = acquireSharedWebcamStream(); + await permissionAcquisition; allDevices = await navigator.mediaDevices.enumerateDevices(); videoInputs = allDevices .filter((device) => device.kind === "videoinput") @@ -87,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..adeb93a49 --- /dev/null +++ b/src/lib/sharedWebcamStream.ts @@ -0,0 +1,115 @@ +const WEBCAM_WIDTH_IDEAL = 1280; +const WEBCAM_HEIGHT_IDEAL = 720; + +interface WebcamAcquisition { + promise: Promise; + deviceId: string | undefined; + refCount: number; + settled: boolean; + pendingStop: boolean; +} + +const acquisitions = new Set(); + +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 }, + }; +} + +/** 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 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 { + 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 }) + .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; + }); + acquisitions.add(acquisition); + return acquisition.promise; +} + +/** Releases one reference obtained from {@link acquireSharedWebcamStream}. */ +export function releaseSharedWebcamStream(acquisitionPromise: Promise): void { + 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; + } + + if (!target.settled) { + target.pendingStop = true; + return; + } + + acquisitions.delete(target); + void target.promise + .then((stream) => { + stream.getTracks().forEach((track) => track.stop()); + }) + .catch(() => { + // Acquisition failed; nothing to stop. + }); +}