feat(web): AudioOutputSection — output picker, volume, test tone, lifecycle-aware sinkId check
- 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.
This commit is contained in:
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<SectionShell title="Output Device">
|
||||||
|
<div className="text-sm text-txt-tertiary">Checking audio access…</div>
|
||||||
|
</SectionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<SectionShell title="Output Device">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{showPicker ? (
|
||||||
|
<div ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setListOpen((v) => !v)}
|
||||||
|
className="w-full px-3 py-2 flex items-center justify-between rounded-md bg-surface-base hover:bg-interactive-hover transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-[13px] text-txt-primary truncate text-left">{selectedLabel}</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
||||||
|
className={`text-txt-tertiary flex-shrink-0 ml-2 transition-transform ${listOpen ? 'rotate-90' : ''}`}>
|
||||||
|
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{listOpen && (
|
||||||
|
<div className="mt-1 rounded-md bg-surface-base border border-border-hard py-1 max-h-64 overflow-y-auto">
|
||||||
|
<DropdownItem label="System Default" active={outputDeviceId === 'default'} onClick={() => handleSelect('default')} />
|
||||||
|
{outputs.filter(d => d.deviceId !== 'default').map((d) => (
|
||||||
|
<DropdownItem
|
||||||
|
key={d.deviceId}
|
||||||
|
label={outputLabels.get(d.deviceId) ?? d.deviceId}
|
||||||
|
active={outputDeviceId === d.deviceId}
|
||||||
|
onClick={() => handleSelect(d.deviceId)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : permState === 'granted' && !supportsSinkId ? (
|
||||||
|
<div className="text-xs text-txt-tertiary">
|
||||||
|
This browser doesn't support choosing an output device. Audio plays to the system default.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-xs text-txt-tertiary">
|
||||||
|
Grant microphone permission to list output devices (browsers gate output names behind microphone access).
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { requestPermission().catch(() => {}); }}
|
||||||
|
className="text-[13px] px-3 py-2 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Enable audio access
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<div className="text-[13px] font-medium text-txt-primary">Output Volume</div>
|
||||||
|
<div className="text-xs text-txt-tertiary tabular-nums">{outputVolume}%</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={200}
|
||||||
|
value={outputVolume}
|
||||||
|
onChange={(e) => 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%)`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleTestTone}
|
||||||
|
className="w-full text-[13px] px-3 py-2 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-primary transition-colors flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
||||||
|
</svg>
|
||||||
|
Play test sound
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</SectionShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user