mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
+130
-19
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user