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';
|
import { isElectron, getElectronAPI } from '../../../platform/platform';
|
||||||
|
|
||||||
type PermissionState = 'unknown' | 'probing' | 'granted' | 'initial-denied' | 'hard-blocked';
|
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.';
|
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() {
|
export function VideoSection() {
|
||||||
|
const cameraDeviceId = useVoiceStore((s) => s.cameraDeviceId);
|
||||||
|
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||||
|
|
||||||
const [permState, setPermState] = useState<PermissionState>('unknown');
|
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.
|
// Tracks mount state so async probe handlers don't write to state after unmount.
|
||||||
const mountedRef = useRef(true);
|
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.
|
// Run the probe once on mount.
|
||||||
useEffect(() => {
|
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 () => {
|
const handleTryAgain = async () => {
|
||||||
setPermState('probing');
|
setPermState('probing');
|
||||||
try {
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Video
|
Video
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5 text-sm text-txt-tertiary italic">
|
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
|
||||||
(UI under construction — Task 9 adds dropdown, T10–T11 add preview, T12 adds lifecycle.)
|
<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>
|
||||||
</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