refactor(web): UserAreaPanel uses useAudioDevices — kills unconditional getUserMedia probe; reuses shared DropdownItem
This commit is contained in:
@@ -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' && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-txt-tertiary">
|
||||||
|
Microphone permission needed.{' '}
|
||||||
<button
|
<button
|
||||||
key={d.deviceId}
|
onClick={() => { requestPermission().catch(() => {}); }}
|
||||||
onClick={() => selectInput(d)}
|
className="underline text-accent-primary"
|
||||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
|
||||||
inputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{inputDeviceId === d.deviceId && (
|
Enable
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0">
|
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className={inputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{permState === 'granted' && (
|
||||||
|
<>
|
||||||
|
<DropdownItem
|
||||||
|
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' && (
|
||||||
|
<div className="px-3 py-2 text-[12px] text-txt-tertiary">
|
||||||
|
Audio permission needed.{' '}
|
||||||
<button
|
<button
|
||||||
key={d.deviceId}
|
onClick={() => { requestPermission().catch(() => {}); }}
|
||||||
onClick={() => selectOutput(d)}
|
className="underline text-accent-primary"
|
||||||
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
|
|
||||||
outputDeviceId === d.deviceId ? 'text-txt-primary' : 'text-txt-secondary'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{outputDeviceId === d.deviceId && (
|
Enable
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-accent-primary flex-shrink-0">
|
|
||||||
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span className={outputDeviceId === d.deviceId ? '' : 'pl-6'}>{d.label || 'Default'}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{permState === 'granted' && (
|
||||||
|
<>
|
||||||
|
<DropdownItem
|
||||||
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user