From 40cedba8ae06383e86ebc05982314fdaac0ea7db Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Thu, 21 Aug 2025 17:54:26 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20decouple=20audio?= =?UTF-8?q?/video=20controls=20for=20reorganization=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../controls/Device/AudioDevicesControl.tsx | 124 +++++++++++++ .../controls/Device/VideoDeviceControl.tsx | 151 ++++++++++++++++ .../controls/SelectToggleDevice.tsx | 171 ------------------ .../livekit/prefabs/ControlBar/ControlBar.tsx | 44 +---- .../prefabs/ControlBar/DesktopControlBar.tsx | 27 +-- .../prefabs/ControlBar/MobileControlBar.tsx | 27 +-- 6 files changed, 288 insertions(+), 256 deletions(-) create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx create mode 100644 src/frontend/src/features/rooms/livekit/components/controls/Device/VideoDeviceControl.tsx delete mode 100644 src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx new file mode 100644 index 00000000..44e1fee9 --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/AudioDevicesControl.tsx @@ -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' | '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 ( +
+ + {!hideMenu && ( + + + ({ + value: d.deviceId, + label: d.label, + }))} + selectedItem={activeDeviceId} + onAction={(value) => { + setActiveMediaDevice(value as string) + saveAudioInputDeviceId(value as string) + }} + variant="dark" + /> + + )} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/VideoDeviceControl.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/VideoDeviceControl.tsx new file mode 100644 index 00000000..2d95265e --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/VideoDeviceControl.tsx @@ -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' | '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 + + toggle(!trackProps.enabled, { + processor: processor, + } as VideoCaptureOptions) + } + + const { devices, activeDeviceId, setActiveMediaDevice } = + useMediaDeviceSelect({ kind: 'videoinput', track }) + + const selectLabel = t('videoinput.choose') + + return ( +
+ + {!hideMenu && ( + + + ({ + value: d.deviceId, + label: d.label, + }))} + selectedItem={activeDeviceId} + onAction={(value) => { + setActiveMediaDevice(value as string) + saveVideoInputDeviceId(value as string) + }} + variant="dark" + /> + + )} +
+ ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx deleted file mode 100644 index d805edd7..00000000 --- a/src/frontend/src/features/rooms/livekit/components/controls/SelectToggleDevice.tsx +++ /dev/null @@ -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 = - UseTrackToggleProps & { - track?: LocalAudioTrack | LocalVideoTrack - initialDeviceId?: string - onActiveDeviceChange: (deviceId: string) => void - source: ToggleSource - variant?: NonNullable['variant'] - menuVariant?: 'dark' | 'light' - hideMenu?: boolean - } - -export const SelectToggleDevice = ({ - track, - initialDeviceId, - onActiveDeviceChange, - hideMenu, - variant = 'primaryDark', - menuVariant = 'light', - ...props -}: SelectToggleDeviceProps) => { - 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 - - 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 ( -
- - {!hideMenu && ( - - - ({ - value: d.deviceId, - label: d.label, - }))} - selectedItem={activeDeviceId} - onAction={(value) => { - setActiveMediaDevice(value as string) - onActiveDeviceChange(value as string) - }} - variant={menuVariant} - /> - - )} -
- ) -} diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx index 018d8e2f..f9bee237 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx @@ -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 { * @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 ( {isMobile ? ( - + ) : ( - + )} ) } -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 diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx index 1874074b..52f8062f 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx @@ -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(null) return ( @@ -53,27 +48,15 @@ export function DesktopControlBar({ gap: '0.65rem', })} > - onDeviceError?.({ source: Track.Source.Microphone, error }) } - onActiveDeviceChange={(deviceId) => - saveAudioInputDeviceId(deviceId ?? '') - } - menuVariant="dark" /> - onDeviceError?.({ source: Track.Source.Camera, error }) } - onActiveDeviceChange={(deviceId) => - saveVideoInputDeviceId(deviceId ?? '') - } - menuVariant="dark" /> {browserSupportsScreenSharing && ( diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx index 33e60c93..abc82efa 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/MobileControlBar.tsx @@ -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({ })} > - onDeviceError?.({ source: Track.Source.Microphone, error }) } - onActiveDeviceChange={(deviceId) => - saveAudioInputDeviceId(deviceId ?? '') - } - hideMenu={true} /> - onDeviceError?.({ source: Track.Source.Camera, error }) } - onActiveDeviceChange={(deviceId) => - saveVideoInputDeviceId(deviceId ?? '') - } - hideMenu={true} />