diff --git a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx index 9e88830e..643b6aae 100644 --- a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx @@ -60,10 +60,14 @@ export function VideoSection() { const [permState, setPermState] = useState('unknown'); const [devices, setDevices] = useState([]); const [listOpen, setListOpen] = useState(false); + const [previewError, setPreviewError] = useState(null); // Tracks mount state so async probe handlers don't write to state after unmount. const mountedRef = useRef(true); // Anchor for the dropdown's click-outside-to-close behaviour. const dropdownRef = useRef(null); + // Preview tile refs. + const previewVideoRef = useRef(null); + const previewStreamRef = useRef(null); // Close the dropdown when the user clicks outside it. useEffect(() => { @@ -133,6 +137,64 @@ export function VideoSection() { }; }, [permState]); + // Pre-call preview: start a getUserMedia stream for the selected device whenever + // permission is granted. Swap source when cameraDeviceId changes. + useEffect(() => { + if (permState !== 'granted') return; + + let cancelled = false; + + const start = async () => { + // Stop any prior stream before requesting a new one. + if (previewStreamRef.current) { + previewStreamRef.current.getTracks().forEach((t) => t.stop()); + previewStreamRef.current = null; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true, + }); + if (cancelled) { + stream.getTracks().forEach((t) => t.stop()); + return; + } + previewStreamRef.current = stream; + const videoEl = previewVideoRef.current; + if (videoEl) { + videoEl.srcObject = stream; + // Autoplay errors on muted video are common and benign. + videoEl.play().catch(() => {}); + } + setPreviewError(null); + } catch (err) { + if (cancelled) return; + const name = errorName(err); + if (name === 'NotReadableError') { + setPreviewError('Camera is in use by another application.'); + } else if (name === 'OverconstrainedError') { + setPreviewError('Selected camera is unavailable.'); + } else { + setPreviewError('Could not start camera preview.'); + } + } + }; + + start(); + + return () => { + cancelled = true; + if (previewStreamRef.current) { + previewStreamRef.current.getTracks().forEach((t) => t.stop()); + previewStreamRef.current = null; + } + const videoEl = previewVideoRef.current; + if (videoEl) { + videoEl.srcObject = null; + } + }; + }, [permState, cameraDeviceId]); + const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]); const handleTryAgain = async () => { @@ -203,6 +265,20 @@ export function VideoSection() { Video
+
+
Camera