diff --git a/src/frontend/src/features/rooms/components/Join.tsx b/src/frontend/src/features/rooms/components/Join.tsx index e52bf610..0e333d47 100644 --- a/src/frontend/src/features/rooms/components/Join.tsx +++ b/src/frontend/src/features/rooms/components/Join.tsx @@ -1,8 +1,27 @@ import { useTranslation } from 'react-i18next' -import { PreJoin, type LocalUserChoices } from '@livekit/components-react' +import { + ParticipantPlaceholder, + usePersistentUserChoices, + usePreviewTracks, + type LocalUserChoices, +} from '@livekit/components-react' +import { css } from '@/styled-system/css' +import { log } from '@livekit/components-core' +import { defaultUserChoices } from '@livekit/components-core' import { Screen } from '@/layout/Screen' -import { CenteredContent } from '@/layout/CenteredContent' import { useUser } from '@/features/auth' +import React from 'react' +import { + facingModeFromLocalTrack, + LocalVideoTrack, + Track, +} from 'livekit-client' +import { H } from '@/primitives/H' +import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice' +import { Field } from '@/primitives/Field' +import { Button } from '@/primitives' + +const onError = (e: Error) => console.error('ERROR', e) export const Join = ({ onSubmit, @@ -11,20 +30,349 @@ export const Join = ({ }) => { const { t } = useTranslation('rooms') const { user } = useUser() + const defaults: Partial = { username: user?.full_name } + const persistUserChoices = true + const joinLabel = t('join.joinLabel') + const userLabel = t('join.usernameLabel') + + const [userChoices, setUserChoices] = React.useState(defaultUserChoices) + + // TODO: Remove and pipe `defaults` object directly into `usePersistentUserChoices` once we fully switch from type `LocalUserChoices` to `UserChoices`. + const partialDefaults: Partial = { + ...(defaults.audioDeviceId !== undefined && { + audioDeviceId: defaults.audioDeviceId, + }), + ...(defaults.videoDeviceId !== undefined && { + videoDeviceId: defaults.videoDeviceId, + }), + ...(defaults.audioEnabled !== undefined && { + audioEnabled: defaults.audioEnabled, + }), + ...(defaults.videoEnabled !== undefined && { + videoEnabled: defaults.videoEnabled, + }), + ...(defaults.username !== undefined && { username: defaults.username }), + } + + const { + userChoices: initialUserChoices, + saveAudioInputDeviceId, + saveAudioInputEnabled, + saveVideoInputDeviceId, + saveVideoInputEnabled, + saveUsername, + } = usePersistentUserChoices({ + defaults: partialDefaults, + preventSave: !persistUserChoices, + preventLoad: !persistUserChoices, + }) + + // Initialize device settings + const [audioEnabled, setAudioEnabled] = React.useState( + initialUserChoices.audioEnabled + ) + const [videoEnabled, setVideoEnabled] = React.useState( + initialUserChoices.videoEnabled + ) + const [audioDeviceId, setAudioDeviceId] = React.useState( + initialUserChoices.audioDeviceId + ) + const [videoDeviceId, setVideoDeviceId] = React.useState( + initialUserChoices.videoDeviceId + ) + const [username, setUsername] = React.useState(initialUserChoices.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 } + : false, + }, + onError + ) + + const videoEl = React.useRef(null) + + const videoTrack = React.useMemo( + () => + tracks?.filter( + (track) => track.kind === Track.Kind.Video + )[0] as LocalVideoTrack, + [tracks] + ) + + const audioTrack = React.useMemo( + () => + tracks?.filter( + (track) => track.kind === Track.Kind.Audio + )[0] as LocalVideoTrack, + [tracks] + ) + + const facingMode = React.useMemo(() => { + if (videoTrack) { + const { facingMode } = facingModeFromLocalTrack(videoTrack) + return facingMode + } else { + return 'undefined' + } + }, [videoTrack]) + + 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) => { + return values.username !== '' + }, []) + + React.useEffect(() => { + const newUserChoices = { + username, + videoEnabled, + videoDeviceId, + audioEnabled, + audioDeviceId, + } + setUserChoices(newUserChoices) + setIsValid(handleValidation(newUserChoices)) + }, [ + username, + videoEnabled, + handleValidation, + audioEnabled, + audioDeviceId, + videoDeviceId, + ]) + + function handleSubmit() { + if (handleValidation(userChoices)) { + if (typeof onSubmit === 'function') { + onSubmit(userChoices) + } + } else { + log.warn('Validation failed with: ', userChoices) + } + } return ( - - - - + +
+
+
+
+ {videoTrack && ( + // eslint-disable-next-line jsx-a11y/media-has-caption +
+
+ setAudioEnabled(enabled)} + onDeviceError={(error) => console.error(error)} + onActiveDeviceChange={(deviceId) => + setAudioDeviceId(deviceId ?? '') + } + variant="tertiary" + /> + { + setVideoEnabled(enabled) + }} + onDeviceError={(error) => console.error(error)} + onActiveDeviceChange={(deviceId) => + setVideoDeviceId(deviceId ?? '') + } + variant="tertiary" + /> +
+
+ +
+
+ + {t('join.heading')} + + setUsername(value)} + validate={(value) => { + return !value ?

{t('join.errors.usernameEmpty')}

: null + }} + className={css({ + width: '100%', + })} + wrapperProps={{ + noMargin: true, + fullWidth: true, + }} + labelProps={{ + center: true, + }} + maxLength={50} + /> + + +
+
+
) } diff --git a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx index 3a45baac..84fbaffa 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx @@ -13,13 +13,14 @@ import { RiVideoOffLine, RiVideoOnLine, } from '@remixicon/react' -import { Track } from 'livekit-client' +import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client' import { Shortcut } from '@/features/shortcuts/types' import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx' import { css } from '@/styled-system/css' import { ButtonRecipeProps } from '@/primitives/buttonRecipe' +import { useEffect } from 'react' export type ToggleSource = Exclude< Track.Source, @@ -66,6 +67,8 @@ const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = { type SelectToggleDeviceProps = UseTrackToggleProps & { + track?: LocalAudioTrack | LocalVideoTrack | undefined + initialDeviceId?: string onActiveDeviceChange: (deviceId: string) => void source: SelectToggleSource variant?: NonNullable['variant'] @@ -74,10 +77,12 @@ type SelectToggleDeviceProps = } export const SelectToggleDevice = ({ + track, + initialDeviceId, onActiveDeviceChange, hideMenu, variant = 'primaryDark', - menuVariant = 'light', + menuVariant = 'light', ...props }: SelectToggleDeviceProps) => { const config = selectToggleDeviceConfig[props.source] @@ -88,7 +93,19 @@ export const SelectToggleDevice = ({ const trackProps = useTrackToggle(props) const { devices, activeDeviceId, setActiveMediaDevice } = - useMediaDeviceSelect({ kind: config.kind }) + useMediaDeviceSelect({ kind: config.kind, track }) + + /** + * When providing only track outside of a room context, activeDeviceId is undefined. + * So we need to initialize it with the initialDeviceId. + * nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id. + */ + useEffect(() => { + if (initialDeviceId !== undefined) { + setActiveMediaDevice(initialDeviceId) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setActiveMediaDevice]) const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` }) diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx index 0dab2b99..9cb1da10 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx @@ -62,7 +62,7 @@ export function DesktopControlBar({ onActiveDeviceChange={(deviceId) => saveAudioInputDeviceId(deviceId ?? '') } - variant="dark" + menuVariant="dark" /> saveVideoInputDeviceId(deviceId ?? '') } - variant="dark" + menuVariant="dark" /> {browserSupportsScreenSharing && (