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 { 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 {
|
||||
return err instanceof Error ? err.name : '';
|
||||
@@ -60,24 +76,49 @@ export function VideoSection() {
|
||||
const setCameraDeviceId = useVoiceStore((s) => s.setCameraDeviceId);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
|
||||
const [permState, setPermState] = useState<PermissionState>('unknown');
|
||||
const [permState, setPermState] = useState<PermissionStatus>('unknown');
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
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);
|
||||
// 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);
|
||||
// Bumped on visibilitychange to force the dual-mode preview effect to re-run
|
||||
// (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.
|
||||
// Tracks mount state so async 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);
|
||||
// Preview tile refs.
|
||||
const previewVideoRef = useRef<HTMLVideoElement>(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.
|
||||
useEffect(() => {
|
||||
@@ -92,41 +133,84 @@ export function VideoSection() {
|
||||
}, [listOpen]);
|
||||
|
||||
// Tab-visibility cleanup: release the camera light when the tab is hidden.
|
||||
// The dual-mode preview effect depends on `restartTick`; bumping it forces its
|
||||
// cleanup to run (which calls fullStop()) and the effect re-runs. The effect
|
||||
// itself short-circuits while document.visibilityState === 'hidden', so no new
|
||||
// stream is opened until the tab is visible again.
|
||||
// Per spec, we DO NOT auto-resume on visibility return — the tile goes
|
||||
// dormant and the user clicks again. Auto-resume would defeat the privacy
|
||||
// model by silently re-lighting the LED on tab focus.
|
||||
useEffect(() => {
|
||||
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);
|
||||
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(() => {
|
||||
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 {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
// Stop immediately — we only needed to unlock labels and verify access.
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
if (mountedRef.current) setPermState('granted');
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
if (errorName(err) === 'NotAllowedError') setPermState('initial-denied');
|
||||
else setPermState('granted'); // NotFoundError / NotReadableError — still allow dropdown render
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const s = await navigator.permissions.query({ name: 'camera' as PermissionName });
|
||||
status = s as PermissionStatus_API;
|
||||
apply(s.state);
|
||||
// Re-render if the user grants/revokes via OS or browser settings while
|
||||
// the section is open.
|
||||
onChange = () => apply(s.state);
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
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.
|
||||
// enumerateDevices() is passive and never lights the LED.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
|
||||
@@ -160,84 +244,77 @@ export function VideoSection() {
|
||||
};
|
||||
}, [permState]);
|
||||
|
||||
// Dual-mode preview:
|
||||
// - In-call: if a LiveKit local Camera publication exists with a live MediaStreamTrack,
|
||||
// attach that track to the <video> element (no parallel getUserMedia — avoids
|
||||
// NotReadableError on platforms with exclusive-camera semantics, and avoids
|
||||
// double-capture / the camera light being lit twice on platforms that allow it).
|
||||
// - Pre-call: open getUserMedia for the selected device.
|
||||
// Reactive on isCameraOn so flipping camera mid-session swaps modes cleanly.
|
||||
// In-call attach: when an LK camera publication exists with a live track,
|
||||
// attach it to the preview <video>. No getUserMedia, no extra LED activity —
|
||||
// the LED is already on for the call publication. Reactive on `isCameraOn`
|
||||
// and `cameraDeviceId` so hot-swap mid-call updates the preview source.
|
||||
//
|
||||
// When `isCameraOn` flips false mid-session, the tile returns to DORMANT
|
||||
// (no auto-restart of getUserMedia) per the privacy model.
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
|
||||
// Tab is hidden — the previous run's cleanup already fired when restartTick
|
||||
// changed, releasing the camera. Don't start a new stream while hidden;
|
||||
// we'll re-run again on the next visibilitychange when the tab returns.
|
||||
if (document.visibilityState === 'hidden') {
|
||||
return () => {};
|
||||
if (!isCameraOn) {
|
||||
// Camera turned off — detach any in-call attachment. Tile becomes dormant
|
||||
// unless the user has an active opt-in pre-call preview running.
|
||||
detachInCall();
|
||||
if (!previewActive) setActiveDeviceId(null);
|
||||
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.
|
||||
const stopPreCall = () => {
|
||||
if (previewStreamRef.current) {
|
||||
previewStreamRef.current.getTracks().forEach((t) => t.stop());
|
||||
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 = () => {
|
||||
// If a pre-call preview is running, stop it cleanly — the in-call track
|
||||
// takes over the tile. The LK track's LED is already on for the call.
|
||||
if (previewActive) {
|
||||
startGenRef.current += 1;
|
||||
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();
|
||||
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.
|
||||
// Returns false (never throws) when no room/publication exists or the track is dead,
|
||||
// so the pre-call fallback can run cleanly.
|
||||
const tryInCall = (): boolean => {
|
||||
const room = getActiveRoom();
|
||||
if (!room) return false;
|
||||
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 false;
|
||||
// When the user changes the dropdown WHILE the pre-call preview is running,
|
||||
// re-open getUserMedia for the new device. (Same effect was previously
|
||||
// bundled into the dual-mode auto-start; isolating it here makes the dormant
|
||||
// state truly dormant.)
|
||||
useEffect(() => {
|
||||
if (permState !== 'granted') return;
|
||||
if (!previewActive) return;
|
||||
if (isCameraOn) return; // in-call mode handles its own swap via syncCamera
|
||||
|
||||
// Defensive: ensure we don't keep a parallel getUserMedia stream alive.
|
||||
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 gen = ++startGenRef.current;
|
||||
stopPreCall();
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
|
||||
});
|
||||
if (cancelled) {
|
||||
if (gen !== startGenRef.current || !mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
@@ -245,57 +322,102 @@ export function VideoSection() {
|
||||
const videoEl = previewVideoRef.current;
|
||||
if (videoEl) {
|
||||
videoEl.srcObject = stream;
|
||||
// Autoplay errors on muted video are common and benign.
|
||||
videoEl.play().catch(() => {});
|
||||
}
|
||||
const id = stream.getVideoTracks()[0]?.getSettings().deviceId ?? null;
|
||||
setActiveDeviceId(id);
|
||||
setPreviewError(null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
if (gen !== startGenRef.current || !mountedRef.current) return;
|
||||
const name = errorName(err);
|
||||
if (name === 'NotReadableError') {
|
||||
setPreviewError('Camera is in use by another application.');
|
||||
} else if (name === 'OverconstrainedError') {
|
||||
setPreviewError('Selected camera is unavailable.');
|
||||
} else {
|
||||
setPreviewError('Could not start camera preview.');
|
||||
}
|
||||
if (name === 'NotReadableError') setPreviewError('Camera is in use by another application.');
|
||||
else if (name === 'OverconstrainedError') setPreviewError('Selected camera is unavailable.');
|
||||
else if (name === 'NotAllowedError') {
|
||||
setPermState('denied');
|
||||
setPreviewActive(false);
|
||||
} else setPreviewError('Could not start camera preview.');
|
||||
}
|
||||
};
|
||||
|
||||
// Mode decision. tryInCall short-circuits to the pre-call fallback if the LK
|
||||
// track isn't ready yet (e.g., user just toggled camera on, publication still
|
||||
// negotiating).
|
||||
if (isCameraOn && tryInCall()) {
|
||||
// In-call mode active.
|
||||
} else {
|
||||
startPreCall();
|
||||
}
|
||||
|
||||
start();
|
||||
// Cleanup runs on next deviceId change or unmount.
|
||||
return () => {
|
||||
cancelled = true;
|
||||
fullStop();
|
||||
// Bumping gen invalidates the in-flight start; actual track stop happens
|
||||
// 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 handleTryAgain = async () => {
|
||||
setPermState('probing');
|
||||
// Explicit user gesture: open getUserMedia for the selected device.
|
||||
// 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 {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
if (mountedRef.current) setPermState('granted');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: cameraDeviceId ? { deviceId: { exact: cameraDeviceId } } : true,
|
||||
});
|
||||
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) {
|
||||
if (!mountedRef.current) return;
|
||||
// Second denial → escalate to hard-blocked.
|
||||
if (errorName(err) === 'NotAllowedError') setPermState('hard-blocked');
|
||||
else setPermState('initial-denied');
|
||||
if (gen !== startGenRef.current || !mountedRef.current) return;
|
||||
const name = errorName(err);
|
||||
if (name === 'NotAllowedError') {
|
||||
// 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 (
|
||||
<div>
|
||||
<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 (
|
||||
<div>
|
||||
<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.'}
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
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'
|
||||
const selectedLabel =
|
||||
cameraDeviceId === null
|
||||
@@ -343,23 +491,58 @@ export function VideoSection() {
|
||||
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 (
|
||||
<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">
|
||||
<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
|
||||
ref={previewVideoRef}
|
||||
muted
|
||||
playsInline
|
||||
className="w-full h-full object-cover"
|
||||
className={`w-full h-full object-cover ${isDormant ? 'invisible' : ''}`}
|
||||
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 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-surface-base/80 text-xs text-txt-tertiary text-center px-4">
|
||||
{previewError}
|
||||
<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">
|
||||
<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>
|
||||
@@ -399,17 +582,16 @@ export function VideoSection() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{cameraDeviceId === null && activeDeviceId && (() => {
|
||||
const m = devices.find((d) => d.deviceId === activeDeviceId);
|
||||
const label = m
|
||||
? displayLabels.get(activeDeviceId) ?? (m.label || 'detected camera')
|
||||
: 'detected camera';
|
||||
return (
|
||||
<div className="text-xs text-txt-tertiary mt-1">
|
||||
Currently using: {label}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{cameraDeviceId === null &&
|
||||
activeDeviceId &&
|
||||
(previewActive || isCameraOn) &&
|
||||
(() => {
|
||||
const m = devices.find((d) => d.deviceId === activeDeviceId);
|
||||
const label = m
|
||||
? displayLabels.get(activeDeviceId) ?? (m.label || 'detected camera')
|
||||
: 'detected camera';
|
||||
return <div className="text-xs text-txt-tertiary mt-1">Currently using: {label}</div>;
|
||||
})()}
|
||||
{devices.length === 0 && (
|
||||
<div className="text-xs text-txt-tertiary mt-1">No cameras detected.</div>
|
||||
)}
|
||||
@@ -448,3 +630,8 @@ function DropdownItem({ label, active, onClick }: DropdownItemProps) {
|
||||
</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