diff --git a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx index 1436c03f..9e88830e 100644 --- a/packages/web/src/components/modals/settingsPanels/VideoSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/VideoSection.tsx @@ -1,4 +1,5 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useVoiceStore } from '../../../stores/voiceStore'; import { isElectron, getElectronAPI } from '../../../platform/platform'; type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked'; @@ -22,10 +23,59 @@ function getHardBlockedCopy(): string { return 'Camera permission was denied. Reset it in your browser/Chromium prompt and try again.'; } +/** + * Build a deviceId → display-label map applying the spec's rules: + * - Empty `label` falls back to `"Camera N"` using enumeration index. + * - Duplicate non-empty labels get `" (1)"`, `" (2)"` suffixes by enumeration order. + * Single occurrences stay unsuffixed. + */ +function buildDisplayLabels(devices: MediaDeviceInfo[]): Map { + const counts = new Map(); + for (const d of devices) { + if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1); + } + const seen = new Map(); + const labels = new Map(); + devices.forEach((d, i) => { + if (!d.label) { + labels.set(d.deviceId, `Camera ${i + 1}`); + return; + } + const total = counts.get(d.label) ?? 1; + if (total <= 1) { + labels.set(d.deviceId, d.label); + return; + } + const used = (seen.get(d.label) ?? 0) + 1; + seen.set(d.label, used); + labels.set(d.deviceId, `${d.label} (${used})`); + }); + return labels; +} + export function VideoSection() { + const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId); + const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId); + const [permState, setPermState] = useState('unknown'); + const [devices, setDevices] = useState([]); + const [listOpen, setListOpen] = useState(false); // 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); + + // Close the dropdown when the user clicks outside it. + useEffect(() => { + if (!listOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setListOpen(false); + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [listOpen]); // Run the probe once on mount. useEffect(() => { @@ -49,6 +99,42 @@ export function VideoSection() { }; }, []); + // Enumerate cameras when permission is granted; refresh on devicechange. + useEffect(() => { + if (permState !== 'granted') return; + + let cancelled = false; + const enumerate = async () => { + try { + const all = await navigator.mediaDevices.enumerateDevices(); + if (cancelled) return; + const seen = new Set(); + const cams: MediaDeviceInfo[] = []; + for (const d of all) { + if (d.kind !== 'videoinput') continue; + if (seen.has(d.deviceId)) continue; + seen.add(d.deviceId); + cams.push(d); + } + setDevices(cams); + } catch { + if (!cancelled) setDevices([]); + } + }; + + enumerate(); + const onChange = () => { + enumerate(); + }; + navigator.mediaDevices.addEventListener('devicechange', onChange); + return () => { + cancelled = true; + navigator.mediaDevices.removeEventListener('devicechange', onChange); + }; + }, [permState]); + + const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]); + const handleTryAgain = async () => { setPermState('probing'); try { @@ -100,15 +186,94 @@ export function VideoSection() { ); } - // permState === 'granted' — main UI added in T9–T12. + // permState === 'granted' + const selectedLabel = + cameraDeviceId === null + ? 'Auto (system default)' + : displayLabels.get(cameraDeviceId) ?? 'Auto (system default)'; + + const handleSelect = (id: string | null) => { + setCameraDeviceId(id); + setListOpen(false); + }; + return (
Video
-
- (UI under construction — Task 9 adds dropdown, T10–T11 add preview, T12 adds lifecycle.) +
+
Camera
+
+ + {listOpen && ( +
+ handleSelect(null)} + /> + {devices.map((d) => ( + handleSelect(d.deviceId)} + /> + ))} +
+ )} +
+ {devices.length === 0 && ( +
No cameras detected.
+ )}
); } + +interface DropdownItemProps { + label: string; + active: boolean; + onClick: () => void; +} + +function DropdownItem({ label, active, onClick }: DropdownItemProps) { + return ( + + ); +}