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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user