✨(front) revamp join screen
We want this screen to have a better ux, the join button was invisible on some small screen sizes, also we want to align the style of this screen with the ui of the video conference previously made.
This commit is contained in:
@@ -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<LocalUserChoices> = { 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<LocalUserChoices> = {
|
||||
...(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<boolean>(
|
||||
initialUserChoices.audioEnabled
|
||||
)
|
||||
const [videoEnabled, setVideoEnabled] = React.useState<boolean>(
|
||||
initialUserChoices.videoEnabled
|
||||
)
|
||||
const [audioDeviceId, setAudioDeviceId] = React.useState<string>(
|
||||
initialUserChoices.audioDeviceId
|
||||
)
|
||||
const [videoDeviceId, setVideoDeviceId] = React.useState<string>(
|
||||
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<boolean>()
|
||||
|
||||
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 (
|
||||
<Screen layout="centered" footer={false}>
|
||||
<CenteredContent title={t('join.heading')}>
|
||||
<PreJoin
|
||||
persistUserChoices
|
||||
onSubmit={onSubmit}
|
||||
micLabel={t('join.audioinput.label')}
|
||||
camLabel={t('join.videoinput.label')}
|
||||
joinLabel={t('join.joinLabel')}
|
||||
userLabel={t('join.usernameLabel')}
|
||||
defaults={{ username: user?.full_name }}
|
||||
/>
|
||||
</CenteredContent>
|
||||
<Screen footer={false}>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
flexGrow: 1,
|
||||
lg: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
height: 'auto',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '2rem',
|
||||
padding: '0 2rem',
|
||||
flexDirection: 'column',
|
||||
minWidth: 0,
|
||||
width: '100%',
|
||||
lg: {
|
||||
flexDirection: 'row',
|
||||
width: 'auto',
|
||||
height: '570px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
lg: {
|
||||
width: '740px',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
borderRadius: '1rem',
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
aspectRatio: 16 / 9,
|
||||
'--tw-shadow':
|
||||
'0 20px 25px -5px #0000001a, 0 8px 10px -6px #0000001a',
|
||||
'--tw-shadow-colored':
|
||||
'0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
||||
boxShadow:
|
||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
||||
})}
|
||||
>
|
||||
{videoTrack && (
|
||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||
<video
|
||||
ref={videoEl}
|
||||
width="1280"
|
||||
height="720"
|
||||
data-lk-facing-mode={facingMode}
|
||||
className={css({
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
transform: 'rotateY(180deg)',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{(!videoTrack || !videoEnabled) && (
|
||||
<div
|
||||
id="container"
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#000',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
})}
|
||||
>
|
||||
<ParticipantPlaceholder
|
||||
className={css({
|
||||
maxWidth: '100%',
|
||||
height: '70%',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="lk-button-group-container"></div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '1.5rem',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Microphone}
|
||||
initialState={audioEnabled}
|
||||
track={audioTrack}
|
||||
initialDeviceId={audioDeviceId}
|
||||
onChange={(enabled) => setAudioEnabled(enabled)}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setAudioDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
initialState={videoEnabled}
|
||||
track={videoTrack}
|
||||
initialDeviceId={videoDeviceId}
|
||||
onChange={(enabled) => {
|
||||
setVideoEnabled(enabled)
|
||||
}}
|
||||
onDeviceError={(error) => console.error(error)}
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
setVideoDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
flexShrink: 0,
|
||||
padding: '0',
|
||||
sm: {
|
||||
width: '448px',
|
||||
padding: '0 3rem 9rem 3rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<form
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '1rem',
|
||||
})}
|
||||
>
|
||||
<H lvl={1} className={css({ marginBottom: 0 })}>
|
||||
{t('join.heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
label={userLabel}
|
||||
defaultValue={username}
|
||||
onChange={(value) => setUsername(value)}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('join.errors.usernameEmpty')}</p> : null
|
||||
}}
|
||||
className={css({
|
||||
width: '100%',
|
||||
})}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
labelProps={{
|
||||
center: true,
|
||||
}}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant={'primary'}
|
||||
onPress={handleSubmit}
|
||||
isDisabled={!isValid}
|
||||
fullWidth
|
||||
>
|
||||
{joinLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Screen>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<T extends ToggleSource> =
|
||||
UseTrackToggleProps<T> & {
|
||||
track?: LocalAudioTrack | LocalVideoTrack | undefined
|
||||
initialDeviceId?: string
|
||||
onActiveDeviceChange: (deviceId: string) => void
|
||||
source: SelectToggleSource
|
||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||
@@ -74,10 +77,12 @@ type SelectToggleDeviceProps<T extends ToggleSource> =
|
||||
}
|
||||
|
||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
track,
|
||||
initialDeviceId,
|
||||
onActiveDeviceChange,
|
||||
hideMenu,
|
||||
variant = 'primaryDark',
|
||||
menuVariant = 'light',
|
||||
menuVariant = 'light',
|
||||
...props
|
||||
}: SelectToggleDeviceProps<T>) => {
|
||||
const config = selectToggleDeviceConfig[props.source]
|
||||
@@ -88,7 +93,19 @@ export const SelectToggleDevice = <T extends ToggleSource>({
|
||||
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}` })
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveAudioInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="dark"
|
||||
menuVariant="dark"
|
||||
/>
|
||||
<SelectToggleDevice
|
||||
source={Track.Source.Camera}
|
||||
@@ -73,7 +73,7 @@ export function DesktopControlBar({
|
||||
onActiveDeviceChange={(deviceId) =>
|
||||
saveVideoInputDeviceId(deviceId ?? '')
|
||||
}
|
||||
variant="dark"
|
||||
menuVariant="dark"
|
||||
/>
|
||||
{browserSupportsScreenSharing && (
|
||||
<ScreenShareToggle
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
"toggleOff": "",
|
||||
"toggleOn": "",
|
||||
"usernameHint": "",
|
||||
"usernameLabel": ""
|
||||
"usernameLabel": "",
|
||||
"errors": {
|
||||
"usernameEmpty": ""
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -18,13 +18,16 @@
|
||||
"enable": "Enable microphone",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Verify your settings",
|
||||
"heading": "Join the meeting",
|
||||
"joinLabel": "Join",
|
||||
"joinMeeting": "Join meeting",
|
||||
"toggleOff": "Click to turn off",
|
||||
"toggleOn": "Click to turn on",
|
||||
"usernameHint": "Shown to other participants",
|
||||
"usernameLabel": "Your name"
|
||||
"usernameLabel": "Your name",
|
||||
"errors": {
|
||||
"usernameEmpty": "Your name cannot be empty"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -18,13 +18,16 @@
|
||||
"enable": "Activer le micro",
|
||||
"label": "Microphone"
|
||||
},
|
||||
"heading": "Vérifiez vos paramètres",
|
||||
"heading": "Rejoindre la réunion",
|
||||
"joinLabel": "Rejoindre",
|
||||
"joinMeeting": "Rejoindre la réjoindre",
|
||||
"toggleOff": "Cliquez pour désactiver",
|
||||
"toggleOn": "Cliquez pour activer",
|
||||
"usernameHint": "Affiché aux autres participants",
|
||||
"usernameLabel": "Votre nom"
|
||||
"usernameLabel": "Votre nom",
|
||||
"errors": {
|
||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
"shareDialog": {
|
||||
|
||||
@@ -42,7 +42,7 @@ const FieldWrapper = styled('div', {
|
||||
const StyledLabel = styled(Label, {
|
||||
base: {
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontSize: '0.75rem',
|
||||
},
|
||||
variants: {
|
||||
center: {
|
||||
|
||||
Reference in New Issue
Block a user