add camera preview in call view prescreen

This commit is contained in:
Ajay Bura 2026-10-18 13:25:20 +05:30
commit 9f51e17b86
2 changed files with 139 additions and 0 deletions

View file

@ -15,6 +15,7 @@ import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { useClient } from '../../hooks/useClient';
import { CameraPreview } from './CameraPreview';
function LivekitServerMissingMessage() {
return (
@ -114,6 +115,7 @@ function CallPrescreen() {
</Badge>
</Header>
)}
<CameraPreview />
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Box className={css.PrescreenMessage} alignItems="Center">

View file

@ -0,0 +1,137 @@
import { Box, config, Icon, Icons, Text, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import React, { useEffect, useRef, useState } from 'react';
import { SequenceCard } from '../../components/sequence-card';
import { CutoutCard } from '../../components/cutout-card';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
type CameraStatus = 'idle' | 'loading' | 'ready' | 'denied' | 'not-found' | 'in-use' | 'error';
function useCameraStream(enabled: boolean) {
const [stream, setStream] = useState<MediaStream | null>(null);
const [status, setStatus] = useState<CameraStatus>('idle');
useEffect(() => {
if (!enabled) {
setStream(null);
setStatus('idle');
return undefined;
}
let cancelled = false;
let active: MediaStream | undefined;
setStatus('loading');
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('error');
return undefined;
}
navigator.mediaDevices
.getUserMedia({ video: true, audio: false })
.then((s) => {
if (cancelled) {
s.getTracks().forEach((t) => t.stop());
return;
}
active = s;
setStream(s);
setStatus('ready');
})
.catch((e: DOMException) => {
if (cancelled) return;
setStream(null);
switch (e.name) {
case 'NotAllowedError':
case 'SecurityError':
setStatus('denied');
break;
case 'NotFoundError':
case 'OverconstrainedError':
setStatus('not-found');
break;
case 'NotReadableError':
case 'AbortError':
setStatus('in-use');
break;
default:
setStatus('error');
}
});
return () => {
cancelled = true;
active?.getTracks().forEach((t) => t.stop());
};
}, [enabled]);
return { stream, status };
}
const STATUS_MESSAGE: Record<Exclude<CameraStatus, 'ready'>, string> = {
idle: 'Camera off',
loading: 'Starting camera…',
denied: 'Camera permission denied',
'not-found': 'No camera found',
'in-use': 'Camera is in use by another app',
error: 'Unable to access camera',
};
export function CameraPreview() {
const callPrefAtom = useCallPreferencesAtom();
const { video } = useAtomValue(callPrefAtom);
const { stream, status } = useCameraStream(video);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const el = videoRef.current;
if (el) el.srcObject = stream;
}, [stream]);
return (
<SequenceCard
variant="SurfaceVariant"
direction="Column"
style={{
padding: config.space.S400,
}}
>
<CutoutCard variant={status === 'idle' || status === 'ready' ? 'Surface' : 'Warning'}>
{status === 'ready' ? (
<video
ref={videoRef}
autoPlay
muted
playsInline
style={{
backgroundColor: 'black',
display: 'block',
width: '100%',
minHeight: toRem(148),
maxHeight: toRem(148),
transform: 'scaleX(-1)', // mirror view
}}
/>
) : (
<Box
style={{
minHeight: toRem(148),
}}
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="200"
grow="Yes"
>
<Icon src={status === 'idle' ? Icons.User : Icons.Warning} />
<Text size="L400" align="Center">
{STATUS_MESSAGE[status as Exclude<CameraStatus, 'ready'>]}
</Text>
</Box>
)}
</CutoutCard>
</SequenceCard>
);
}