feat(settings): audio output device picker (labels, OS default, live refresh) (#173)

* feat(settings): clearer audio device labels for duplicate ALSA names

Show HDMI outputs as "Card (HDMI n)" from hdmi:DEV indices; include PCM and
optional subdevice for hw/plughw/sysdefault; label other ALSA plugins with
iface and PCM. When labels still collide, append a structured hint
(iface · card · PCM) instead of only truncating the raw device string.

* feat(settings): improve audio output device picker

Parse ALSA-style ids into clearer labels (HDMI with DEV index, PCM/subdevice
for hw/plughw/sysdefault). Disambiguate colliding labels; share stderr
suppression for Linux device enumeration.

Add audio_default_output_device_name and tag the matching list entry as the
current system output (i18n). While the Audio tab is open, refresh list and
mark on audio:device-changed and audio:device-reset without toggling the
refresh spinner. Show an error toast if listing devices fails.
This commit is contained in:
cucadmuh
2026-04-13 21:13:22 +03:00
committed by GitHub
parent e75fda168e
commit 9cd4743d1c
11 changed files with 187 additions and 40 deletions
+130 -19
View File
@@ -11,6 +11,7 @@ import i18n from '../i18n';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
import { useOfflineStore } from '../store/offlineStore';
@@ -224,22 +225,95 @@ function snapHotCacheMb(v: number): number {
/** Makes raw ALSA device names more readable on Linux.
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
* e.g. "sysdefault:CARD=U192k" → "U192k"
* "hw:CARD=U192k,DEV=0" → "U192k (hw)"
* "hw:CARD=U192k,DEV=0" → "U192k (hw · PCM 0)"
* "hdmi:CARD=NVidia,DEV=1" → "NVidia (HDMI · DEV 1)" (same DEV as in ALSA string)
* "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
* Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
function formatAudioDeviceLabel(name: string): string {
const cardMatch = name.match(/CARD=([^,]+)/);
if (!cardMatch) return name;
const card = cardMatch[1];
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
if (name.startsWith('sysdefault:')) return card;
if (name.startsWith('plughw:')) return card;
if (name.startsWith('hw:')) return `${card} (hw)`;
if (name.startsWith('front:')) return `${card} (Front)`;
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
const devM = name.match(/DEV=(\d+)/);
const devNum = devM ? parseInt(devM[1], 10) : null;
const subM = name.match(/SUBDEV=(\d+)/);
const subNum = subM ? parseInt(subM[1], 10) : null;
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
if (name.startsWith('hdmi:')) {
const d = devNum !== null ? devNum : 0;
return `${card} (HDMI · DEV ${d})`;
}
if (name.startsWith('sysdefault:')) {
if (devNum !== null && devNum > 0) return `${card} (default · PCM ${devNum})`;
return card;
}
if (name.startsWith('plughw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (plug · PCM ${devNum}${sub})`;
}
return card;
}
if (name.startsWith('hw:')) {
if (devNum !== null) {
const sub = subNum !== null ? ` · sub ${subNum}` : '';
return `${card} (hw · PCM ${devNum}${sub})`;
}
return `${card} (hw)`;
}
if (name.startsWith('front:')) return `${card} (Front)`;
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
// Other ALSA iface:card,dev — show plugin + PCM so identical cards differ
const iface = name.split(':')[0];
if (iface && !['default', 'pulse', 'pipewire'].includes(iface)) {
if (devNum !== null) return `${card} (${iface} · PCM ${devNum})`;
return `${card} (${iface})`;
}
return card;
}
/** Readable tail when two devices still share the same label (rare after formatAudioDeviceLabel). */
function audioDeviceDuplicateHint(raw: string): string {
const cardM = raw.match(/CARD=([^,]+)/);
const devM = raw.match(/DEV=(\d+)/);
const subM = raw.match(/SUBDEV=(\d+)/);
const iface = raw.split(':')[0] || '';
const parts: string[] = [];
if (iface) parts.push(iface);
if (cardM) parts.push(cardM[1]);
if (devM) parts.push(`PCM ${devM[1]}`);
if (subM) parts.push(`sub ${subM[1]}`);
if (parts.length > 1) return parts.join(' · ');
return raw.length > 56 ? `${raw.slice(-53)}` : raw;
}
/** When several devices share the same display label, append a disambiguator. */
function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicateBase: boolean): string {
if (!duplicateBase) return baseLabel;
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
}
function buildAudioDeviceSelectOptions(
devices: string[],
defaultLabel: string,
osDefaultDeviceId: string | null,
osDefaultMark: string,
): { value: string; label: string }[] {
const baseLabels = devices.map(formatAudioDeviceLabel);
const countByBase = new Map<string, number>();
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
return [
{ value: '', label: defaultLabel },
...devices.map((d, i) => {
const base = baseLabels[i];
const dup = (countByBase.get(base) ?? 0) > 1;
let label = disambiguatedAudioDeviceLabel(d, base, dup);
if (osDefaultDeviceId && d === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
return { value: d, label };
}),
];
}
export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
@@ -280,6 +354,7 @@ export default function Settings() {
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
const [hotCacheBytes, setHotCacheBytes] = useState<number | null>(null);
const [audioDevices, setAudioDevices] = useState<string[]>([]);
const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
const [deviceSwitching, setDeviceSwitching] = useState(false);
const [devicesLoading, setDevicesLoading] = useState(false);
const [showClearConfirm, setShowClearConfirm] = useState(false);
@@ -298,19 +373,53 @@ export default function Settings() {
invoke<number>('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0));
}, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]);
const refreshAudioDevices = () => {
setDevicesLoading(true);
invoke<string[]>('audio_list_devices')
.then(setAudioDevices)
.catch(() => {})
.finally(() => setDevicesLoading(false));
};
const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => {
const silent = !!opts?.silent;
if (!silent) setDevicesLoading(true);
const listP = invoke<string[]>('audio_list_devices').catch((e) => {
console.error(e);
showToast(t('settings.audioOutputDeviceListError'), 5000, 'error');
return [] as string[];
});
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
Promise.all([listP, defP])
.then(([devices, osDefault]) => {
setAudioDevices(devices);
setOsDefaultAudioDeviceId(osDefault ?? null);
})
.finally(() => {
if (!silent) setDevicesLoading(false);
});
}, [t]);
// Load available audio output devices when Audio tab opens.
useEffect(() => {
if (activeTab !== 'audio') return;
refreshAudioDevices();
}, [activeTab]);
}, [activeTab, refreshAudioDevices]);
// Keep device list + "current system output" mark in sync when the backend reopens the stream.
useEffect(() => {
if (activeTab !== 'audio') return;
let cancelled = false;
const unlisteners: Array<() => void> = [];
(async () => {
for (const ev of ['audio:device-changed', 'audio:device-reset'] as const) {
const u = await listen(ev, () => {
if (!cancelled) refreshAudioDevices({ silent: true });
});
if (cancelled) {
u();
return;
}
unlisteners.push(u);
}
})();
return () => {
cancelled = true;
for (const u of unlisteners) u();
};
}, [activeTab, refreshAudioDevices]);
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
useEffect(() => {
@@ -550,10 +659,12 @@ export default function Settings() {
} catch { /* device open failed — don't persist */ }
setDeviceSwitching(false);
}}
options={[
{ value: '', label: t('settings.audioOutputDeviceDefault') },
...audioDevices.map(d => ({ value: d, label: formatAudioDeviceLabel(d) })),
]}
options={buildAudioDeviceSelectOptions(
audioDevices,
t('settings.audioOutputDeviceDefault'),
osDefaultAudioDeviceId,
t('settings.audioOutputDeviceOsDefaultNow'),
)}
/>
<button
className="icon-btn"