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 AudioInputSection() { const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const inputVolume = useVoiceStore((s) => s.inputVolume); const setInputVolume = useVoiceStore((s) => s.setInputVolume); const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); const [listOpen, setListOpen] = useState(false); const [micLevel, setMicLevel] = useState(0); const [activeUpstreamId, setActiveUpstreamId] = useState(null); // Bumped whenever AudioManager's AudioContext transitions to 'running'. // Used as a dep on effects that need to re-run once the context exists — // the user may open Settings before joining voice (no AudioContext yet), // then join voice and expect the meter / resolved-default hint to come // alive without reopening the panel. const [audioCtxGen, setAudioCtxGen] = useState(0); const dropdownRef = useRef(null); const animFrameRef = useRef(0); // Click-outside-to-close. iOS Safari does not synthesize `mousedown` from // touch reliably, so we listen for `touchstart` alongside `mousedown` to // make the popover dismissable with a single tap on touch devices. useEffect(() => { if (!listOpen) return; const onPointerDown = (e: MouseEvent | TouchEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { setListOpen(false); } }; document.addEventListener('mousedown', onPointerDown); document.addEventListener('touchstart', onPointerDown, { passive: true }); return () => { document.removeEventListener('mousedown', onPointerDown); document.removeEventListener('touchstart', onPointerDown); }; }, [listOpen]); // Listen for AudioContext resume events so dependent effects re-trigger // when the context first becomes available (e.g. user joins voice after // opening Settings). onResumed fires on every 'running' state transition; // we only need an opaque generation bump to re-run downstream effects. useEffect(() => { if (permState !== 'granted') return; const am = AudioManager.getInstance(); const unsubscribe = am.onResumed(() => setAudioCtxGen((g) => g + 1)); return () => { unsubscribe(); }; }, [permState]); // Live mic-level meter. Reuses AudioManager's analyser node, which is part // of the canonical pipeline — no extra getUserMedia required once the user // is in voice. Re-runs on `audioCtxGen` bumps so the meter activates after // the AudioContext appears mid-session. useEffect(() => { if (permState !== 'granted') return; let stopped = false; const am = AudioManager.getInstance(); const ctx = am.getContext(); if (!ctx) return; // nothing to measure until the user joins voice or hits Test const analyser = am.getAnalyserNode(); analyser.fftSize = 256; const data = new Uint8Array(analyser.frequencyBinCount); const tick = () => { if (stopped) return; analyser.getByteFrequencyData(data); const avg = data.reduce((a, b) => a + b, 0) / data.length; setMicLevel(Math.min(avg / 128, 1)); animFrameRef.current = requestAnimationFrame(tick); }; tick(); return () => { stopped = true; if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current); }; }, [permState, audioCtxGen]); // Track the resolved upstream deviceId for the "Currently using: X" hint. // Re-runs on `audioCtxGen` because the resolved-default ID is only known // after AudioManager has actually opened a stream. useEffect(() => { if (permState !== 'granted') return; const am = AudioManager.getInstance(); const id = am.getCurrentInputDeviceId(); setActiveUpstreamId(id === 'default' ? null : id); }, [permState, inputDeviceId, audioCtxGen]); if (permState === 'unknown') { return (
Checking microphone access…
); } if (permState === 'denied') { return (
⚠ Microphone access denied
Grant microphone permission to choose an input device.
); } if (permState === 'prompt') { return (
Microphone permission needed to list and choose an input device.
); } // permState === 'granted' const selectedLabel = inputDeviceId === 'default' ? 'System Default' : inputLabels.get(inputDeviceId) ?? 'System Default'; const resolvedHint = inputDeviceId === 'default' && activeUpstreamId ? inputLabels.get(activeUpstreamId) : null; const handleSelect = (id: string) => { setInputDevice(id); AudioManager.getInstance().setInputDevice(id).catch(() => {}); setListOpen(false); }; const micBars = 20; const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); return (
{listOpen && (
handleSelect('default')} /> {inputs.filter(d => d.deviceId !== 'default').map((d) => ( handleSelect(d.deviceId)} /> ))}
)}
{resolvedHint && (
Currently using: {resolvedHint}
)} {inputs.length === 0 && (
No microphones detected.
)}
Input Volume
{inputVolume}%
setInputVolume(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)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) ${inputVolume / 2}%, rgb(var(--interactive-muted)) 100%)`, }} />
{Array.from({ length: micBars }).map((_, i) => (
))}
The level meter activates once you join a voice channel.
); }