Files
meet/src/frontend/src/features/settings/components/tabs/AudioTab.tsx
T
lebaudantoine 04ec967a99 ️(frontend) handle actions on userChoices store directly in the module
The Valtio actions on the store were initially defined inside a hook,
which was a bad idea: it does not follow Valtio's recommendation of
defining actions at the module level, and the function definitions
were re-created every time the hook re-rendered, which happened on
every state update.

These action has nothing related to React.

Additionally, the snapshot should be used more directly so Valtio can
understand and optimize which parts of the proxy are of interest to
the snapshot being made.
2026-05-30 23:39:05 +02:00

142 lines
4.7 KiB
TypeScript

import { DialogProps, Field, Switch, Text } from '@/primitives'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import {
useIsSpeaking,
useMediaDeviceSelect,
useRoomContext,
} from '@livekit/components-react'
import { isSafari } from '@/utils/livekit'
import { useTranslation } from 'react-i18next'
import { SoundTester } from '@/components/SoundTester'
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
import posthog from 'posthog-js'
import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio'
import {
saveAudioInputDeviceId,
saveAudioOutputDeviceId,
saveNoiseReductionEnabled,
userChoicesStore,
} from '@/stores/userChoices'
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'>
type DeviceItems = Array<{ value: string; label: string }>
export const AudioTab = ({ id }: AudioTabProps) => {
const { t } = useTranslation('settings')
const { localParticipant } = useRoomContext()
const { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId } =
useSnapshot(userChoicesStore)
const isSpeaking = useIsSpeaking(localParticipant)
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
useMediaDeviceSelect({ kind: 'audiooutput' })
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
useMediaDeviceSelect({ kind: 'audioinput' })
const itemsOut: DeviceItems = devicesOut.map((d) => ({
value: d.deviceId,
label: d.label,
}))
const itemsIn: DeviceItems = devicesIn.map((d) => ({
value: d.deviceId,
label: d.label,
}))
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for microphone permissions
// may raise an error. As a workaround, we infer microphone permission status by checking if the list of audio input
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted microphone access.
const isMicEnabled = devicesIn?.length > 0
const disabledProps = isMicEnabled
? {}
: {
placeholder: t('audio.permissionsRequired'),
isDisabled: true,
defaultSelectedKey: undefined,
}
const noiseReductionAvailable = useNoiseReductionAvailable()
return (
<TabPanel padding={'md'} flex id={id}>
<RowWrapper heading={t('audio.microphone.heading')}>
<Field
type="select"
label={t('audio.microphone.label')}
items={itemsIn}
selectedKey={audioDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceIn(key as string)
saveAudioInputDeviceId(key as string)
}}
{...disabledProps}
style={{
width: '100%',
}}
/>
<>
{localParticipant.isMicrophoneEnabled ? (
<ActiveSpeaker isSpeaking={isSpeaking} />
) : (
<span>{t('audio.microphone.disabled')}</span>
)}
</>
</RowWrapper>
{/* Safari has a known limitation where its implementation of 'enumerateDevices' does not include audio output devices.
To prevent errors or an empty selection list, we only render the speakers selection field on non-Safari browsers. */}
{!isSafari() ? (
<RowWrapper heading={t('audio.speakers.heading')}>
<Field
type="select"
label={t('audio.speakers.label')}
items={itemsOut}
selectedKey={audioOutputDeviceId}
onSelectionChange={async (key) => {
await setActiveMediaDeviceOut(key as string)
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
style={{
minWidth: 0,
}}
/>
<SoundTester />
</RowWrapper>
) : (
<RowWrapper heading={t('audio.speakers.heading')}>
<Text variant="warning" margin="md">
{t('audio.speakers.safariWarning')}
</Text>
<div />
</RowWrapper>
)}
{noiseReductionAvailable && (
<RowWrapper heading={t('audio.noiseReduction.heading')} beta>
<Switch
aria-label={t(
`audio.noiseReduction.ariaLabel.${noiseReductionEnabled ? 'disable' : 'enable'}`
)}
isSelected={noiseReductionEnabled}
onChange={(v) => {
saveNoiseReductionEnabled(v)
if (v) posthog.capture('noise-reduction-init')
}}
>
{t('audio.noiseReduction.label')}
</Switch>
<div />
</RowWrapper>
)}
</TabPanel>
)
}