Compare commits

..

16 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
lebaudantoine 890e043d29 🚨(frontend) remove redundant undefined type or optional specifier
Fix TypeScript warning by removing either 'undefined' type annotation
or '?' optional specifier where both were redundantly specified.
2025-08-11 19:04:59 +02:00
lebaudantoine 9eb412758a 🚨(frontend) fix unnecessary destructuring rename in devices assignment
Remove unnecessary renaming in destructuring assignment where variable
was renamed to the same name, violating TypeScript rule S6650.
2025-08-11 19:04:59 +02:00
lebaudantoine fa27afdfdf 💄(frontend) add symmetric shadows for white button contrast enhancement
Add symmetric shadows at top and bottom of white circle buttons to
ensure sufficient visual contrast against varying background colors.

Improves button visibility and accessibility by providing consistent
visual definition regardless of background context.
2025-08-11 19:04:59 +02:00
lebaudantoine cb8b415ef9 ♻️(frontend) refactor device select for controlled behavior
Major refactor of device select component with several key improvements:

* Set permission=true for Firefox compatibility - without this flag,
  device list returns empty on Firefox
* Implement controlled component pattern for active device selection,
  ensuring sync with preview track state
* Remove default device handling as controlled behavior eliminates need
* Render selectors only after permissions granted to prevent double
  permission prompts (separate for mic/camera)

Ensures usePreviewTrack handles initial permission request, then
selectors allow specific device choice once access is granted.
2025-08-11 19:04:59 +02:00
lebaudantoine 7c6182cc4e 🐛(frontend) fix default device ID mismatch with actual preview track
Update default device IDs when preview track starts to match the
actual device being used. LiveKit returns 'default' string which may
not exist in device list, causing ID mismatch.

Prevents misleading situation where default device ID doesn't
correspond to the device actually used by the preview track. Now
synchronizes IDs once preview starts for accurate device tracking.
2025-08-11 19:04:59 +02:00
lebaudantoine 2d47e90a1a 🐛(frontend) reset video ref on track stop for state transitions
Reset video element reference when track stops to ensure "camera
starting" to "camera started" message transitions work correctly on
repeated camera toggles.

Previously only worked on initial video element load. Now properly
handles state transitions for multiple camera enable/disable cycles.
2025-08-11 19:04:59 +02:00
lebaudantoine 56ec2dd8cb ♻️(frontend) refactor select to show arrow up when menu opens upward
Update select toggle device component used in conference to display
upward arrow when dropdown menu opens above the select component.

Improves visual consistency by matching arrow direction with actual
menu opening direction, providing clearer user interface feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine 361de29780 🩹(frontend) refactor track muting for proper audio/video control
Fix useTrackToggle hook that wasn't properly muting/unmuting tracks
outside room context. Previously only toggled boolean state via
setUpManualToggle without actual track control.

This caused misleading visual feedback where prejoin showed "camera
disabled" while hardware remained active. Users could see camera/mic
LEDs still on despite UI indicating otherwise.

Refactor provides genuine track muting for accurate user feedback.
2025-08-11 19:04:59 +02:00
lebaudantoine e4d5ca64b9 ♻️(frontend) refactor prejoin screen for room context flexibility
Decouple prejoin components from conference context to enable different
behaviors when inside vs outside room environments. Components can now
evolve independently with lighter coupling.

Refactor layout structure to prepare for upcoming speaker selector
introduction. This decoupling allows for more flexible component
evolution and cleaner architecture.
2025-08-11 19:04:59 +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(),
}
}