♻️(frontend) refactor in-room device selection with audio output control

Refactor device selection within rooms and add audio output selection
to audio controls as requested by users.

Ensures code reuse between join and room components by sharing device
selection logic across both contexts.
This commit is contained in:
lebaudantoine
2025-08-21 18:20:25 +02:00
committed by aleb_the_flash
parent 40cedba8ae
commit ebf676529f
4 changed files with 101 additions and 56 deletions

View File

@@ -20,6 +20,7 @@ import {
EffectsConfiguration, EffectsConfiguration,
EffectsConfigurationProps, EffectsConfigurationProps,
} from '../livekit/components/effects/EffectsConfiguration' } from '../livekit/components/effects/EffectsConfiguration'
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices' import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { isMobileBrowser } from '@livekit/components-core' import { isMobileBrowser } from '@livekit/components-core'
@@ -35,7 +36,6 @@ 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 { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId' import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
import { isSafari } from '@/utils/livekit' import { isSafari } from '@/utils/livekit'
import type { LocalUserChoices } from '@/stores/userChoices' import type { LocalUserChoices } from '@/stores/userChoices'

View File

@@ -1,12 +1,8 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
useMediaDeviceSelect, import { Button, Popover } from '@/primitives'
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine, RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiArrowUpSLine, RiMicLine, RiMicOffLine } from '@remixicon/react'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client' import { Track } from 'livekit-client'
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'
@@ -16,17 +12,16 @@ import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig' import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
import { SelectDevice } from './SelectDevice'
type AudioDevicesControlProps = Omit< type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>, UseTrackToggleProps<Source.Microphone>,
'source' | 'onChange' 'source' | 'onChange'
> & { > & {
track?: LocalAudioTrack | LocalVideoTrack
hideMenu?: boolean hideMenu?: boolean
} }
export const AudioDevicesControl = ({ export const AudioDevicesControl = ({
track,
hideMenu, hideMenu,
...props ...props
}: AudioDevicesControlProps) => { }: AudioDevicesControlProps) => {
@@ -44,8 +39,12 @@ export const AudioDevicesControl = ({
} }
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { saveAudioInputDeviceId, saveAudioInputEnabled } = const {
usePersistentUserChoices() userChoices: { audioDeviceId, audioOutputDeviceId },
saveAudioInputDeviceId,
saveAudioInputEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const onChange = React.useCallback( const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) => (enabled: boolean, isUserInitiated: boolean) =>
@@ -63,9 +62,6 @@ export const AudioDevicesControl = ({
const isPermissionDeniedOrPrompted = const isPermissionDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: 'audioinput', track })
const selectLabel = t('audioinput.choose') const selectLabel = t('audioinput.choose')
return ( return (
@@ -90,7 +86,7 @@ export const AudioDevicesControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Menu variant="dark"> <Popover variant="dark" withArrow={false}>
<Button <Button
isDisabled={isPermissionDeniedOrPrompted} isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel} tooltip={selectLabel}
@@ -105,19 +101,42 @@ export const AudioDevicesControl = ({
> >
<RiArrowUpSLine /> <RiArrowUpSLine />
</Button> </Button>
<MenuList <div
items={devices.map((d) => ({ className={css({
value: d.deviceId, maxWidth: '36rem',
label: d.label, padding: '0.15rem',
}))} display: 'flex',
selectedItem={activeDeviceId} gap: '0.5rem',
onAction={(value) => { })}
setActiveMediaDevice(value as string) >
saveAudioInputDeviceId(value as string) <div
}} style={{
variant="dark" flex: '1 1 0',
/> minWidth: 0,
</Menu> }}
>
<SelectDevice
context="room"
kind="audioinput"
id={audioDeviceId}
onSubmit={saveAudioInputDeviceId}
/>
</div>
<div
style={{
flex: '1 1 0',
minWidth: 0,
}}
>
<SelectDevice
context="room"
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
</div>
</Popover>
)} )}
</div> </div>
) )

View File

@@ -10,6 +10,7 @@ import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select' import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions' import { permissionsStore } from '@/stores/permissions'
import { Placement } from '@react-types/overlays'
type DeviceItems = Array<{ value: string; label: string }> type DeviceItems = Array<{ value: string; label: string }>
@@ -17,14 +18,21 @@ type DeviceConfig = {
icon: RemixiconComponentType icon: RemixiconComponentType
} }
type SelectDeviceContext = {
variant?: 'light' | 'dark'
placement?: Placement
}
type SelectDeviceProps = { type SelectDeviceProps = {
id?: string id?: string
onSubmit?: (id: string) => void onSubmit?: (id: string) => void
kind: MediaDeviceKind kind: MediaDeviceKind
context?: 'join' | 'room'
} }
type SelectDevicePermissionsProps = SelectDeviceProps & { type SelectDevicePermissionsProps = SelectDeviceProps & {
config: DeviceConfig config: DeviceConfig
contextProps: SelectDeviceContext
} }
const SelectDevicePermissions = ({ const SelectDevicePermissions = ({
@@ -32,6 +40,7 @@ const SelectDevicePermissions = ({
kind, kind,
config, config,
onSubmit, onSubmit,
contextProps,
}: SelectDevicePermissionsProps) => { }: SelectDevicePermissionsProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
@@ -78,15 +87,28 @@ const SelectDevicePermissions = ({
onSubmit?.(key as string) onSubmit?.(key as string)
setActiveMediaDevice(key as string) setActiveMediaDevice(key as string)
}} }}
{...contextProps}
/> />
) )
} }
export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => { export const SelectDevice = ({
id,
onSubmit,
kind,
context = 'join',
}: SelectDeviceProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'join' }) const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const permissions = useSnapshot(permissionsStore) const permissions = useSnapshot(permissionsStore)
const contextProps = useMemo<SelectDeviceContext>(() => {
if (context == 'room') {
return { variant: 'dark', placement: 'top' }
}
return {}
}, [context])
const config = useMemo<DeviceConfig | undefined>(() => { const config = useMemo<DeviceConfig | undefined>(() => {
switch (kind) { switch (kind) {
case 'audioinput': case 'audioinput':
@@ -128,6 +150,7 @@ export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
items={[]} items={[]}
iconComponent={config?.icon} iconComponent={config?.icon}
placeholder={t('selectDevice.permissionsNeeded')} placeholder={t('selectDevice.permissionsNeeded')}
{...contextProps}
/> />
) )
} }
@@ -138,6 +161,7 @@ export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
onSubmit={onSubmit} onSubmit={onSubmit}
kind={kind} kind={kind}
config={config} config={config}
contextProps={contextProps}
/> />
) )
} }

View File

@@ -1,12 +1,8 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
useMediaDeviceSelect, import { Button, Popover } from '@/primitives'
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine, RiVideoOffLine, RiVideoOnLine } from '@remixicon/react' import { RiArrowUpSLine, RiVideoOffLine, RiVideoOnLine } from '@remixicon/react'
import { LocalVideoTrack, Track, VideoCaptureOptions } from 'livekit-client' import { Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice' import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
@@ -17,17 +13,16 @@ import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig' import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source import Source = Track.Source
import * as React from 'react' import * as React from 'react'
import { SelectDevice } from './SelectDevice'
type VideoDeviceControlProps = Omit< type VideoDeviceControlProps = Omit<
UseTrackToggleProps<Source.Camera>, UseTrackToggleProps<Source.Camera>,
'source' | 'onChange' 'source' | 'onChange'
> & { > & {
track?: LocalVideoTrack
hideMenu?: boolean hideMenu?: boolean
} }
export const VideoDeviceControl = ({ export const VideoDeviceControl = ({
track,
hideMenu, hideMenu,
...props ...props
}: VideoDeviceControlProps) => { }: VideoDeviceControlProps) => {
@@ -90,9 +85,6 @@ export const VideoDeviceControl = ({
} as VideoCaptureOptions) } as VideoCaptureOptions)
} }
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: 'videoinput', track })
const selectLabel = t('videoinput.choose') const selectLabel = t('videoinput.choose')
return ( return (
@@ -117,7 +109,7 @@ export const VideoDeviceControl = ({
}} }}
/> />
{!hideMenu && ( {!hideMenu && (
<Menu variant="dark"> <Popover variant="dark" withArrow={false}>
<Button <Button
isDisabled={isPermissionDeniedOrPrompted} isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel} tooltip={selectLabel}
@@ -132,19 +124,29 @@ export const VideoDeviceControl = ({
> >
<RiArrowUpSLine /> <RiArrowUpSLine />
</Button> </Button>
<MenuList <div
items={devices.map((d) => ({ className={css({
value: d.deviceId, maxWidth: '36rem',
label: d.label, padding: '0.15rem',
}))} display: 'flex',
selectedItem={activeDeviceId} gap: '0.5rem',
onAction={(value) => { })}
setActiveMediaDevice(value as string) >
saveVideoInputDeviceId(value as string) <div
}} style={{
variant="dark" flex: '1 1 0',
/> minWidth: 0,
</Menu> }}
>
<SelectDevice
context="room"
kind="videoinput"
id={userChoices.videoDeviceId}
onSubmit={saveVideoInputDeviceId}
/>
</div>
</div>
</Popover>
)} )}
</div> </div>
) )