diff --git a/CHANGELOG.md b/CHANGELOG.md index c6fd47c4..a95dcd98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/OutputSoundTester.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/OutputSoundTester.tsx new file mode 100644 index 00000000..ba6f7d9f --- /dev/null +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/OutputSoundTester.tsx @@ -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(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 ( + + + {/* eslint-disable-next-line jsx-a11y/media-has-caption */} + + ) +} diff --git a/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx b/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx index b1b2a672..9e31217e 100644 --- a/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx +++ b/src/frontend/src/features/rooms/livekit/components/controls/Device/SelectDevice.tsx @@ -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 = ({ menuFooter={ kind === 'audioinput' ? ( + ) : kind === 'audiooutput' && canTestAudioOutput() ? ( + ) : undefined } {...props} diff --git a/src/frontend/src/features/rooms/utils/canTestAudioOutput.ts b/src/frontend/src/features/rooms/utils/canTestAudioOutput.ts new file mode 100644 index 00000000..f44efa2d --- /dev/null +++ b/src/frontend/src/features/rooms/utils/canTestAudioOutput.ts @@ -0,0 +1,3 @@ +export const canTestAudioOutput = () => + typeof HTMLMediaElement !== 'undefined' && + 'setSinkId' in HTMLMediaElement.prototype // Safari: no output routing diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json index 2d27256f..23184cb5 100644 --- a/src/frontend/src/locales/de/rooms.json +++ b/src/frontend/src/locales/de/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json index 21a13865..a798fff0 100644 --- a/src/frontend/src/locales/en/rooms.json +++ b/src/frontend/src/locales/en/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json index 00519708..173feff2 100644 --- a/src/frontend/src/locales/fr/rooms.json +++ b/src/frontend/src/locales/fr/rooms.json @@ -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": { diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json index 744d7384..cf519a5c 100644 --- a/src/frontend/src/locales/nl/rooms.json +++ b/src/frontend/src/locales/nl/rooms.json @@ -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": {