fix(camera): dormant-by-default preview, never trigger camera on tab open

Voice & Video tab no longer fires getUserMedia on mount. Uses
navigator.permissions.query({name:'camera'}) for state detection,
which is passive (no LED activation). Preview only opens when the
user explicitly clicks the dormant tile or the prompt-state CTA.
In-call mode unchanged (attaches existing LK track, no extra LED).

Spec and plan updated to reflect the corrected design — the
"auto-start preview, no Test Camera toggle" rationale was wrong;
macOS holds the camera LED on for ~2s after release, so any
incidental getUserMedia call (probe, transient mount) flashes the
LED in the user's face even when they aren't on the Voice & Video
panel. Privacy-correct UX: never light the LED without an explicit
user action.
This commit is contained in:
Jannis Braun
2026-04-27 21:13:58 +02:00
parent 02e881fd00
commit 4748df8b24
@@ -4,7 +4,23 @@ import { useVoiceStore } from '../../../stores/voiceStore';
import { getActiveRoom } from '../../../hooks/useLiveKit'; import { getActiveRoom } from '../../../hooks/useLiveKit';
import { isElectron, getElectronAPI } from '../../../platform/platform'; 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 { function errorName(err: unknown): string {
return err instanceof Error ? err.name : ''; return err instanceof Error ? err.name : '';
@@ -60,24 +76,49 @@ export function VideoSection() {
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId); const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const [permState, setPermState] = useState<PermissionState>('unknown'); const [permState, setPermState] = useState<PermissionStatus>('unknown');
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]); const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
const [listOpen, setListOpen] = useState(false); 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<string | null>(null); const [previewError, setPreviewError] = useState<string | null>(null);
// Tracks the deviceId the browser actually picked for the active preview track. // 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<string | null>(null); const [activeDeviceId, setActiveDeviceId] = useState<string | null>(null);
// Bumped on visibilitychange to force the dual-mode preview effect to re-run // Tracks mount state so async handlers don't write to state after unmount.
// (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.
const mountedRef = useRef(true); const mountedRef = useRef(true);
// Anchor for the dropdown's click-outside-to-close behaviour. // Anchor for the dropdown's click-outside-to-close behaviour.
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
// Preview tile refs. // Preview tile refs.
const previewVideoRef = useRef<HTMLVideoElement>(null); const previewVideoRef = useRef<HTMLVideoElement>(null);
const previewStreamRef = useRef<MediaStream | null>(null); const previewStreamRef = useRef<MediaStream | null>(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. // Close the dropdown when the user clicks outside it.
useEffect(() => { useEffect(() => {
@@ -92,41 +133,84 @@ export function VideoSection() {
}, [listOpen]); }, [listOpen]);
// Tab-visibility cleanup: release the camera light when the tab is hidden. // Tab-visibility cleanup: release the camera light when the tab is hidden.
// The dual-mode preview effect depends on `restartTick`; bumping it forces its // Per spec, we DO NOT auto-resume on visibility return — the tile goes
// cleanup to run (which calls fullStop()) and the effect re-runs. The effect // dormant and the user clicks again. Auto-resume would defeat the privacy
// itself short-circuits while document.visibilityState === 'hidden', so no new // model by silently re-lighting the LED on tab focus.
// stream is opened until the tab is visible again.
useEffect(() => { useEffect(() => {
const onVisibility = () => { 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); document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('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(() => { useEffect(() => {
mountedRef.current = true; 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 { try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true }); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
// Stop immediately — we only needed to unlock labels and verify access. const s = await navigator.permissions.query({ name: 'camera' as PermissionName });
stream.getTracks().forEach((t) => t.stop()); status = s as PermissionStatus_API;
if (mountedRef.current) setPermState('granted'); apply(s.state);
} catch (err) { // Re-render if the user grants/revokes via OS or browser settings while
if (!mountedRef.current) return; // the section is open.
if (errorName(err) === 'NotAllowedError') setPermState('initial-denied'); onChange = () => apply(s.state);
else setPermState('granted'); // NotFoundError / NotReadableError — still allow dropdown render 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 () => { return () => {
cancelled = true;
mountedRef.current = false; 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. // Enumerate cameras when permission is granted; refresh on devicechange.
// enumerateDevices() is passive and never lights the LED.
useEffect(() => { useEffect(() => {
if (permState !== 'granted') return; if (permState !== 'granted') return;
@@ -160,84 +244,77 @@ export function VideoSection() {
}; };
}, [permState]); }, [permState]);
// Dual-mode preview: // In-call attach: when an LK camera publication exists with a live track,
// - In-call: if a LiveKit local Camera publication exists with a live MediaStreamTrack, // attach it to the preview <video>. No getUserMedia, no extra LED activity —
// attach that track to the <video> element (no parallel getUserMedia — avoids // the LED is already on for the call publication. Reactive on `isCameraOn`
// NotReadableError on platforms with exclusive-camera semantics, and avoids // and `cameraDeviceId` so hot-swap mid-call updates the preview source.
// double-capture / the camera light being lit twice on platforms that allow it). //
// - Pre-call: open getUserMedia for the selected device. // When `isCameraOn` flips false mid-session, the tile returns to DORMANT
// Reactive on isCameraOn so flipping camera mid-session swaps modes cleanly. // (no auto-restart of getUserMedia) per the privacy model.
useEffect(() => { useEffect(() => {
if (permState !== 'granted') return; if (permState !== 'granted') return;
if (!isCameraOn) {
// Tab is hidden — the previous run's cleanup already fired when restartTick // Camera turned off — detach any in-call attachment. Tile becomes dormant
// changed, releasing the camera. Don't start a new stream while hidden; // unless the user has an active opt-in pre-call preview running.
// we'll re-run again on the next visibilitychange when the tab returns. detachInCall();
if (document.visibilityState === 'hidden') { if (!previewActive) setActiveDeviceId(null);
return () => {}; return;
} }
let cancelled = false; const room = getActiveRoom();
if (!room) return;
const camPub = Array.from(room.localParticipant.trackPublications.values()).find(
(pub) => pub.source === Track.Source.Camera,
);
const mst = camPub?.track?.mediaStreamTrack;
if (!mst || mst.readyState !== 'live') return;
// Stop the pre-call getUserMedia stream we own. Releases the camera light. // If a pre-call preview is running, stop it cleanly — the in-call track
const stopPreCall = () => { // takes over the tile. The LK track's LED is already on for the call.
if (previewStreamRef.current) { if (previewActive) {
previewStreamRef.current.getTracks().forEach((t) => t.stop()); startGenRef.current += 1;
previewStreamRef.current = 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;
};
const fullStop = () => {
stopPreCall(); stopPreCall();
setPreviewActive(false);
}
const videoEl = previewVideoRef.current;
if (!videoEl) return;
// Wrap the LK MediaStreamTrack in a fresh MediaStream for srcObject.
// This mirrors LiveKit's `track.attach()` internally; the wrapper does
// not duplicate or take ownership of the track.
videoEl.srcObject = new MediaStream([mst]);
videoEl.play().catch(() => {});
setActiveDeviceId(mst.getSettings().deviceId ?? null);
setPreviewError(null);
return () => {
detachInCall(); detachInCall();
setActiveDeviceId(null);
}; };
// We intentionally exclude `previewActive` from deps — its only role here
// is to short-circuit the "stop pre-call before attach" branch, which is
// already idempotent. Including it would re-run the attach on every
// user-driven start/stop click, causing visible flicker on the tile.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permState, isCameraOn, cameraDeviceId]);
// Returns true iff an LK camera track was successfully attached to the preview. // When the user changes the dropdown WHILE the pre-call preview is running,
// Returns false (never throws) when no room/publication exists or the track is dead, // re-open getUserMedia for the new device. (Same effect was previously
// so the pre-call fallback can run cleanly. // bundled into the dual-mode auto-start; isolating it here makes the dormant
const tryInCall = (): boolean => { // state truly dormant.)
const room = getActiveRoom(); useEffect(() => {
if (!room) return false; if (permState !== 'granted') return;
const camPub = Array.from(room.localParticipant.trackPublications.values()).find( if (!previewActive) return;
(pub) => pub.source === Track.Source.Camera, if (isCameraOn) return; // in-call mode handles its own swap via syncCamera
);
const mst = camPub?.track?.mediaStreamTrack;
if (!mst || mst.readyState !== 'live') return false;
// Defensive: ensure we don't keep a parallel getUserMedia stream alive. const gen = ++startGenRef.current;
stopPreCall(); stopPreCall();
const videoEl = previewVideoRef.current;
if (!videoEl) return false;
// Wrap the LK MediaStreamTrack in a fresh MediaStream for srcObject assignment.
// This is the same pattern LiveKit's track.attach() uses internally; the
// wrapper does not duplicate or take ownership of the track.
videoEl.srcObject = new MediaStream([mst]);
videoEl.play().catch(() => {});
setActiveDeviceId(mst.getSettings().deviceId ?? null);
setPreviewError(null);
return true;
};
const startPreCall = async () => {
// Make sure no LK attachment is left over from a previous mode.
detachInCall();
// Stop any prior pre-call stream before requesting a new one.
stopPreCall();
const start = async () => {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ const stream = await navigator.mediaDevices.getUserMedia({
video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true, video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
}); });
if (cancelled) { if (gen !== startGenRef.current || !mountedRef.current) {
stream.getTracks().forEach((t) => t.stop()); stream.getTracks().forEach((t) => t.stop());
return; return;
} }
@@ -245,57 +322,102 @@ export function VideoSection() {
const videoEl = previewVideoRef.current; const videoEl = previewVideoRef.current;
if (videoEl) { if (videoEl) {
videoEl.srcObject = stream; videoEl.srcObject = stream;
// Autoplay errors on muted video are common and benign.
videoEl.play().catch(() => {}); videoEl.play().catch(() => {});
} }
const id = stream.getVideoTracks()[0]?.getSettings().deviceId ?? null; const id = stream.getVideoTracks()[0]?.getSettings().deviceId ?? null;
setActiveDeviceId(id); setActiveDeviceId(id);
setPreviewError(null); setPreviewError(null);
} catch (err) { } catch (err) {
if (cancelled) return; if (gen !== startGenRef.current || !mountedRef.current) return;
const name = errorName(err); const name = errorName(err);
if (name === 'NotReadableError') { if (name === 'NotReadableError') setPreviewError('Camera is in use by another application.');
setPreviewError('Camera is in use by another application.'); else if (name === 'OverconstrainedError') setPreviewError('Selected camera is unavailable.');
} else if (name === 'OverconstrainedError') { else if (name === 'NotAllowedError') {
setPreviewError('Selected camera is unavailable.'); setPermState('denied');
} else { setPreviewActive(false);
setPreviewError('Could not start camera preview.'); } else setPreviewError('Could not start camera preview.');
}
} }
}; };
// Mode decision. tryInCall short-circuits to the pre-call fallback if the LK start();
// track isn't ready yet (e.g., user just toggled camera on, publication still // Cleanup runs on next deviceId change or unmount.
// negotiating).
if (isCameraOn && tryInCall()) {
// In-call mode active.
} else {
startPreCall();
}
return () => { return () => {
cancelled = true; // Bumping gen invalidates the in-flight start; actual track stop happens
fullStop(); // when the user clicks Stop preview or via the unmount cleanup below.
}; };
}, [permState, cameraDeviceId, isCameraOn, restartTick]); }, [cameraDeviceId, previewActive, isCameraOn, permState]);
// Component-unmount cleanup: stop any live preview stream so the LED goes off
// when the modal closes / settings tab changes / route changes.
useEffect(() => {
return () => {
startGenRef.current += 1;
stopPreCall();
};
}, []);
const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]); const displayLabels = useMemo(() => buildDisplayLabels(devices), [devices]);
const handleTryAgain = async () => { // Explicit user gesture: open getUserMedia for the selected device.
setPermState('probing'); // Called from (a) click on dormant tile, (b) "Enable camera preview" CTA,
// (c) retry from an error state.
const startPreviewFromUser = async () => {
setPreviewError(null);
const gen = ++startGenRef.current;
stopPreCall();
setPreviewActive(true);
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ video: true }); const stream = await navigator.mediaDevices.getUserMedia({
stream.getTracks().forEach((t) => t.stop()); video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
if (mountedRef.current) setPermState('granted'); });
if (gen !== startGenRef.current || !mountedRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
previewStreamRef.current = stream;
const videoEl = previewVideoRef.current;
if (videoEl) {
videoEl.srcObject = stream;
videoEl.play().catch(() => {});
}
const id = stream.getVideoTracks()[0]?.getSettings().deviceId ?? null;
setActiveDeviceId(id);
// If we were in `prompt` state, this getUserMedia call also granted
// permission. Transition to `granted`; the enumerate effect picks up.
if (mountedRef.current) {
setPermState((prev) => (prev === 'prompt' || prev === 'denied' ? 'granted' : prev));
}
} catch (err) { } catch (err) {
if (!mountedRef.current) return; if (gen !== startGenRef.current || !mountedRef.current) return;
// Second denial → escalate to hard-blocked. const name = errorName(err);
if (errorName(err) === 'NotAllowedError') setPermState('hard-blocked'); if (name === 'NotAllowedError') {
else setPermState('initial-denied'); // First-prompt deny from `prompt` state, or "Try again" denial from `denied`.
setPermState((prev) => (prev === 'denied' ? 'hard-blocked' : 'denied'));
setPreviewActive(false);
} else if (name === 'NotReadableError') {
setPreviewError('Camera is in use by another application.');
} else if (name === 'OverconstrainedError') {
setPreviewError('Selected camera is unavailable.');
} else if (name === 'NotFoundError') {
setPreviewError('No camera detected.');
} else {
setPreviewError('Could not start camera preview.');
}
} }
}; };
if (permState === 'unknown' || permState === 'probing') { const stopPreviewFromUser = () => {
startGenRef.current += 1;
stopPreCall();
setPreviewActive(false);
setActiveDeviceId(null);
setPreviewError(null);
};
// ── Render ────────────────────────────────────────────────────────────
if (permState === 'unknown') {
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">
@@ -308,7 +430,7 @@ export function VideoSection() {
); );
} }
if (permState === 'initial-denied' || permState === 'hard-blocked') { if (permState === 'denied' || permState === 'hard-blocked') {
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">
@@ -322,7 +444,7 @@ export function VideoSection() {
: 'Grant camera permission to choose a camera.'} : 'Grant camera permission to choose a camera.'}
</div> </div>
<button <button
onClick={handleTryAgain} onClick={startPreviewFromUser}
className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors" className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
> >
Try again Try again
@@ -332,6 +454,32 @@ export function VideoSection() {
); );
} }
if (permState === 'prompt') {
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 space-y-3">
<div className="aspect-video w-full rounded-lg bg-surface-base overflow-hidden relative flex flex-col items-center justify-center text-center px-6">
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-2">
<path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z" />
</svg>
<div className="text-sm text-txt-secondary mb-1">
Camera permission needed to choose a camera and preview.
</div>
</div>
<button
onClick={startPreviewFromUser}
className="w-full text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover active:bg-accent-primary-active text-white font-medium transition-colors"
>
Enable camera preview
</button>
</div>
</div>
);
}
// permState === 'granted' // permState === 'granted'
const selectedLabel = const selectedLabel =
cameraDeviceId === null cameraDeviceId === null
@@ -343,23 +491,58 @@ export function VideoSection() {
setListOpen(false); setListOpen(false);
}; };
// The preview tile is "dormant" when no in-call track is attached AND no
// user-initiated preview is running. In dormant state we render a play-icon
// overlay and a click handler that calls startPreviewFromUser.
const inCallAttached = isCameraOn && activeDeviceId !== null && !previewActive;
const isDormant = !previewActive && !inCallAttached;
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"> <div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="aspect-video w-full rounded-lg bg-surface-base overflow-hidden relative mb-3"> <div className="aspect-video w-full rounded-lg bg-surface-base overflow-hidden relative mb-3 group">
<video <video
ref={previewVideoRef} ref={previewVideoRef}
muted muted
playsInline playsInline
className="w-full h-full object-cover" className={`w-full h-full object-cover ${isDormant ? 'invisible' : ''}`}
style={{ transform: 'scaleX(-1)' }} style={{ transform: 'scaleX(-1)' }}
/> />
{isDormant && (
<button
type="button"
onClick={startPreviewFromUser}
aria-label="Test camera"
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary hover:text-txt-secondary hover:bg-white/[0.02] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-primary"
>
<svg width="40" height="40" viewBox="0 0 24 24" fill="currentColor" className="mb-2">
<path d="M8 5v14l11-7z" />
</svg>
<span className="text-xs">Click to test camera</span>
</button>
)}
{previewActive && !previewError && (
<button
type="button"
onClick={stopPreviewFromUser}
className="absolute top-2 right-2 text-[11px] px-2 py-1 rounded-md bg-black/60 hover:bg-black/75 text-white/90 transition-colors opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
>
Stop preview
</button>
)}
{previewError && ( {previewError && (
<div className="absolute inset-0 flex items-center justify-center bg-surface-base/80 text-xs text-txt-tertiary text-center px-4"> <div className="absolute inset-0 flex flex-col items-center justify-center bg-surface-base/90 text-xs text-txt-tertiary text-center px-4 gap-2">
{previewError} <div>{previewError}</div>
<button
type="button"
onClick={startPreviewFromUser}
className="text-[11px] px-2 py-1 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
>
Try again
</button>
</div> </div>
)} )}
</div> </div>
@@ -399,17 +582,16 @@ export function VideoSection() {
</div> </div>
)} )}
</div> </div>
{cameraDeviceId === null && activeDeviceId && (() => { {cameraDeviceId === null &&
const m = devices.find((d) => d.deviceId === activeDeviceId); activeDeviceId &&
const label = m (previewActive || isCameraOn) &&
? displayLabels.get(activeDeviceId) ?? (m.label || 'detected camera') (() => {
: 'detected camera'; const m = devices.find((d) => d.deviceId === activeDeviceId);
return ( const label = m
<div className="text-xs text-txt-tertiary mt-1"> ? displayLabels.get(activeDeviceId) ?? (m.label || 'detected camera')
Currently using: {label} : 'detected camera';
</div> return <div className="text-xs text-txt-tertiary mt-1">Currently using: {label}</div>;
); })()}
})()}
{devices.length === 0 && ( {devices.length === 0 && (
<div className="text-xs text-txt-tertiary mt-1">No cameras detected.</div> <div className="text-xs text-txt-tertiary mt-1">No cameras detected.</div>
)} )}
@@ -448,3 +630,8 @@ function DropdownItem({ label, active, onClick }: DropdownItemProps) {
</button> </button>
); );
} }
// Local alias for the runtime PermissionStatus event-target. The lib.dom name
// `PermissionStatus` clashes with our internal state-machine type name above,
// so we alias it here at the bottom for the listener-cleanup branch.
type PermissionStatus_API = globalThis.PermissionStatus;