feat(video-section): enumerate cameras and render dropdown
Empty labels fall back to 'Camera N'; duplicate labels are disambiguated with ' (1)', ' (2)' suffixes by enumeration order. 'No cameras detected' subline appears when enumeration is empty.
This commit is contained in:
@@ -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<string, string> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const d of devices) {
|
||||
if (d.label) counts.set(d.label, (counts.get(d.label) ?? 0) + 1);
|
||||
}
|
||||
const seen = new Map<string, number>();
|
||||
const labels = new Map<string, string>();
|
||||
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<PermissionState>('unknown');
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
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<HTMLDivElement>(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<string>();
|
||||
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 (
|
||||
<div>
|
||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||
Video
|
||||
</div>
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 text-sm text-txt-tertiary italic">
|
||||
(UI under construction — Task 9 adds dropdown, T10–T11 add preview, T12 adds lifecycle.)
|
||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||
<div className="text-[13px] font-medium text-txt-primary mb-1.5">Camera</div>
|
||||
<div ref={dropdownRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setListOpen((v) => !v)}
|
||||
className="w-full px-3 py-2 flex items-center justify-between rounded-md bg-surface-base hover:bg-interactive-hover transition-colors"
|
||||
>
|
||||
<span className="text-[13px] text-txt-primary truncate text-left">{selectedLabel}</span>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={`text-txt-tertiary flex-shrink-0 ml-2 transition-transform ${listOpen ? 'rotate-90' : ''}`}
|
||||
>
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||
</svg>
|
||||
</button>
|
||||
{listOpen && (
|
||||
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1">
|
||||
<DropdownItem
|
||||
label="Auto (system default)"
|
||||
active={cameraDeviceId === null}
|
||||
onClick={() => handleSelect(null)}
|
||||
/>
|
||||
{devices.map((d) => (
|
||||
<DropdownItem
|
||||
key={d.deviceId}
|
||||
label={displayLabels.get(d.deviceId) ?? d.deviceId}
|
||||
active={cameraDeviceId === d.deviceId}
|
||||
onClick={() => handleSelect(d.deviceId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{devices.length === 0 && (
|
||||
<div className="text-xs text-txt-tertiary mt-1">No cameras detected.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DropdownItemProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function DropdownItem({ label, active, onClick }: DropdownItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
||||
active ? 'text-txt-primary' : 'text-txt-secondary'
|
||||
}`}
|
||||
>
|
||||
{active && (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-accent-primary flex-shrink-0"
|
||||
>
|
||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
<span className={`truncate ${active ? '' : 'pl-6'}`}>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user