diff --git a/.changeset/yellow-timers-deny.md b/.changeset/yellow-timers-deny.md new file mode 100644 index 000000000..396decc97 --- /dev/null +++ b/.changeset/yellow-timers-deny.md @@ -0,0 +1,6 @@ +--- +'@livekit/components-react': patch +'@livekit/components-styles': patch +--- + +Fix prejoin bugs diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c3d2d0697..9436ab142 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -26,14 +26,14 @@ jobs: npm install -g yarn yarn install - - name: Build Packages - run: yarn build - - name: ESLint run: yarn lint - name: Prettier run: yarn format + - name: Build Packages + run: yarn build + - name: Run Tests run: yarn test diff --git a/packages/react/src/components/controls/TrackToggle.tsx b/packages/react/src/components/controls/TrackToggle.tsx index 832175b8c..74c0d5ed4 100644 --- a/packages/react/src/components/controls/TrackToggle.tsx +++ b/packages/react/src/components/controls/TrackToggle.tsx @@ -27,7 +27,7 @@ export function useTrackToggle({ source, onChange, initialState, ...rest }: UseT ); const pending = useObservableState(pendingObserver, false); - const enabled = useObservableState(enabledObserver, !!track?.isEnabled); + const enabled = useObservableState(enabledObserver, initialState ?? !!track?.isEnabled); React.useEffect(() => { onChange?.(enabled); diff --git a/packages/react/src/prefabs/PreJoin.tsx b/packages/react/src/prefabs/PreJoin.tsx index 35fbf52f9..83f9880d3 100644 --- a/packages/react/src/prefabs/PreJoin.tsx +++ b/packages/react/src/prefabs/PreJoin.tsx @@ -1,8 +1,6 @@ import { createLocalAudioTrack, createLocalVideoTrack, - getEmptyAudioStreamTrack, - getEmptyVideoStreamTrack, LocalAudioTrack, LocalVideoTrack, Track, @@ -13,6 +11,7 @@ import { MediaDeviceMenu } from './MediaDeviceMenu'; import { useMediaDevices } from '../components/controls/MediaDeviceSelect'; import { TrackToggle } from '../components/controls/TrackToggle'; import { log } from '@livekit/components-core'; +import { ParticipantPlaceholder } from '../assets/images'; export type LocalUserChoices = { username: string; @@ -51,6 +50,101 @@ export type PreJoinProps = Omit, 'onSubmit' debug?: boolean; }; +function usePreviewDevice( + enabled: boolean, + deviceId: string, + kind: 'videoinput' | 'audioinput', +) { + const [deviceError, setDeviceError] = React.useState(null); + + const devices = useMediaDevices({ kind: 'videoinput' }); + 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: deviceId, + resolution: VideoPresets.h720.resolution, + }) + : await createLocalAudioTrack({ deviceId }); + + const newDeviceId = await track.getDeviceId(); + 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.restartTrack({ + deviceId: id, + }); + prevDeviceId.current = id; + }; + + const prevDeviceId = React.useRef(localDeviceId); + + React.useEffect(() => { + if (enabled && !localTrack && !deviceError) { + log.debug('creating track', kind); + createTrack(localDeviceId, kind); + } + }, [enabled, localTrack, deviceError]); + + // switch camera device + React.useEffect(() => { + if (!enabled) { + if (localTrack) { + log.debug(`muting ${kind} track`); + localTrack.mute().then(() => log.debug(localTrack.mediaStreamTrack)); + } + return; + } + if ( + localTrack && + selectedDevice?.deviceId && + prevDeviceId.current !== selectedDevice?.deviceId + ) { + log.debug(`switching ${kind} device from`, prevDeviceId.current, selectedDevice.deviceId); + switchDevice(localTrack, selectedDevice.deviceId); + } else { + localTrack?.unmute(); + } + + return () => { + localTrack?.stop(); + localTrack?.mute(); + }; + }, [localTrack, selectedDevice, enabled, kind]); + + 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). @@ -80,141 +174,31 @@ export const PreJoin = ({ const [videoEnabled, setVideoEnabled] = React.useState( defaults.videoEnabled ?? DEFAULT_USER_CHOICES.videoEnabled, ); + const [videoDeviceId, setVideoDeviceId] = React.useState( + defaults.videoDeviceId ?? DEFAULT_USER_CHOICES.videoDeviceId, + ); const [audioEnabled, setAudioEnabled] = React.useState( defaults.audioEnabled ?? DEFAULT_USER_CHOICES.audioEnabled, ); - const [selectedVideoDevice, setSelectedVideoDevice] = React.useState( - undefined, - ); - const [selectedAudioDevice, setSelectedAudioDevice] = React.useState( - undefined, + const [audioDeviceId, setAudioDeviceId] = React.useState( + defaults.audioDeviceId ?? DEFAULT_USER_CHOICES.audioDeviceId, ); - const [deviceError, setDeviceError] = React.useState(null); - const [audioDeviceError, setAudioDeviceError] = React.useState(null); - - const videoDevices = useMediaDevices({ kind: 'videoinput' }); - const audioDevices = useMediaDevices({ kind: 'audioinput' }); + const video = usePreviewDevice(videoEnabled, videoDeviceId, 'videoinput'); const videoEl = React.useRef(null); - const audioEl = React.useRef(null); - const [localVideoTrack, setLocalVideoTrack] = React.useState(); - const [localVideoDeviceId, setLocalVideoDeviceId] = React.useState(); - - const [localAudioTrack, setLocalAudioTrack] = React.useState(); - const [localAudioDeviceId, setLocalAudioDeviceId] = React.useState(); - - const [isValid, setIsValid] = React.useState(); - - const createVideoTrack = async (deviceId?: string | undefined) => { - try { - const track = await createLocalVideoTrack({ - deviceId: deviceId, - resolution: VideoPresets.h720.resolution, - }); - - const newDeviceId = await track.getDeviceId(); - setLocalVideoTrack(track); - if (deviceId !== newDeviceId) { - setLocalVideoDeviceId(newDeviceId); - } - } catch (e) { - if (e instanceof Error) { - setDeviceError(e); - onError?.(e); - } - } - }; - - const createAudioTrack = async (deviceId?: string | undefined) => { - try { - const track = await createLocalAudioTrack({ - deviceId: deviceId, - }); - - const newDeviceId = await track.getDeviceId(); - setLocalAudioTrack(track); - if (deviceId !== newDeviceId) { - setLocalAudioDeviceId(newDeviceId); - } - } catch (e) { - if (e instanceof Error) { - setAudioDeviceError(e); - onError?.(e); - } - } - }; - - const prevVideoId = React.useRef(localVideoDeviceId); React.useEffect(() => { - if (videoEnabled) { - if (!localVideoTrack && !deviceError) { - log.debug('starting video'); - setLocalVideoTrack(new LocalVideoTrack(getEmptyVideoStreamTrack())); - createVideoTrack(); - } else if (prevVideoId.current !== selectedVideoDevice?.deviceId) { - log.debug('restarting video'); - localVideoTrack - ?.restartTrack({ - deviceId: selectedVideoDevice?.deviceId, - }) - .catch((e) => setAudioDeviceError(e)); - prevVideoId.current = selectedVideoDevice?.deviceId; - } else { - localVideoTrack?.unmute(); - } - } else { - if (localVideoTrack) { - log.debug('disabling video'); - localVideoTrack.mute(); - } - } - return () => { - localVideoTrack?.mute(); - localVideoTrack?.stop(); - }; - }, [videoEnabled, selectedVideoDevice, localVideoTrack, deviceError]); - - const prevAudioId = React.useRef(localAudioDeviceId); + if (videoEl.current) video.localTrack?.attach(videoEl.current); - React.useEffect(() => { - if (audioEnabled) { - if (!localAudioTrack && !audioDeviceError) { - setLocalAudioTrack(new LocalAudioTrack(getEmptyAudioStreamTrack())); - createAudioTrack(); - } else if (prevAudioId.current !== selectedAudioDevice?.deviceId) { - localAudioTrack - ?.restartTrack({ - deviceId: selectedAudioDevice?.deviceId, - }) - .catch((e) => setAudioDeviceError(e)); - } else { - localAudioTrack?.unmute(); - } - } else { - localAudioTrack?.stop(); - } return () => { - localAudioTrack?.stop(); + video.localTrack?.detach(); }; - }, [audioEnabled, localAudioTrack, selectedAudioDevice, audioDeviceError]); + }, [video.localTrack, videoEl]); - React.useEffect(() => { - if (videoEl.current) localVideoTrack?.attach(videoEl.current); + const audio = usePreviewDevice(audioEnabled, audioDeviceId, 'audioinput'); - return () => { - localVideoTrack?.detach(); - }; - }, [localVideoTrack, videoEl]); - - React.useEffect(() => { - setSelectedVideoDevice(videoDevices.find((dev) => dev.deviceId === localVideoDeviceId)); - }, [localVideoDeviceId, videoDevices]); - - React.useEffect(() => { - setSelectedAudioDevice(audioDevices.find((dev) => dev.deviceId === localAudioDeviceId)); - }, [localAudioDeviceId, audioDevices]); + const [isValid, setIsValid] = React.useState(); const handleValidation = React.useCallback( (values: LocalUserChoices) => { @@ -227,23 +211,34 @@ export const PreJoin = ({ [onValidate], ); + React.useEffect(() => { + if (audio.deviceError) { + onError?.(audio.deviceError); + } + }, [audio.deviceError, onError]); + React.useEffect(() => { + if (video.deviceError) { + onError?.(video.deviceError); + } + }, [video.deviceError, onError]); + React.useEffect(() => { const newUserChoices = { username: username, videoEnabled: videoEnabled, + videoDeviceId: video.selectedDevice?.deviceId ?? '', audioEnabled: audioEnabled, - videoDeviceId: selectedVideoDevice?.deviceId ?? '', - audioDeviceId: selectedAudioDevice?.deviceId ?? '', + audioDeviceId: audio.selectedDevice?.deviceId ?? '', }; setUserChoices(newUserChoices); setIsValid(handleValidation(newUserChoices)); }, [ username, videoEnabled, - audioEnabled, - selectedAudioDevice, - selectedVideoDevice, + video.selectedDevice, handleValidation, + audioEnabled, + audio.selectedDevice, ]); function handleSubmit(event: React.FormEvent) { @@ -259,18 +254,14 @@ export const PreJoin = ({ return (
- {localVideoTrack ? ( -