Compare commits

...

7 Commits

Author SHA1 Message Date
lebaudantoine ffc3e4a656 🚸(frontend) prioritize UI reactivity over error handling in track toggle
Update track muting/unmuting to prioritize immediate UI state changes
over error handling to prevent weird UX delays.

Ensures toggle buttons reflect new state instantly rather than waiting
for operation completion. Users expect immediate visual feedback when
interacting with mute controls, even if errors occur later.
2025-08-11 23:31:34 +02:00
lebaudantoine 634b8c03a0 🚸(frontend) auto-select single audio output device for smoother UX
When browsers don't return 'default' audio output device ID and only
one device is available, automatically select the single option to
improve user experience.

Prevents unnecessary user interaction when there's only one choice
available, making the device selection flow smoother and more intuitive
for users with single audio output setups.

This is necessary only for audio output because we don't create
a preview track, compare to video or mic.
2025-08-11 23:31:34 +02:00
lebaudantoine 6b622ddc44 🩹(frontend) disable Safari speaker select for LiveKit compatibility
Disable speaker selection on prejoin screen for Safari based on LiveKit
documentation stating audio output selection isn't supported, though
this needs further verification.

Maintains consistency with audio tab behavior until Safari audio output
support can be confirmed. Feature remains available on other browsers
where support is verified.
2025-08-11 23:31:34 +02:00
lebaudantoine 8bfb4d3db3 🐛(frontend) link audio output permissions to mic to prevent prompts
Fix issue where requesting audio output devices triggers microphone
permission prompts in certain browsers by linking audio output select
permissions with microphone permissions.

Ensures no unexpected permission prompts occur before preview tracks
are acquired, maintaining smooth user flow during device selection
without interrupting the permission sequence.
2025-08-11 23:31:34 +02:00
lebaudantoine e79a167a3e 🌐(frontend) fix Dutch plural forms in join screen translations
Correct plural form usage in Dutch language translations for the join
screen interface elements.
2025-08-11 23:31:34 +02:00
lebaudantoine e75776070c (frontend) add speaker select component for audio output configuration
Introduce speaker selection component requested by users to allow audio
output device configuration before entering calls.

Enables users to test and configure their preferred audio output device
during prejoin setup, ensuring proper audio routing before call begins.
Improves user experience by preventing audio issues during meetings.
2025-08-11 23:31:34 +02:00
lebaudantoine 0f72f0f727 (frontend) add localStorage persistence for audio output device
Implement audio output device persistence in localStorage since LiveKit
doesn't handle this by default.

Ensures user's preferred audio output selection is remembered across
sessions, improving user experience by maintaining device preferences
without requiring re-selection on each visit.
2025-08-11 22:00:53 +02:00
11 changed files with 86 additions and 11 deletions
@@ -102,11 +102,15 @@ export const Conference = ({
audioCaptureDefaults: {
deviceId: userConfig.audioDeviceId ?? undefined,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
},
}
// do not rely on the userConfig object directly as its reference may change on every render
}, [
userConfig.videoDeviceId,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
isAdaptiveStreamSupported,
isDynacastSupported,
])
@@ -31,6 +31,7 @@ import { openPermissionsDialog, permissionsStore } from '@/stores/permissions'
import { ToggleDevice } from './join/ToggleDevice'
import { SelectDevice } from './join/SelectDevice'
import { useResolveDefaultDeviceId } from '../livekit/hooks/useResolveDefaultDevice'
import { isSafari } from '@/utils/livekit'
const onError = (e: Error) => console.error('ERROR', e)
@@ -101,11 +102,13 @@ export const Join = ({
audioEnabled,
videoEnabled,
audioDeviceId,
audioOutputDeviceId,
videoDeviceId,
processorSerialized,
username,
},
saveAudioInputEnabled,
saveAudioOutputDeviceId,
saveVideoInputEnabled,
saveAudioInputDeviceId,
saveVideoInputDeviceId,
@@ -590,7 +593,7 @@ export const Join = ({
>
<div
className={css({
width: '50%',
width: '30%',
})}
>
<SelectDevice
@@ -599,9 +602,22 @@ export const Join = ({
onSubmit={saveAudioInputDeviceId}
/>
</div>
{!isSafari() && (
<div
className={css({
width: '30%',
})}
>
<SelectDevice
kind="audiooutput"
id={audioOutputDeviceId}
onSubmit={saveAudioOutputDeviceId}
/>
</div>
)}
<div
className={css({
width: '50%',
width: '30%',
})}
>
<SelectDevice
@@ -2,10 +2,11 @@ import {
RemixiconComponentType,
RiMicLine,
RiVideoOnLine,
RiVolumeDownLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useMediaDeviceSelect } from '@livekit/components-react'
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { Select } from '@/primitives/Select'
import { useSnapshot } from 'valtio'
import { permissionsStore } from '@/stores/permissions'
@@ -44,6 +45,22 @@ const SelectDevicePermissions = ({
label: d.label,
}))
/**
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
* Auto-selects the only available audio output device when currently on 'default'
*/
useEffect(() => {
if (
kind !== 'audiooutput' ||
items.length !== 1 ||
items[0].value === 'default' ||
activeDeviceId !== 'default'
)
return
onSubmit?.(items[0].value)
setActiveMediaDevice(items[0].value)
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
return (
<Select
aria-label={t(`${kind}.choose`)}
@@ -51,7 +68,11 @@ const SelectDevicePermissions = ({
isDisabled={items.length === 0}
items={items}
iconComponent={config?.icon}
placeholder={t('selectDevice.loading')}
placeholder={
items.length === 0
? t('selectDevice.loading')
: t('selectDevice.select')
}
selectedKey={id || activeDeviceId}
onSelectionChange={(key) => {
onSubmit?.(key as string)
@@ -72,6 +93,10 @@ export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
return {
icon: RiMicLine,
}
case 'audiooutput':
return {
icon: RiVolumeDownLine,
}
case 'videoinput':
return {
icon: RiVideoOnLine,
@@ -86,6 +111,9 @@ export const SelectDevice = ({ id, onSubmit, kind }: SelectDeviceProps) => {
if (kind == 'videoinput') {
return permissions.isCameraDenied || permissions.isCameraPrompted
}
if (kind == 'audiooutput') {
return permissions.isMicrophoneDenied || permissions.isMicrophonePrompted
}
return false
}, [kind, permissions])
@@ -49,13 +49,13 @@ export const ToggleDevice = <T extends ToggleSource>({
}
try {
if (isTrackEnabled) {
await track.mute()
setIsTrackEnabled(false)
onChange?.(false, true)
await track.mute()
} else {
await track.unmute()
setIsTrackEnabled(true)
onChange?.(true, true)
await track.unmute()
}
} catch (error) {
console.error('Failed to toggle track:', error)
@@ -16,6 +16,9 @@ export function usePersistentUserChoices() {
saveAudioInputDeviceId: (deviceId: string) => {
userChoicesStore.audioDeviceId = deviceId
},
saveAudioOutputDeviceId: (deviceId: string) => {
userChoicesStore.audioOutputDeviceId = deviceId
},
saveVideoInputDeviceId: (deviceId: string) => {
userChoicesStore.videoDeviceId = deviceId
},
@@ -95,6 +95,7 @@ export const AudioTab = ({ id }: AudioTabProps) => {
userChoices: { noiseReductionEnabled },
saveAudioInputDeviceId,
saveNoiseReductionEnabled,
saveAudioOutputDeviceId,
} = usePersistentUserChoices()
const isSpeaking = useIsSpeaking(localParticipant)
@@ -183,9 +184,10 @@ export const AudioTab = ({ id }: AudioTabProps) => {
defaultSelectedKey={
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
}
onSelectionChange={async (key) =>
onSelectionChange={(key) => {
setActiveMediaDeviceOut(key as string)
}
saveAudioOutputDeviceId(key as string)
}}
{...disabledProps}
style={{
minWidth: 0,
+5
View File
@@ -10,6 +10,7 @@
"join": {
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich"
},
"videoinput": {
@@ -27,6 +28,10 @@
"enable": "Mikrofon aktivieren",
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
},
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
+5
View File
@@ -10,6 +10,7 @@
"join": {
"selectDevice": {
"loading": "Loading…",
"select": "Select a value",
"permissionsNeeded": "Permission needed"
},
"videoinput": {
@@ -27,6 +28,10 @@
"enable": "Enable microphone",
"label": "Microphone"
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
},
"effects": {
"description": "Apply effects",
"title": "Effects",
+5
View File
@@ -10,6 +10,7 @@
"join": {
"selectDevice": {
"loading": "Chargement…",
"select": "Sélectionnez une valeur",
"permissionsNeeded": "Autorisations nécessaires"
},
"videoinput": {
@@ -27,6 +28,10 @@
"enable": "Activer le micro",
"label": "Microphone"
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
},
"heading": "Rejoindre la réunion ?",
"effects": {
"description": "Effets d'arrière plan",
+8 -3
View File
@@ -10,11 +10,12 @@
"join": {
"selectDevice": {
"loading": "Bezig met laden…",
"permissionNeeded": "Toestemming vereist"
"select": "Selecteer een waarde",
"permissionsNeeded": "Toestemming vereist"
},
"videoinput": {
"choose": "Selecteer camera",
"permissionNeeded": "Selecteer camera - Toestemming vereist",
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
"disable": "Camera uitschakelen",
"enable": "Camera inschakelen",
"label": "Camera",
@@ -22,11 +23,15 @@
},
"audioinput": {
"choose": "Selecteer microfoon",
"permissionNeeded": "Selecteer microfoon - Toestemming vereist",
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
"disable": "Microfoon dempen",
"enable": "Microfoon dempen opheffen",
"label": "Microfoon"
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
},
"effects": {
"description": "Pas effecten toe",
"title": "Effecten",
+2
View File
@@ -9,11 +9,13 @@ import {
export type LocalUserChoices = LocalUserChoicesLK & {
processorSerialized?: ProcessorSerialized
noiseReductionEnabled?: boolean
audioOutputDeviceId?: string
}
function getUserChoicesState(): LocalUserChoices {
return {
noiseReductionEnabled: false,
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
...loadUserChoices(),
}
}