From 5b8af14e12f47c415294d3bc67bcf96e9b956b1f Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 3 May 2026 01:47:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20AudioOutputSection=20=E2=80=94=20o?= =?UTF-8?q?utput=20picker,=20volume,=20test=20tone,=20lifecycle-aware=20si?= =?UTF-8?q?nkId=20check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Uses shared SectionShell/DropdownItem from ./_shared/SettingsPickerPrimitives rather than redefining local copies (parity with AudioInputSection). - supportsSinkId is reactive to AudioContext lifecycle via the audioCtxGen pattern (bumped by AudioManager.onResumed). Defaults to true and only flips to false when a real context exists AND lacks setSinkId (Safari < 17), so the picker is never preemptively hidden when the user opens Settings before joining voice. AudioManager.setOutputDevice + initContext re-apply path handles the deferred sinkId binding once the context appears. --- .../settingsPanels/AudioOutputSection.tsx | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx diff --git a/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx new file mode 100644 index 00000000..2c2108a7 --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/AudioOutputSection.tsx @@ -0,0 +1,167 @@ +import { useEffect, useRef, useState } from 'react'; +import { useVoiceStore } from '../../../stores/voiceStore'; +import { AudioManager } from '../../../audio/AudioManager'; +import { useAudioDevices } from '../../../hooks/useAudioDevices'; +import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; + +export function AudioOutputSection() { + const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); + const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); + const outputVolume = useVoiceStore((s) => s.outputVolume); + const setOutputVolume = useVoiceStore((s) => s.setOutputVolume); + const { permState, outputs, outputLabels, requestPermission } = useAudioDevices(); + + const [listOpen, setListOpen] = useState(false); + // Default to "supported" — only flip false if a real context exists and + // lacks setSinkId (Safari < 17). Pre-context users can still pick a device; + // AudioManager.setOutputDevice defers the actual setSinkId until the + // context exists (applyOutputDevice early-returns when ctx is null, and + // initContext re-applies the persisted ID on creation). + const [supportsSinkId, setSupportsSinkId] = useState(true); + // Bumped whenever AudioManager's AudioContext transitions to 'running'. + // Used as a dep on the supportsSinkId effect so the check re-evaluates + // when the user joins voice after opening Settings — without this, a + // user who opens Settings before joining voice would see an incorrect + // "browser doesn't support setSinkId" fallback that never recovers. + const [audioCtxGen, setAudioCtxGen] = useState(0); + const dropdownRef = useRef(null); + + // Click-outside-to-close. + useEffect(() => { + if (!listOpen) return; + const onMouseDown = (e: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { + setListOpen(false); + } + }; + document.addEventListener('mousedown', onMouseDown); + return () => document.removeEventListener('mousedown', onMouseDown); + }, [listOpen]); + + // Listen for AudioContext resume events so the supportsSinkId check + // re-evaluates once the context first becomes available. + useEffect(() => { + if (permState !== 'granted') return; + const am = AudioManager.getInstance(); + const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1)); + return () => { unsubscribe(); }; + }, [permState]); + + // Detect setSinkId support — Safari < 17 does not support it on AudioContext. + // Default state is "supported"; we only flip to false once we have a real + // context to inspect AND it lacks the API. This avoids hiding the picker + // from users who open Settings before joining voice (no context yet). + useEffect(() => { + const ctx = AudioManager.getInstance().getContext(); + if (ctx && !('setSinkId' in ctx)) { + setSupportsSinkId(false); + } else { + setSupportsSinkId(true); + } + }, [permState, audioCtxGen]); + + if (permState === 'unknown') { + return ( + +
Checking audio access…
+
+ ); + } + + // Output-device labels are gated behind microphone permission. If permission + // is not granted we can still let the user adjust output volume + test the + // current default, but the picker is hidden. + const showPicker = permState === 'granted' && supportsSinkId; + const selectedLabel = outputDeviceId === 'default' + ? 'System Default' + : outputLabels.get(outputDeviceId) ?? 'System Default'; + + const handleSelect = (id: string) => { + setOutputDevice(id); + AudioManager.getInstance().setOutputDevice(id).catch(() => {}); + setListOpen(false); + }; + + const handleTestTone = async () => { + try { await AudioManager.getInstance().playTestTone(); } catch { /* best-effort */ } + }; + + return ( + +
+ {showPicker ? ( +
+ + {listOpen && ( +
+ handleSelect('default')} /> + {outputs.filter(d => d.deviceId !== 'default').map((d) => ( + handleSelect(d.deviceId)} + /> + ))} +
+ )} +
+ ) : permState === 'granted' && !supportsSinkId ? ( +
+ This browser doesn't support choosing an output device. Audio plays to the system default. +
+ ) : ( +
+
+ Grant microphone permission to list output devices (browsers gate output names behind microphone access). +
+ +
+ )} + +
+
+
Output Volume
+
{outputVolume}%
+
+ setOutputVolume(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer bg-surface-base [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, rgb(var(--accent-primary)) 0%, rgb(var(--accent-primary)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) ${outputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, + }} + /> +
+ + +
+
+ ); +}