(frontend) add a sound tester to the output select menu

Add a sound tester next to the selected output device in the speaker
select menu, so users can play a test sound and confirm they picked
the right speaker.

Inspired by the microphone gauge added previously, and requested by
users.
This commit is contained in:
lebaudantoine
2026-08-09 15:19:43 +02:00
committed by aleb_the_flash
parent b780d2845a
commit 8f27b89d21
8 changed files with 157 additions and 5 deletions
+1
View File
@@ -12,6 +12,7 @@ and this project adheres to
- 📈(frontend) capture media diagnostics on media errors
- ✨(frontend) add an audio gauge to the microphone select menu
- ✨(frontend) add a sound tester to the output select menu
### Changed
@@ -0,0 +1,134 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { RiVolumeUpLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
// no track: the test plays a bundled file through the selected sink, and
// following `sinkId` mid-playback re-routes it live. No permission involved.
type Theme = 'light' | 'dark'
const BUTTON_VARIANT = {
light: 'quaternaryText',
dark: 'primaryTextDark',
} as const
const StyledContainer = styled('div', {
base: {
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
paddingTop: '0.5rem',
marginTop: '0.5rem',
borderTop: '1px solid',
},
variants: {
theme: {
light: {
borderColor: 'gray.200',
},
dark: {
borderColor: 'primaryDark.300',
},
},
},
})
const StyledButtonContent = styled('span', {
base: {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 'full',
paddingX: '1.625rem',
'& > svg': {
position: 'absolute',
left: 0,
},
},
})
type OutputSoundTesterProps = {
/** The device the test should play through (the select's current key). */
sinkId?: string
variant?: Theme
}
export const OutputSoundTester = ({
sinkId,
variant = 'light',
}: OutputSoundTesterProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const latestSinkIdRef = useRef(sinkId)
latestSinkIdRef.current = sinkId
const stopPlayback = useCallback(() => {
const audio = audioRef.current
if (audio) {
audio.pause()
audio.currentTime = 0
}
setIsPlaying(false)
}, [])
useEffect(() => {
if (!sinkId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(sinkId).catch(() => {
// Re-routing failed (stale or unplugged device): stop the test rather
// than keep playing through the previous sink.
if (latestSinkIdRef.current === sinkId) {
stopPlayback()
}
})
}, [sinkId, stopPlayback])
useEffect(() => {
const audio = audioRef.current
return () => audio?.pause()
}, [])
return (
<StyledContainer theme={variant}>
<Button
variant={BUTTON_VARIANT[variant]}
size="sm"
fullWidth
isDisabled={isPlaying}
onPress={async () => {
const audio = audioRef.current
if (!audio) return
try {
// Confirm routing before starting: a no-op when already routed,
// but rejects on a stale device id, so the test never plays
// through the wrong sink.
if (sinkId && canTestAudioOutput()) {
await audio.setSinkId(sinkId)
}
await audio.play()
setIsPlaying(true)
} catch {
stopPlayback()
}
}}
>
<StyledButtonContent>
<RiVolumeUpLine size={18} aria-hidden />
{isPlaying ? t('audiooutput.testing') : t('audiooutput.test')}
</StyledButtonContent>
</Button>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<audio
ref={audioRef}
src="sounds/uprise.mp3"
onEnded={() => setIsPlaying(false)}
/>
</StyledContainer>
)
}
@@ -7,6 +7,8 @@ import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
import type { LocalAudioTrack } from 'livekit-client'
import { AudioLevelGauge } from './AudioLevelGauge'
import { OutputSoundTester } from './OutputSoundTester'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
type DeviceItems = Array<{ value: string; label: string }>
@@ -81,6 +83,11 @@ const SelectDevicePermissions = <T extends string | number>({
menuFooter={
kind === 'audioinput' ? (
<AudioLevelGauge track={track} variant={props.variant} />
) : kind === 'audiooutput' && canTestAudioOutput() ? (
<OutputSoundTester
sinkId={selectedKey as string}
variant={props.variant}
/>
) : undefined
}
{...props}
@@ -0,0 +1,3 @@
export const canTestAudioOutput = () =>
typeof HTMLMediaElement !== 'undefined' &&
'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing
+3 -2
View File
@@ -40,7 +40,9 @@
},
"audiooutput": {
"choose": "Audioausgabe auswählen",
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich"
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich",
"test": "Lautsprecher testen",
"testing": "Testton wird abgespielt…"
}
},
"join": {
@@ -328,7 +330,6 @@
}
}
},
"faceLandmarks": {
"title": "Visuelle Effekte",
"glasses": {
+3 -1
View File
@@ -40,7 +40,9 @@
},
"audiooutput": {
"choose": "Select speaker",
"permissionsNeeded": "Select speaker - permission needed"
"permissionsNeeded": "Select speaker - permission needed",
"test": "Test speakers",
"testing": "Playing test sound…"
}
},
"join": {
+3 -1
View File
@@ -40,7 +40,9 @@
},
"audiooutput": {
"choose": "Choisir le haut-parleur",
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires",
"test": "Tester les haut-parleurs",
"testing": "Lecture du son de test…"
}
},
"join": {
+3 -1
View File
@@ -40,7 +40,9 @@
},
"audiooutput": {
"choose": "Selecteer luidspreker",
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist",
"test": "Luidsprekers testen",
"testing": "Testgeluid wordt afgespeeld…"
}
},
"join": {