feat(audio): add refresh button for device list in Settings

The module-level cache prevented newly connected USB audio devices
from appearing in the dropdown. Replace with a manual refresh button
(RotateCcw icon, spins while loading) next to the dropdown.

Device enumeration now runs on every Audio tab open and on explicit
refresh. ALSA stderr noise is already suppressed by the dup2 guard,
so re-enumeration is silent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-13 18:38:58 +02:00
parent 1cf5faff21
commit fc7a5815ef
+39 -28
View File
@@ -221,10 +221,6 @@ function snapHotCacheMb(v: number): number {
return Math.round((x - 32) / 32) * 32 + 32; return Math.round((x - 32) / 32) * 32 + 32;
} }
// Module-level cache — device enumeration triggers ALSA probing on Linux (stderr noise),
// so we only enumerate once per app session.
let _audioDevicesCache: string[] | null = null;
export default function Settings() { export default function Settings() {
const auth = useAuthStore(); const auth = useAuthStore();
const theme = useThemeStore(); const theme = useThemeStore();
@@ -266,6 +262,7 @@ export default function Settings() {
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null); const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [audioDevices, setAudioDevices] = useState<string[]>([]); const [audioDevices, setAudioDevices] = useState<string[]>([]);
const [deviceSwitching, setDeviceSwitching] = useState(false); const [deviceSwitching, setDeviceSwitching] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false); const [showClearConfirm, setShowClearConfirm] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
const [contributorsOpen, setContributorsOpen] = useState(false); const [contributorsOpen, setContributorsOpen] = useState(false);
@@ -282,15 +279,18 @@ export default function Settings() {
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
// Load available audio output devices when Audio tab is first opened. const refreshAudioDevices = () => {
// Uses a module-level cache so ALSA enumeration (noisy on Linux) only happens once per session. setDevicesLoading(true);
invoke<string[]>('audio_list_devices')
.then(setAudioDevices)
.catch(() => {})
.finally(() => setDevicesLoading(false));
};
// Load available audio output devices when Audio tab opens.
useEffect(() => { useEffect(() => {
if (activeTab !== 'audio') return; if (activeTab !== 'audio') return;
if (_audioDevicesCache) { setAudioDevices(_audioDevicesCache); return; } refreshAudioDevices();
invoke<string[]>('audio_list_devices').then(devices => {
_audioDevicesCache = devices;
setAudioDevices(devices);
}).catch(() => {});
}, [activeTab]); }, [activeTab]);
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */ /** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
@@ -517,23 +517,34 @@ export default function Settings() {
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}> <div style={{ fontSize: 12, color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('settings.audioOutputDeviceDesc')} {t('settings.audioOutputDeviceDesc')}
</div> </div>
<CustomSelect <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
value={auth.audioOutputDevice ?? ''} <CustomSelect
disabled={deviceSwitching} style={{ flex: 1 }}
onChange={async (val) => { value={auth.audioOutputDevice ?? ''}
const device = val || null; disabled={deviceSwitching || devicesLoading}
setDeviceSwitching(true); onChange={async (val) => {
try { const device = val || null;
await invoke('audio_set_device', { deviceName: device }); setDeviceSwitching(true);
auth.setAudioOutputDevice(device); try {
} catch { /* device open failed — don't persist */ } await invoke('audio_set_device', { deviceName: device });
setDeviceSwitching(false); auth.setAudioOutputDevice(device);
}} } catch { /* device open failed — don't persist */ }
options={[ setDeviceSwitching(false);
{ value: '', label: t('settings.audioOutputDeviceDefault') }, }}
...audioDevices.map(d => ({ value: d, label: d })), options={[
]} { value: '', label: t('settings.audioOutputDeviceDefault') },
/> ...audioDevices.map(d => ({ value: d, label: d })),
]}
/>
<button
className="icon-btn"
onClick={refreshAudioDevices}
disabled={devicesLoading || deviceSwitching}
data-tooltip={t('common.refresh')}
>
<RotateCcw size={15} className={devicesLoading ? 'spin' : ''} />
</button>
</div>
</div> </div>
</section> </section>