import type { CreateLocalTracksOptions, LocalAudioTrack, LocalVideoTrack, TrackProcessor, } from 'livekit-client'; import { createLocalAudioTrack, createLocalTracks, createLocalVideoTrack, facingModeFromLocalTrack, Track, VideoPresets, Mutex, } from 'livekit-client'; import * as React from 'react'; import { MediaDeviceMenu } from './MediaDeviceMenu'; import { TrackToggle } from '../components/controls/TrackToggle'; import type { LocalUserChoices } from '@livekit/components-core'; import { log } from '@livekit/components-core'; import { ParticipantPlaceholder } from '../assets/images'; import { useMediaDevices, usePersistentUserChoices } from '../hooks'; import { useWarnAboutMissingStyles } from '../hooks/useWarnAboutMissingStyles'; import { roomOptionsStringifyReplacer } from '../utils'; /** * Props for the PreJoin component. * @public */ export interface PreJoinProps extends Omit, 'onSubmit' | 'onError'> { /** This function is called with the `LocalUserChoices` if validation is passed. */ onSubmit?: (values: LocalUserChoices) => void; /** * Provide your custom validation function. Only if validation is successful the user choices are past to the onSubmit callback. */ onValidate?: (values: LocalUserChoices) => boolean; onError?: (error: Error) => void; /** Prefill the input form with initial values. */ defaults?: Partial; /** Display a debug window for your convenience. */ debug?: boolean; joinLabel?: string; micLabel?: string; camLabel?: string; userLabel?: string; /** * If true, user choices are persisted across sessions. * @defaultValue true * @alpha */ persistUserChoices?: boolean; videoProcessor?: TrackProcessor; } /** @public */ export function usePreviewTracks( options: CreateLocalTracksOptions, onError?: (err: Error) => void, ) { const [tracks, setTracks] = React.useState>(); const trackLock = React.useMemo(() => new Mutex(), []); React.useEffect(() => { let needsCleanup = false; let localTracks: Array = []; trackLock.lock().then(async (unlock) => { try { if (options.audio || options.video) { localTracks = (await createLocalTracks(options)) as Array< LocalAudioTrack | LocalVideoTrack >; if (needsCleanup) { localTracks.forEach((tr) => tr.stop()); } else { setTracks(localTracks); } } } catch (e: unknown) { if (onError && e instanceof Error) { onError(e); } else { log.error(e); } } finally { unlock(); } }); return () => { needsCleanup = true; localTracks.forEach((track) => { track.stop(); }); }; }, [JSON.stringify(options, roomOptionsStringifyReplacer), onError, trackLock]); return tracks; } /** * @public * @deprecated use `usePreviewTracks` instead */ export function usePreviewDevice( enabled: boolean, deviceId: string, kind: 'videoinput' | 'audioinput', ) { const [deviceError, setDeviceError] = React.useState(null); const [isCreatingTrack, setIsCreatingTrack] = React.useState(false); const devices = useMediaDevices({ kind }); const [selectedDevice, setSelectedDevice] = React.useState( undefined, ); const [localTrack, setLocalTrack] = React.useState(); const [localDeviceId, setLocalDeviceId] = React.useState(deviceId); React.useEffect(() => { setLocalDeviceId(deviceId); }, [deviceId]); const createTrack = async (deviceId: string, kind: 'videoinput' | 'audioinput') => { try { const track = kind === 'videoinput' ? await createLocalVideoTrack({ deviceId, resolution: VideoPresets.h720.resolution, }) : await createLocalAudioTrack({ deviceId }); const newDeviceId = await track.getDeviceId(false); if (newDeviceId && deviceId !== newDeviceId) { prevDeviceId.current = newDeviceId; setLocalDeviceId(newDeviceId); } setLocalTrack(track as T); } catch (e) { if (e instanceof Error) { setDeviceError(e); } } }; const switchDevice = async (track: LocalVideoTrack | LocalAudioTrack, id: string) => { await track.setDeviceId(id); prevDeviceId.current = id; }; const prevDeviceId = React.useRef(localDeviceId); React.useEffect(() => { if (enabled && !localTrack && !deviceError && !isCreatingTrack) { log.debug('creating track', kind); setIsCreatingTrack(true); createTrack(localDeviceId, kind).finally(() => { setIsCreatingTrack(false); }); } }, [enabled, localTrack, deviceError, isCreatingTrack]); // switch camera device React.useEffect(() => { if (!localTrack) { return; } if (!enabled) { log.debug(`muting ${kind} track`); localTrack.mute().then(() => log.debug(localTrack.mediaStreamTrack)); } else if (selectedDevice?.deviceId && prevDeviceId.current !== selectedDevice?.deviceId) { log.debug(`switching ${kind} device from`, prevDeviceId.current, selectedDevice.deviceId); switchDevice(localTrack, selectedDevice.deviceId); } else { log.debug(`unmuting local ${kind} track`); localTrack.unmute(); } }, [localTrack, selectedDevice, enabled, kind]); React.useEffect(() => { return () => { if (localTrack) { log.debug(`stopping local ${kind} track`); localTrack.stop(); localTrack.mute(); } }; }, []); React.useEffect(() => { setSelectedDevice(devices?.find((dev) => dev.deviceId === localDeviceId)); }, [localDeviceId, devices]); return { selectedDevice, localTrack, deviceError, }; } /** * The `PreJoin` prefab component is normally presented to the user before he enters a room. * This component allows the user to check and select the preferred media device (camera und microphone). * On submit the user decisions are returned, which can then be passed on to the `LiveKitRoom` so that the user enters the room with the correct media devices. * * @remarks * This component is independent of the `LiveKitRoom` component and should not be nested within it. * Because it only accesses the local media tracks this component is self-contained and works without connection to the LiveKit server. * * @example * ```tsx * * ``` * @public */ export function PreJoin({ defaults = {}, onValidate, onSubmit, onError, debug, joinLabel = 'Join Room', micLabel = 'Microphone', camLabel = 'Camera', userLabel = 'Username', persistUserChoices = true, videoProcessor, ...htmlProps }: PreJoinProps) { const { userChoices: initialUserChoices, saveAudioInputDeviceId, saveAudioInputEnabled, saveVideoInputDeviceId, saveVideoInputEnabled, saveUsername, } = usePersistentUserChoices({ defaults, preventSave: !persistUserChoices, preventLoad: !persistUserChoices, }); const [userChoices, setUserChoices] = React.useState(initialUserChoices); // Initialize device settings const [audioEnabled, setAudioEnabled] = React.useState(userChoices.audioEnabled); const [videoEnabled, setVideoEnabled] = React.useState(userChoices.videoEnabled); const [audioDeviceId, setAudioDeviceId] = React.useState(userChoices.audioDeviceId); const [videoDeviceId, setVideoDeviceId] = React.useState(userChoices.videoDeviceId); const [username, setUsername] = React.useState(userChoices.username); // Save user choices to persistent storage. React.useEffect(() => { saveAudioInputEnabled(audioEnabled); }, [audioEnabled, saveAudioInputEnabled]); React.useEffect(() => { saveVideoInputEnabled(videoEnabled); }, [videoEnabled, saveVideoInputEnabled]); React.useEffect(() => { saveAudioInputDeviceId(audioDeviceId); }, [audioDeviceId, saveAudioInputDeviceId]); React.useEffect(() => { saveVideoInputDeviceId(videoDeviceId); }, [videoDeviceId, saveVideoInputDeviceId]); React.useEffect(() => { saveUsername(username); }, [username, saveUsername]); const tracks = usePreviewTracks( { audio: audioEnabled ? { deviceId: initialUserChoices.audioDeviceId } : false, video: videoEnabled ? { deviceId: initialUserChoices.videoDeviceId, processor: videoProcessor } : false, }, onError, ); const videoEl = React.useRef(null); const videoTrack = React.useMemo( () => tracks?.filter((track) => track.kind === Track.Kind.Video)[0] as LocalVideoTrack, [tracks], ); const facingMode = React.useMemo(() => { if (videoTrack) { const { facingMode } = facingModeFromLocalTrack(videoTrack); return facingMode; } else { return 'undefined'; } }, [videoTrack]); const audioTrack = React.useMemo( () => tracks?.filter((track) => track.kind === Track.Kind.Audio)[0] as LocalAudioTrack, [tracks], ); React.useEffect(() => { if (videoEl.current && videoTrack) { videoTrack.unmute(); videoTrack.attach(videoEl.current); } return () => { videoTrack?.detach(); }; }, [videoTrack]); const [isValid, setIsValid] = React.useState(); const handleValidation = React.useCallback( (values: LocalUserChoices) => { if (typeof onValidate === 'function') { return onValidate(values); } else { return values.username !== ''; } }, [onValidate], ); React.useEffect(() => { const newUserChoices = { username, videoEnabled, videoDeviceId, audioEnabled, audioDeviceId, }; setUserChoices(newUserChoices); setIsValid(handleValidation(newUserChoices)); }, [username, videoEnabled, handleValidation, audioEnabled, audioDeviceId, videoDeviceId]); function handleSubmit(event: React.FormEvent) { event.preventDefault(); if (handleValidation(userChoices)) { if (typeof onSubmit === 'function') { onSubmit(userChoices); } } else { log.warn('Validation failed with: ', userChoices); } } useWarnAboutMissingStyles(); return (
{videoTrack && (
setAudioEnabled(enabled)} > {micLabel}
setAudioDeviceId(id)} />
setVideoEnabled(enabled)} > {camLabel}
setVideoDeviceId(id)} />
setUsername(inputEl.target.value)} autoComplete="off" />
{debug && ( <> User Choices:
  • Username: {`${userChoices.username}`}
  • Video Enabled: {`${userChoices.videoEnabled}`}
  • Audio Enabled: {`${userChoices.audioEnabled}`}
  • Video Device: {`${userChoices.videoDeviceId}`}
  • Audio Device: {`${userChoices.audioDeviceId}`}
)}
); }