♻️(frontend) refactor prejoin screen for room context flexibility

Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
This commit is contained in:
lebaudantoine
2025-08-11 10:17:39 +02:00
committed by aleb_the_flash
parent bd139a1ef9
commit e4d5ca64b9
11 changed files with 251 additions and 83 deletions

View File

@@ -5,7 +5,6 @@ import { Screen } from '@/layout/Screen'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { LocalVideoTrack, Track } from 'livekit-client' import { LocalVideoTrack, Track } from 'livekit-client'
import { H } from '@/primitives/H' import { H } from '@/primitives/H'
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
import { Field } from '@/primitives/Field' import { Field } from '@/primitives/Field'
import { Button, Dialog, Text, Form } from '@/primitives' import { Button, Dialog, Text, Form } from '@/primitives'
import { VStack } from '@/styled-system/jsx' import { VStack } from '@/styled-system/jsx'
@@ -29,6 +28,8 @@ import { ApiAccessLevel } from '../api/ApiRoom'
import { useLoginHint } from '@/hooks/useLoginHint' import { useLoginHint } from '@/hooks/useLoginHint'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { openPermissionsDialog, permissionsStore } from '@/stores/permissions' import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
const onError = (e: Error) => console.error('ERROR', e) const onError = (e: Error) => console.error('ERROR', e)
@@ -502,6 +503,33 @@ export const Join = ({
)} )}
</div> </div>
</div> </div>
<div
className={css({
position: 'absolute',
bottom: '1rem',
zIndex: '1',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
left: '50%',
transform: 'translateX(-50%)',
})}
>
<ToggleDevice
source={Track.Source.Microphone}
initialState={audioEnabled}
track={audioTrack}
onChange={(enabled) => saveAudioInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
<ToggleDevice
source={Track.Source.Camera}
initialState={videoEnabled}
track={videoTrack}
onChange={(enabled) => saveVideoInputEnabled(enabled)}
onDeviceError={(error) => console.error(error)}
/>
</div>
<div <div
className={css({ className={css({
position: 'absolute', position: 'absolute',
@@ -528,32 +556,26 @@ export const Join = ({
marginX: 'auto', marginX: 'auto',
})} })}
> >
<div> <div
<SelectToggleDevice className={css({
source={Track.Source.Microphone} width: '50%',
initialState={audioEnabled} })}
track={audioTrack} >
initialDeviceId={audioDeviceId} <SelectDevice
onChange={(enabled) => saveAudioInputEnabled(enabled)} kind="audioinput"
onDeviceError={(error) => console.error(error)} id={audioDeviceId}
onActiveDeviceChange={(deviceId) => onSubmit={saveAudioInputDeviceId}
saveAudioInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/> />
</div> </div>
<div> <div
<SelectToggleDevice className={css({
source={Track.Source.Camera} width: '50%',
initialState={videoEnabled} })}
track={videoTrack} >
initialDeviceId={videoDeviceId} <SelectDevice
onChange={(enabled) => saveVideoInputEnabled(enabled)} kind="videoinput"
onDeviceError={(error) => console.error(error)} id={videoDeviceId}
onActiveDeviceChange={(deviceId) => onSubmit={saveVideoInputDeviceId}
saveVideoInputDeviceId(deviceId ?? '')
}
variant="tertiary"
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,74 @@
import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useMemo } from 'react'
import { Select } from '@/primitives/Select'
type DeviceItems = Array<{ value: string; label: string }>
type DeviceConfig = {
icon: RemixiconComponentType
}
type SelectDeviceProps = {
id?: string
onSubmit?: (id: string) => void
kind: MediaDeviceKind
}
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) {
case 'audioinput':
return {
icon: RiMicLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
}
}
}, [kind])
const getDefaultSelectedKey = (items: DeviceItems) => {
if (!items || items.length === 0) return
const defaultItem =
items.find((item) => item.value === 'default') || items[0]
return defaultItem.value
}
const {
devices: devices,
activeDeviceId: activeDeviceId,
setActiveMediaDevice: setActiveMediaDevice,
} = useMediaDeviceSelect({ kind, requestPermissions: false })
const items: DeviceItems = devices
.filter((d) => !!d.deviceId)
.map((d) => ({
value: d.deviceId,
label: d.label,
}))
return (
<Select
aria-label={t(`${kind}.choose`)}
label=""
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={t('selectDevice.loading')}
defaultSelectedKey={id || activeDeviceId || getDefaultSelectedKey(items)}
onSelectionChange={(key) => {
onSubmit?.(key as string)
setActiveMediaDevice(key as string)
}}
/>
)
}

View File

@@ -0,0 +1,38 @@
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
import { ToggleDevice as BaseToggleDevice } from '../../livekit/components/controls/ToggleDevice'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../livekit/config/ToggleDeviceConfig'
import { LocalAudioTrack, LocalVideoTrack } from 'livekit-client'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
type ToggleDeviceProps<T extends ToggleSource> = UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
}
export const ToggleDevice = <T extends ToggleSource>(
props: ToggleDeviceProps<T>
) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const trackProps = useTrackToggle(props)
return (
<BaseToggleDevice
{...trackProps}
config={config}
variant="whiteCircle"
errorVariant="errorCircle"
toggleButtonProps={{
groupPosition: undefined,
}}
/>
)
}

View File

@@ -5,14 +5,7 @@ import {
UseTrackToggleProps, UseTrackToggleProps,
} from '@livekit/components-react' } from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives' import { Button, Menu, MenuList } from '@/primitives'
import { import { RiArrowDownSLine } from '@remixicon/react'
RemixiconComponentType,
RiArrowDownSLine,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { import {
LocalAudioTrack, LocalAudioTrack,
LocalVideoTrack, LocalVideoTrack,
@@ -20,8 +13,6 @@ import {
VideoCaptureOptions, VideoCaptureOptions,
} from 'livekit-client' } from 'livekit-client'
import { Shortcut } from '@/features/shortcuts/types'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx' import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
@@ -30,56 +21,17 @@ import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur' import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions' import { permissionsStore } from '@/stores/permissions'
import {
export type ToggleSource = Exclude< TOGGLE_DEVICE_CONFIG,
Track.Source, ToggleSource,
Track.Source.ScreenShareAudio | Track.Source.Unknown } from '../../config/ToggleDeviceConfig'
>
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
export type SelectToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type SelectToggleDeviceConfigMap = {
[key in SelectToggleSource]: SelectToggleDeviceConfig
}
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
}
type SelectToggleDeviceProps<T extends ToggleSource> = type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & { UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack | undefined track?: LocalAudioTrack | LocalVideoTrack | undefined
initialDeviceId?: string initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void onActiveDeviceChange: (deviceId: string) => void
source: SelectToggleSource source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant'] variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light' menuVariant?: 'dark' | 'light'
hideMenu?: boolean hideMenu?: boolean
@@ -94,7 +46,7 @@ export const SelectToggleDevice = <T extends ToggleSource>({
menuVariant = 'light', menuVariant = 'light',
...props ...props
}: SelectToggleDeviceProps<T>) => { }: SelectToggleDeviceProps<T>) => {
const config = selectToggleDeviceConfig[props.source] const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) { if (!config) {
throw new Error('Invalid source') throw new Error('Invalid source')
} }

View File

@@ -3,7 +3,6 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { appendShortcutLabel } from '@/features/shortcuts/utils' import { appendShortcutLabel } from '@/features/shortcuts/utils'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
import { PermissionNeededButton } from './PermissionNeededButton' import { PermissionNeededButton } from './PermissionNeededButton'
import useLongPress from '@/features/shortcuts/useLongPress' import useLongPress from '@/features/shortcuts/useLongPress'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker' import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
@@ -15,13 +14,15 @@ import {
import { ButtonRecipeProps } from '@/primitives/buttonRecipe' import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { ToggleButtonProps } from '@/primitives/ToggleButton' import { ToggleButtonProps } from '@/primitives/ToggleButton'
import { openPermissionsDialog } from '@/stores/permissions' import { openPermissionsDialog } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../config/ToggleDeviceConfig'
export type ToggleDeviceProps = { export type ToggleDeviceProps = {
enabled: boolean enabled: boolean
isPermissionDeniedOrPrompted?: boolean isPermissionDeniedOrPrompted?: boolean
toggle: () => void toggle: () => void
config: SelectToggleDeviceConfig config: ToggleDeviceConfig
variant?: NonNullable<ButtonRecipeProps>['variant'] variant?: NonNullable<ButtonRecipeProps>['variant']
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
toggleButtonProps?: Partial<ToggleButtonProps> toggleButtonProps?: Partial<ToggleButtonProps>
} }
@@ -30,6 +31,7 @@ export const ToggleDevice = ({
enabled, enabled,
toggle, toggle,
variant = 'primaryDark', variant = 'primaryDark',
errorVariant = 'error2',
toggleButtonProps, toggleButtonProps,
isPermissionDeniedOrPrompted, isPermissionDeniedOrPrompted,
}: ToggleDeviceProps) => { }: ToggleDeviceProps) => {
@@ -72,7 +74,9 @@ export const ToggleDevice = ({
{isPermissionDeniedOrPrompted && <PermissionNeededButton />} {isPermissionDeniedOrPrompted && <PermissionNeededButton />}
<ToggleButton <ToggleButton
isSelected={!enabled} isSelected={!enabled}
variant={enabled && !isPermissionDeniedOrPrompted ? variant : 'error2'} variant={
enabled && !isPermissionDeniedOrPrompted ? variant : errorVariant
}
shySelected shySelected
onPress={() => onPress={() =>
isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle() isPermissionDeniedOrPrompted ? openPermissionsDialog() : toggle()

View File

@@ -0,0 +1,52 @@
import { Track } from 'livekit-client'
import {
RemixiconComponentType,
RiMicLine,
RiMicOffLine,
RiVideoOffLine,
RiVideoOnLine,
} from '@remixicon/react'
import { Shortcut } from '@/features/shortcuts/types'
export type ToggleSource = Exclude<
Track.Source,
| Track.Source.ScreenShareAudio
| Track.Source.Unknown
| Track.Source.ScreenShare
>
export type ToggleDeviceConfig = {
kind: MediaDeviceKind
iconOn: RemixiconComponentType
iconOff: RemixiconComponentType
shortcut?: Shortcut
longPress?: Shortcut
}
type ToggleDeviceConfigMap = {
[key in ToggleSource]: ToggleDeviceConfig
}
export const TOGGLE_DEVICE_CONFIG = {
[Track.Source.Microphone]: {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
},
[Track.Source.Camera]: {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
},
} as const satisfies ToggleDeviceConfigMap

View File

@@ -8,6 +8,9 @@
"back": "Dem Meeting erneut beitreten" "back": "Dem Meeting erneut beitreten"
}, },
"join": { "join": {
"selectDevice": {
"loading": "Laden…"
},
"videoinput": { "videoinput": {
"choose": "Kamera auswählen", "choose": "Kamera auswählen",
"disable": "Kamera deaktivieren", "disable": "Kamera deaktivieren",

View File

@@ -8,6 +8,9 @@
"back": "Rejoin the meeting" "back": "Rejoin the meeting"
}, },
"join": { "join": {
"selectDevice": {
"loading": "Loading…"
},
"videoinput": { "videoinput": {
"choose": "Select camera", "choose": "Select camera",
"disable": "Disable camera", "disable": "Disable camera",

View File

@@ -8,6 +8,9 @@
"back": "Réintégrer la réunion" "back": "Réintégrer la réunion"
}, },
"join": { "join": {
"selectDevice": {
"loading": "Chargement…"
},
"videoinput": { "videoinput": {
"choose": "Choisir la webcam", "choose": "Choisir la webcam",
"disable": "Désactiver la webcam", "disable": "Désactiver la webcam",

View File

@@ -8,6 +8,9 @@
"back": "Sluit weer bij de vergadering aan" "back": "Sluit weer bij de vergadering aan"
}, },
"join": { "join": {
"selectDevice": {
"loading": "Bezig met laden…"
},
"videoinput": { "videoinput": {
"choose": "Selecteer camera", "choose": "Selecteer camera",
"disable": "Camera uitschakelen", "disable": "Camera uitschakelen",

View File

@@ -251,6 +251,20 @@ export const buttonRecipe = cva({
color: 'error.300', color: 'error.300',
}, },
}, },
errorCircle: {
backgroundColor: 'error.500',
width: '56px',
height: '56px',
borderRadius: '100%',
color: 'white',
'&[data-hovered]': {
backgroundColor: 'error.600',
},
'&[data-pressed]': {
backgroundColor: 'error.700',
color: 'error.200',
},
},
// @TODO: better handling of colors… this is a mess // @TODO: better handling of colors… this is a mess
success: { success: {
colorPalette: 'success', colorPalette: 'success',