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

View File

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

View File

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

View File

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