diff --git a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx index e4d9baec..e4f32eb4 100644 --- a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx @@ -4,7 +4,23 @@ import { useVoiceStore } from '../../../stores/voiceStore'; import { getActiveRoom } from '../../../hooks/useLiveKit'; import { isElectron, getElectronAPI } from '../../../platform/platform'; -type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked'; +/** + * Permission state machine for the Video section. + * + * - `unknown` : initial mount, before `permissions.query` resolves. + * - `granted` : permission granted; dropdown is populated; tile is dormant + * until user clicks (or attaches to an in-call LK track). + * - `prompt` : permission not yet decided; dropdown hidden; CTA card shown. + * - `denied` : permission denied; banner shown with "Try again". + * - `hard-blocked`: a "Try again" attempt failed with NotAllowedError → escalate + * the banner to platform-specific recovery instructions. + * + * `permissions.query({ name: 'camera' })` is the ONLY mount-time camera API + * call. It does not light the LED on any platform. `getUserMedia` is fired + * exclusively from explicit user gestures (dormant-tile click, prompt CTA, + * "Try again" button). + */ +type PermissionStatus = 'unknown' | 'granted' | 'prompt' | 'denied' | 'hard-blocked'; function errorName(err: unknown): string { return err instanceof Error ? err.name : ''; @@ -60,24 +76,49 @@ export function VideoSection() { const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId); const isCameraOn = useVoiceStore((s) => s.isCameraOn); - const [permState, setPermState] = useState('unknown'); + const [permState, setPermState] = useState('unknown'); const [devices, setDevices] = useState([]); const [listOpen, setListOpen] = useState(false); + // Whether the user has explicitly opened the pre-call preview. Goes false on + // Stop-preview, tab hidden, unmount, or when in-call mode takes over. Drives + // (a) whether `getUserMedia` is currently running and (b) whether the + // currently-using subline is visible. + const [previewActive, setPreviewActive] = useState(false); const [previewError, setPreviewError] = useState(null); // Tracks the deviceId the browser actually picked for the active preview track. - // Drives the "Currently using: …" subline shown only in Auto mode. + // Drives the "Currently using: …" subline shown only when previewActive (or + // in-call attach) AND cameraDeviceId === null. const [activeDeviceId, setActiveDeviceId] = useState(null); - // Bumped on visibilitychange to force the dual-mode preview effect to re-run - // (cleanup releases the camera light when the tab is hidden, then a fresh run - // restarts the preview when the tab becomes visible again). - const [restartTick, setRestartTick] = useState(0); - // Tracks mount state so async probe handlers don't write to state after unmount. + // Tracks mount state so async 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); + // Tracks whether the active in-flight preview-start request is still wanted + // (cancellable by Stop preview, unmount, visibility change, or re-click). + const startGenRef = useRef(0); + + // Stop the user-owned pre-call getUserMedia stream and release the camera + // light. macOS holds the LED on for ~2s after release (hardware debounce). + const stopPreCall = () => { + if (previewStreamRef.current) { + previewStreamRef.current.getTracks().forEach((t) => t.stop()); + previewStreamRef.current = null; + } + const videoEl = previewVideoRef.current; + if (videoEl && videoEl.srcObject instanceof MediaStream) { + videoEl.srcObject = null; + } + }; + + // Detach an in-call LK track. NEVER call mst.stop() — the publication is + // still being consumed by other call participants. + const detachInCall = () => { + const videoEl = previewVideoRef.current; + if (videoEl) videoEl.srcObject = null; + }; // Close the dropdown when the user clicks outside it. useEffect(() => { @@ -92,41 +133,84 @@ export function VideoSection() { }, [listOpen]); // Tab-visibility cleanup: release the camera light when the tab is hidden. - // The dual-mode preview effect depends on `restartTick`; bumping it forces its - // cleanup to run (which calls fullStop()) and the effect re-runs. The effect - // itself short-circuits while document.visibilityState === 'hidden', so no new - // stream is opened until the tab is visible again. + // Per spec, we DO NOT auto-resume on visibility return — the tile goes + // dormant and the user clicks again. Auto-resume would defeat the privacy + // model by silently re-lighting the LED on tab focus. useEffect(() => { const onVisibility = () => { - setRestartTick((t) => t + 1); + if (document.visibilityState === 'hidden' && previewActive) { + startGenRef.current += 1; // cancel any in-flight start + stopPreCall(); + setPreviewActive(false); + setActiveDeviceId(null); + } }; document.addEventListener('visibilitychange', onVisibility); return () => document.removeEventListener('visibilitychange', onVisibility); - }, []); + }, [previewActive]); - // Run the probe once on mount. + // Mount-time permission probe via the Permissions API. This is the ONLY + // automatic camera-related call on mount. It is passive and does not light + // the LED. `'camera'` is not in the `PermissionName` union in lib.dom.d.ts + // but is supported in Chromium 64+ (every Electron version this project + // ships), Firefox 79+, Safari 16+. The `as PermissionName` cast is the + // standard escape hatch. useEffect(() => { mountedRef.current = true; - const probe = async () => { - setPermState('probing'); + + let cancelled = false; + let status: PermissionStatus_API | null = null; + let onChange: (() => void) | null = null; + + const apply = (state: PermissionState) => { + if (cancelled || !mountedRef.current) return; + if (state === 'granted') setPermState('granted'); + else if (state === 'prompt') setPermState('prompt'); + else setPermState('denied'); + }; + + const run = async () => { + // Permissions API may be missing entirely (older Safari, locked-down + // environments). If so, we conservatively render the prompt-state CTA so + // the user can still grant permission via an explicit click — rather + // than firing getUserMedia behind their back. + if (!navigator.permissions || typeof navigator.permissions.query !== 'function') { + if (!cancelled && mountedRef.current) setPermState('prompt'); + return; + } try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true }); - // Stop immediately — we only needed to unlock labels and verify access. - stream.getTracks().forEach((t) => t.stop()); - if (mountedRef.current) setPermState('granted'); - } catch (err) { - if (!mountedRef.current) return; - if (errorName(err) === 'NotAllowedError') setPermState('initial-denied'); - else setPermState('granted'); // NotFoundError / NotReadableError — still allow dropdown render + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const s = await navigator.permissions.query({ name: 'camera' as PermissionName }); + status = s as PermissionStatus_API; + apply(s.state); + // Re-render if the user grants/revokes via OS or browser settings while + // the section is open. + onChange = () => apply(s.state); + s.addEventListener('change', onChange); + } catch { + // Some browsers/platforms throw on `name: 'camera'` (older Firefox). + // Fall back to the prompt CTA — never fire getUserMedia automatically. + if (!cancelled && mountedRef.current) setPermState('prompt'); } }; - probe(); + + run(); + return () => { + cancelled = true; mountedRef.current = false; + if (status && onChange) { + try { + status.removeEventListener('change', onChange); + } catch { + // ignore — best-effort cleanup + } + } }; }, []); // Enumerate cameras when permission is granted; refresh on devicechange. + // enumerateDevices() is passive and never lights the LED. useEffect(() => { if (permState !== 'granted') return; @@ -160,84 +244,77 @@ export function VideoSection() { }; }, [permState]); - // Dual-mode preview: - // - In-call: if a LiveKit local Camera publication exists with a live MediaStreamTrack, - // attach that track to the