♻️(frontend) decouple audio/video controls for reorganization clarity

Temporary state separating audio and video controls to improve clarity
and prepare for device selection/toggle component reorganization.

Work in progress to better structure device-related components before
implementing final unified control architecture.
This commit is contained in:
lebaudantoine
2025-08-21 17:54:26 +02:00
committed by aleb_the_flash
parent 59e0643dde
commit 40cedba8ae
6 changed files with 288 additions and 256 deletions

View File

@@ -0,0 +1,124 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine, RiMicLine, RiMicOffLine } from '@remixicon/react'
import { LocalAudioTrack, LocalVideoTrack, Track } from 'livekit-client'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source
import * as React from 'react'
type AudioDevicesControlProps = Omit<
UseTrackToggleProps<Source.Microphone>,
'source' | 'onChange'
> & {
track?: LocalAudioTrack | LocalVideoTrack
hideMenu?: boolean
}
export const AudioDevicesControl = ({
track,
hideMenu,
...props
}: AudioDevicesControlProps) => {
const config: ToggleDeviceConfig = {
kind: 'audioinput',
iconOn: RiMicLine,
iconOff: RiMicOffLine,
shortcut: {
key: 'd',
ctrlKey: true,
},
longPress: {
key: 'Space',
},
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { saveAudioInputDeviceId, saveAudioInputEnabled } =
usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Microphone,
onChange,
...props,
})
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted =
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: 'audioinput', track })
const selectLabel = t('audioinput.choose')
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant="primaryDark"
toggle={trackProps.toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant="dark">
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? 'primaryDark'
: 'error2'
}
>
<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>
)
}

View File

@@ -0,0 +1,151 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine, RiVideoOffLine, RiVideoOnLine } from '@remixicon/react'
import { LocalVideoTrack, Track, VideoCaptureOptions } from 'livekit-client'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice'
import { css } from '@/styled-system/css'
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import { ToggleDeviceConfig } from '../../../config/ToggleDeviceConfig'
import Source = Track.Source
import * as React from 'react'
type VideoDeviceControlProps = Omit<
UseTrackToggleProps<Source.Camera>,
'source' | 'onChange'
> & {
track?: LocalVideoTrack
hideMenu?: boolean
}
export const VideoDeviceControl = ({
track,
hideMenu,
...props
}: VideoDeviceControlProps) => {
const config: ToggleDeviceConfig = {
kind: 'videoinput',
iconOn: RiVideoOnLine,
iconOff: RiVideoOffLine,
shortcut: {
key: 'e',
ctrlKey: true,
},
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
usePersistentUserChoices()
const onChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const trackProps = useTrackToggle({
source: Source.Camera,
onChange,
...props,
})
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted =
permissions.isCameraDenied || permissions.isCameraPrompted
const toggle = () => {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
}
const { devices, activeDeviceId, setActiveMediaDevice } =
useMediaDeviceSelect({ kind: 'videoinput', track })
const selectLabel = t('videoinput.choose')
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant="primaryDark"
toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant="dark">
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? 'primaryDark'
: 'error2'
}
>
<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>
)
}

View File

@@ -1,171 +0,0 @@
import { useTranslation } from 'react-i18next'
import {
useMediaDeviceSelect,
useTrackToggle,
UseTrackToggleProps,
} from '@livekit/components-react'
import { Button, Menu, MenuList } from '@/primitives'
import { RiArrowUpSLine } from '@remixicon/react'
import {
LocalAudioTrack,
LocalVideoTrack,
Track,
VideoCaptureOptions,
} from 'livekit-client'
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
import { css } from '@/styled-system/css'
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
import { useEffect, useMemo } from 'react'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
import { BackgroundProcessorFactory } from '../blur'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
import {
TOGGLE_DEVICE_CONFIG,
ToggleSource,
} from '../../config/ToggleDeviceConfig'
type SelectToggleDeviceProps<T extends ToggleSource> =
UseTrackToggleProps<T> & {
track?: LocalAudioTrack | LocalVideoTrack
initialDeviceId?: string
onActiveDeviceChange: (deviceId: string) => void
source: ToggleSource
variant?: NonNullable<ButtonRecipeProps>['variant']
menuVariant?: 'dark' | 'light'
hideMenu?: boolean
}
export const SelectToggleDevice = <T extends ToggleSource>({
track,
initialDeviceId,
onActiveDeviceChange,
hideMenu,
variant = 'primaryDark',
menuVariant = 'light',
...props
}: SelectToggleDeviceProps<T>) => {
const config = TOGGLE_DEVICE_CONFIG[props.source]
if (!config) {
throw new Error('Invalid source')
}
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
const trackProps = useTrackToggle(props)
const { userChoices } = usePersistentUserChoices()
const permissions = useSnapshot(permissionsStore)
const isPermissionDeniedOrPrompted = useMemo(() => {
switch (config.kind) {
case 'audioinput':
return (
permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
)
case 'videoinput':
return permissions.isCameraDenied || permissions.isCameraPrompted
}
}, [permissions, config.kind])
const toggle = () => {
if (props.source === Track.Source.Camera) {
/**
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
* Before, we had the following bug:
* 1 - Configure a processor on join screen
* 2 - Turn off camera on join screen
* 3 - Join the room
* 4 - Turn on the camera
* 5 - No processor is applied to the camera
* Expected: The processor is applied.
*
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
*/
const processor = BackgroundProcessorFactory.deserializeProcessor(
userChoices.processorSerialized
)
const toggle = trackProps.toggle as (
forceState: boolean,
captureOptions: VideoCaptureOptions
) => Promise<void>
toggle(!trackProps.enabled, {
processor: processor,
} as VideoCaptureOptions)
} else {
trackProps.toggle()
}
}
const { devices, activeDeviceId, setActiveMediaDevice } =
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}` })
return (
<div
className={css({
display: 'flex',
gap: '1px',
})}
>
<ToggleDevice
{...trackProps}
config={config}
variant={variant}
toggle={toggle}
isPermissionDeniedOrPrompted={isPermissionDeniedOrPrompted}
toggleButtonProps={{
...(hideMenu
? {
groupPosition: undefined,
}
: {}),
}}
/>
{!hideMenu && (
<Menu variant={menuVariant}>
<Button
isDisabled={isPermissionDeniedOrPrompted}
tooltip={selectLabel}
aria-label={selectLabel}
groupPosition="right"
square
variant={
trackProps.enabled && !isPermissionDeniedOrPrompted
? variant
: 'error2'
}
>
<RiArrowUpSLine />
</Button>
<MenuList
items={devices.map((d) => ({
value: d.deviceId,
label: d.label,
}))}
selectedItem={activeDeviceId}
onAction={(value) => {
setActiveMediaDevice(value as string)
onActiveDeviceChange(value as string)
}}
variant={menuVariant}
/>
</Menu>
)}
</div>
)
}

View File

@@ -5,7 +5,6 @@ import { MobileControlBar } from './MobileControlBar'
import { DesktopControlBar } from './DesktopControlBar'
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
import { useIsMobile } from '@/utils/useIsMobile'
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
/** @public */
export type ControlBarControls = {
@@ -48,53 +47,16 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
* @public
*/
export function ControlBar({ onDeviceError }: ControlBarProps) {
const {
saveAudioInputEnabled,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
} = usePersistentUserChoices()
const microphoneOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
[saveAudioInputEnabled]
)
const cameraOnChange = React.useCallback(
(enabled: boolean, isUserInitiated: boolean) =>
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
[saveVideoInputEnabled]
)
const barProps = {
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}
const isMobile = useIsMobile()
return (
<SettingsDialogProvider>
{isMobile ? (
<MobileControlBar {...barProps} />
<MobileControlBar onDeviceError={onDeviceError} />
) : (
<DesktopControlBar {...barProps} />
<DesktopControlBar onDeviceError={onDeviceError} />
)}
</SettingsDialogProvider>
)
}
export interface ControlBarAuxProps {
onDeviceError: ControlBarProps['onDeviceError']
microphoneOnChange: (
enabled: boolean,
isUserInitiated: boolean
) => void | null
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
saveAudioInputDeviceId: (deviceId: string) => void
saveVideoInputDeviceId: (deviceId: string) => void
}
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>

View File

@@ -2,7 +2,6 @@ import { supportsScreenSharing } from '@livekit/components-core'
import { ControlBarAuxProps } from './ControlBar'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
@@ -11,14 +10,10 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
export function DesktopControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
export function DesktopControlBar({ onDeviceError }: ControlBarAuxProps) {
const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
return (
@@ -53,27 +48,15 @@ export function DesktopControlBar({
gap: '0.65rem',
})}
>
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (

View File

@@ -4,7 +4,6 @@ import { ControlBarAuxProps } from './ControlBar'
import React from 'react'
import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { HandToggle } from '../../components/controls/HandToggle'
import { Button } from '@/primitives/Button'
@@ -24,14 +23,10 @@ import { ResponsiveMenu } from './ResponsiveMenu'
import { ToolsToggle } from '../../components/controls/ToolsToggle'
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
import { useConfig } from '@/api/useConfig'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
export function MobileControlBar({
onDeviceError,
microphoneOnChange,
cameraOnChange,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
}: ControlBarAuxProps) {
export function MobileControlBar({ onDeviceError }: ControlBarAuxProps) {
const { t } = useTranslation('rooms')
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
const browserSupportsScreenSharing = supportsScreenSharing()
@@ -62,27 +57,15 @@ export function MobileControlBar({
})}
>
<LeaveButton />
<SelectToggleDevice
source={Track.Source.Microphone}
onChange={microphoneOnChange}
<AudioDevicesControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Microphone, error })
}
onActiveDeviceChange={(deviceId) =>
saveAudioInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<SelectToggleDevice
source={Track.Source.Camera}
onChange={cameraOnChange}
<VideoDeviceControl
onDeviceError={(error) =>
onDeviceError?.({ source: Track.Source.Camera, error })
}
onActiveDeviceChange={(deviceId) =>
saveVideoInputDeviceId(deviceId ?? '')
}
hideMenu={true}
/>
<HandToggle />
<Button