Files
backspace/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx
T

231 lines
9.0 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { useVoiceStore } from '../../../stores/voiceStore';
import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices';
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<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
// 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]);
// Live mic-level meter. Reuses AudioManager's analyser node, which is part
// of the canonical pipeline — no extra getUserMedia required if the user is
// already in voice OR the AudioContext is active.
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]);
// Track the resolved upstream deviceId for the "System Default · X" hint.
useEffect(() => {
if (permState !== 'granted') return;
const am = AudioManager.getInstance();
const id = am.getCurrentInputDeviceId();
setActiveUpstreamId(id === 'default' ? null : id);
}, [permState, inputDeviceId]);
if (permState === 'unknown') {
return (
<SectionShell title="Input Device">
<div className="text-sm text-txt-tertiary">Checking microphone access</div>
</SectionShell>
);
}
if (permState === 'denied') {
return (
<SectionShell title="Input Device">
<div className="space-y-2">
<div className="text-sm text-txt-primary"> Microphone access denied</div>
<div className="text-xs text-txt-tertiary">
Grant microphone permission to choose an input device.
</div>
<button
onClick={() => { requestPermission().catch(() => {}); }}
className="text-xs px-3 py-1.5 rounded-md bg-surface-base hover:bg-interactive-hover text-txt-secondary transition-colors"
>
Try again
</button>
</div>
</SectionShell>
);
}
if (permState === 'prompt') {
return (
<SectionShell title="Input Device">
<div className="space-y-3">
<div className="text-xs text-txt-tertiary">
Microphone permission needed to list and choose an input device.
</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 microphone access
</button>
</div>
</SectionShell>
);
}
// 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 (
<SectionShell title="Input Device">
<div className="space-y-3">
<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={inputDeviceId === 'default'} onClick={() => handleSelect('default')} />
{inputs.filter(d => d.deviceId !== 'default').map((d) => (
<DropdownItem
key={d.deviceId}
label={inputLabels.get(d.deviceId) ?? d.deviceId}
active={inputDeviceId === d.deviceId}
onClick={() => handleSelect(d.deviceId)}
/>
))}
</div>
)}
</div>
{resolvedHint && (
<div className="text-xs text-txt-tertiary -mt-1">Currently using: {resolvedHint}</div>
)}
{inputs.length === 0 && (
<div className="text-xs text-txt-tertiary">No microphones detected.</div>
)}
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[13px] font-medium text-txt-primary">Input Volume</div>
<div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
</div>
<input
type="range"
min={0}
max={200}
value={inputVolume}
onChange={(e) => 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%)`,
}}
/>
<div className="flex items-center gap-[3px] mt-2">
{Array.from({ length: micBars }).map((_, i) => (
<div
key={i}
className={`flex-1 h-[6px] rounded-[1px] transition-colors duration-75 ${
i < activeBars ? 'bg-status-online' : 'bg-interactive-muted'
}`}
/>
))}
</div>
<div className="text-xs text-txt-tertiary mt-1.5">
The level meter is live whenever an audio session is active. Join a voice channel to test mic input.
</div>
</div>
</div>
</SectionShell>
);
}
function SectionShell({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{title}</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">{children}</div>
</div>
);
}
interface DropdownItemProps {
label: string;
active: boolean;
onClick: () => void;
}
function DropdownItem({ label, active, onClick }: DropdownItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`w-full px-3 py-2 text-left text-[13px] hover:bg-interactive-hover transition-colors flex items-center gap-2 ${
active ? 'text-txt-primary' : 'text-txt-secondary'
}`}
>
{active && (
<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={`truncate ${active ? '' : 'pl-6'}`}>{label}</span>
</button>
);
}