diff --git a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx
index 0db29ea2..262b8a43 100644
--- a/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx
+++ b/src/frontend/src/features/settings/components/SettingsDialogExtended.tsx
@@ -1,5 +1,5 @@
import { Dialog, type DialogProps } from '@/primitives'
-import { Tab, Tabs, TabPanel, TabList } from '@/primitives/Tabs.tsx'
+import { Tab, Tabs, TabList } from '@/primitives/Tabs.tsx'
import { css } from '@/styled-system/css'
import { text } from '@/primitives/Text.tsx'
import { Heading } from 'react-aria-components'
@@ -11,6 +11,7 @@ import {
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { GeneralTab } from '@/features/settings/components/tabs/GeneralTab.tsx'
+import { AudioTab } from '@/features/settings/components/tabs/AudioTab.tsx'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -68,9 +69,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
-
- There are your audio settings
-
+
diff --git a/src/frontend/src/features/settings/components/tabs/AudioTab.tsx b/src/frontend/src/features/settings/components/tabs/AudioTab.tsx
new file mode 100644
index 00000000..e276f135
--- /dev/null
+++ b/src/frontend/src/features/settings/components/tabs/AudioTab.tsx
@@ -0,0 +1,90 @@
+import { DialogProps, Field, H } from '@/primitives'
+
+import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
+import { useMediaDeviceSelect } from '@livekit/components-react'
+import { isSafari } from '@/utils/livekit'
+import { useTranslation } from 'react-i18next'
+
+export type AudioTabProps = Pick &
+ Pick
+
+type DeviceItems = Array<{ value: string; label: string }>
+
+export const AudioTab = ({ id }: AudioTabProps) => {
+ const { t } = useTranslation('settings')
+
+ const {
+ devices: devicesOut,
+ activeDeviceId: activeDeviceIdOut,
+ setActiveMediaDevice: setActiveMediaDeviceOut,
+ } = useMediaDeviceSelect({ kind: 'audiooutput' })
+
+ const {
+ devices: devicesIn,
+ activeDeviceId: activeDeviceIdIn,
+ 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,
+ }
+
+ // No API to directly query the default audio device; this function heuristically finds it.
+ // Returns the item with value 'default' if present; otherwise, returns the first item in the list.
+ const getDefaultSelectedKey = (items: DeviceItems) => {
+ if (!items || items.length === 0) return
+ const defaultItem =
+ items.find((item) => item.value === 'default') || items[0]
+ return defaultItem.value
+ }
+
+ return (
+
+ {t('audio.microphone.heading')}
+ setActiveMediaDeviceIn(key as string)}
+ {...disabledProps}
+ />
+ {/* 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() && (
+ <>
+ {t('audio.speakers.heading')}
+ setActiveMediaDeviceOut(key as string)}
+ {...disabledProps}
+ />
+ >
+ )}
+
+ )
+}
diff --git a/src/frontend/src/locales/de/settings.json b/src/frontend/src/locales/de/settings.json
index e5f90fec..b3c09aab 100644
--- a/src/frontend/src/locales/de/settings.json
+++ b/src/frontend/src/locales/de/settings.json
@@ -6,6 +6,17 @@
"nameLabel": "",
"authentication": ""
},
+ "audio": {
+ "microphone": {
+ "heading": "",
+ "label": ""
+ },
+ "speakers": {
+ "heading": "",
+ "label": ""
+ },
+ "permissionsRequired": ""
+ },
"dialog": {
"heading": ""
},
diff --git a/src/frontend/src/locales/en/settings.json b/src/frontend/src/locales/en/settings.json
index c7519c18..a1b51c49 100644
--- a/src/frontend/src/locales/en/settings.json
+++ b/src/frontend/src/locales/en/settings.json
@@ -6,6 +6,17 @@
"nameLabel": "Votre Nom",
"authentication": "Authentication"
},
+ "audio": {
+ "microphone": {
+ "heading": "Microphone",
+ "label": "Select your audio input"
+ },
+ "speakers": {
+ "heading": "Speakers",
+ "label": "Select your audio output"
+ },
+ "permissionsRequired": "Permissions required"
+ },
"dialog": {
"heading": "Settings"
},
diff --git a/src/frontend/src/locales/fr/settings.json b/src/frontend/src/locales/fr/settings.json
index da9b6943..da64441f 100644
--- a/src/frontend/src/locales/fr/settings.json
+++ b/src/frontend/src/locales/fr/settings.json
@@ -6,6 +6,17 @@
"nameLabel": "Votre Nom",
"authentication": "Authentification"
},
+ "audio": {
+ "microphone": {
+ "heading": "Micro",
+ "label": "Sélectionner votre entrée audio"
+ },
+ "speakers": {
+ "heading": "Haut-parleurs",
+ "label": "Sélectionner votre sortie audio"
+ },
+ "permissionsRequired": "Autorisations nécessaires"
+ },
"dialog": {
"heading": "Paramètres"
},
diff --git a/src/frontend/src/primitives/Select.tsx b/src/frontend/src/primitives/Select.tsx
index 382435ec..4fe7191b 100644
--- a/src/frontend/src/primitives/Select.tsx
+++ b/src/frontend/src/primitives/Select.tsx
@@ -32,6 +32,12 @@ const StyledButton = styled(Button, {
'&[data-pressed]': {
backgroundColor: 'control.hover',
},
+ // fixme disabled style is being overridden by placeholder one and needs refinement.
+ '&[data-disabled]': {
+ color: 'default.subtle-text',
+ borderColor: 'gray.200',
+ boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
+ },
},
})