refactor(web): UserAreaPanel uses useAudioDevices — kills unconditional getUserMedia probe; reuses shared DropdownItem

This commit is contained in:
Jannis Braun
2026-05-03 01:56:24 +02:00
parent ff8dcc28ea
commit 6905071459
@@ -21,6 +21,8 @@ import { DmSearchBar } from './DmSearchBar';
import { DmListItem } from './DmListItem'; import { DmListItem } from './DmListItem';
import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager'; import { useDragManager, type DropTarget, type LayoutItem } from '../../hooks/useDragManager';
import { useDelayedLoading } from '../../hooks/useDelayedLoading'; import { useDelayedLoading } from '../../hooks/useDelayedLoading';
import { useAudioDevices } from '../../hooks/useAudioDevices';
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
export function ChannelSidebar() { export function ChannelSidebar() {
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
@@ -843,15 +845,20 @@ function UserAreaPanel({
onSettingsClick: (tab?: string) => void; onSettingsClick: (tab?: string) => void;
}) { }) {
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([]);
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([]);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
const setInputDevice = useVoiceStore((s) => s.setInputDevice); const setInputDevice = useVoiceStore((s) => s.setInputDevice);
const setOutputDevice = useVoiceStore((s) => s.setOutputDevice); const setOutputDevice = useVoiceStore((s) => s.setOutputDevice);
const [selectedInputLabel, setSelectedInputLabel] = useState<string>('Default'); // Shared hook drives lists, permission state, and live devicechange refresh.
const [selectedOutputLabel, setSelectedOutputLabel] = useState<string>('Default'); const { permState, inputs: inputDevices, outputs: outputDevices, inputLabels, outputLabels, requestPermission } = useAudioDevices();
const selectedInputLabel = inputDeviceId === 'default'
? 'System Default'
: inputLabels.get(inputDeviceId) ?? 'System Default';
const selectedOutputLabel = outputDeviceId === 'default'
? 'System Default'
: outputLabels.get(outputDeviceId) ?? 'System Default';
const inputVolume = useVoiceStore((s) => s.inputVolume); const inputVolume = useVoiceStore((s) => s.inputVolume);
const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume); const storeSetInputVolume = useVoiceStore((s) => s.setInputVolume);
@@ -864,38 +871,6 @@ function UserAreaPanel({
const analyserRef = useRef<AnalyserNode | null>(null); const analyserRef = useRef<AnalyserNode | null>(null);
const animFrameRef = useRef<number>(0); const animFrameRef = useRef<number>(0);
const loadDevices = useCallback(async () => {
try {
// Need to request permission first to get labels
if (!AudioManager.getInstance().getContext()) {
await navigator.mediaDevices.getUserMedia({ audio: true }).then(s => s.getTracks().forEach(t => t.stop()));
}
const devices = await navigator.mediaDevices.enumerateDevices();
// Deduplicate by deviceId — USB devices sharing the same audio chipset
// (e.g. C-Media 0d8c:0134) appear as multiple entries with identical IDs.
const dedup = (list: MediaDeviceInfo[]): MediaDeviceInfo[] => {
const seen = new Set<string>();
return list.filter(d => {
if (seen.has(d.deviceId)) return false;
seen.add(d.deviceId);
return true;
});
};
const inputs = dedup(devices.filter(d => d.kind === 'audioinput'));
const outputs = dedup(devices.filter(d => d.kind === 'audiooutput'));
setInputDevices(inputs);
setOutputDevices(outputs);
const currentInput = inputs.find(d => d.deviceId === inputDeviceId);
if (currentInput) setSelectedInputLabel(currentInput.label || 'Default');
const currentOutput = outputs.find(d => d.deviceId === outputDeviceId);
if (currentOutput) setSelectedOutputLabel(currentOutput.label || 'Default');
} catch {
// permission denied
}
}, [inputDeviceId, outputDeviceId]);
// Start mic level monitoring when input panel opens // Start mic level monitoring when input panel opens
useEffect(() => { useEffect(() => {
if (openPanel !== 'input') { if (openPanel !== 'input') {
@@ -946,27 +921,24 @@ function UserAreaPanel({
if (openPanel === panel) { if (openPanel === panel) {
setOpenPanel(null); setOpenPanel(null);
} else { } else {
loadDevices();
setOpenPanel(panel); setOpenPanel(panel);
setShowInputDeviceList(false); setShowInputDeviceList(false);
setShowOutputDeviceList(false); setShowOutputDeviceList(false);
// Explicitly resume on interaction // Resume the AudioContext so the mic-level meter starts measuring on open.
AudioManager.getInstance().resumeContext(); AudioManager.getInstance().resumeContext();
} }
}; };
const selectInput = (device: MediaDeviceInfo) => { const selectInput = (deviceId: string) => {
setInputDevice(device.deviceId); // Pure state update → triggers syncMic if in voice call setInputDevice(deviceId); // Pure state update → triggers syncMic if in voice call
AudioManager.getInstance().setInputDevice(device.deviceId); // Immediate preview for mic level meter AudioManager.getInstance().setInputDevice(deviceId).catch(() => {});
setSelectedInputLabel(device.label || 'Default');
setShowInputDeviceList(false); setShowInputDeviceList(false);
}; };
const selectOutput = (device: MediaDeviceInfo) => { const selectOutput = (deviceId: string) => {
setOutputDevice(device.deviceId); setOutputDevice(deviceId);
setSelectedOutputLabel(device.label || 'Default'); AudioManager.getInstance().setOutputDevice(deviceId).catch(() => {});
setShowOutputDeviceList(false); setShowOutputDeviceList(false);
AudioManager.getInstance().setOutputDevice(device.deviceId);
}; };
// Generate mic level bars (20 bars like Discord) // Generate mic level bars (20 bars like Discord)
@@ -993,23 +965,35 @@ function UserAreaPanel({
</svg> </svg>
</button> </button>
{showInputDeviceList && ( {showInputDeviceList && (
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard"> <div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard max-h-64 overflow-y-auto">
{inputDevices.map(d => ( {permState !== 'granted' && (
<button <div className="px-3 py-2 text-[12px] text-txt-tertiary">
key={d.deviceId} Microphone permission needed.{' '}
onClick={() => selectInput(d)} <button
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${ onClick={() => { requestPermission().catch(() => {}); }}
inputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary' className="underline text-accent-primary"
}`} >
> Enable
{inputDeviceId === d.deviceId && ( </button>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0"> </div>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> )}
</svg> {permState === 'granted' && (
)} <>
<span className={inputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span> <DropdownItem
</button> label="System Default"
))} active={inputDeviceId === 'default'}
onClick={() => selectInput('default')}
/>
{inputDevices.filter(d => d.deviceId !== 'default').map(d => (
<DropdownItem
key={d.deviceId}
label={inputLabels.get(d.deviceId) ?? d.deviceId}
active={inputDeviceId === d.deviceId}
onClick={() => selectInput(d.deviceId)}
/>
))}
</>
)}
</div> </div>
)} )}
</div> </div>
@@ -1079,23 +1063,35 @@ function UserAreaPanel({
</svg> </svg>
</button> </button>
{showOutputDeviceList && ( {showOutputDeviceList && (
<div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard"> <div className="bg-surface-base rounded-lg shadow-lg mx-2 mb-2 py-1 border border-border-hard max-h-64 overflow-y-auto">
{outputDevices.map(d => ( {permState !== 'granted' && (
<button <div className="px-3 py-2 text-[12px] text-txt-tertiary">
key={d.deviceId} Audio permission needed.{' '}
onClick={() => selectOutput(d)} <button
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${ onClick={() => { requestPermission().catch(() => {}); }}
outputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary' className="underline text-accent-primary"
}`} >
> Enable
{outputDeviceId === d.deviceId && ( </button>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0"> </div>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" /> )}
</svg> {permState === 'granted' && (
)} <>
<span className={outputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span> <DropdownItem
</button> label="System Default"
))} active={outputDeviceId === 'default'}
onClick={() => selectOutput('default')}
/>
{outputDevices.filter(d => d.deviceId !== 'default').map(d => (
<DropdownItem
key={d.deviceId}
label={outputLabels.get(d.deviceId) ?? d.deviceId}
active={outputDeviceId === d.deviceId}
onClick={() => selectOutput(d.deviceId)}
/>
))}
</>
)}
</div> </div>
)} )}
</div> </div>