diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 500da89e..d8091cc3 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -2911,34 +2911,53 @@ pub async fn audio_play_radio( Ok(()) } +/// ALSA probes noisy plugins during device queries — suppress stderr on Unix. +#[cfg(unix)] +fn with_suppressed_alsa_stderr(f: impl FnOnce() -> R) -> R { + struct StderrGuard(i32); + impl Drop for StderrGuard { + fn drop(&mut self) { + unsafe { libc::dup2(self.0, 2); libc::close(self.0); } + } + } + let _guard = unsafe { + let saved = libc::dup(2); + let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY); + libc::dup2(devnull, 2); + libc::close(devnull); + StderrGuard(saved) + }; + f() +} + +#[cfg(not(unix))] +#[inline] +fn with_suppressed_alsa_stderr(f: impl FnOnce() -> R) -> R { + f() +} + /// Returns the names of all available audio output devices on the current host. /// On Linux, ALSA probes unavailable backends (JACK, OSS, dmix) and prints errors to /// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean. #[tauri::command] pub fn audio_list_devices() -> Vec { use rodio::cpal::traits::{DeviceTrait, HostTrait}; + with_suppressed_alsa_stderr(|| { + let host = rodio::cpal::default_host(); + host.output_devices() + .map(|iter| iter.filter_map(|d| d.name().ok()).collect()) + .unwrap_or_default() + }) +} - #[cfg(unix)] - let _guard = { - struct StderrGuard(i32); - impl Drop for StderrGuard { - fn drop(&mut self) { - unsafe { libc::dup2(self.0, 2); libc::close(self.0); } - } - } - unsafe { - let saved = libc::dup(2); - let devnull = libc::open(b"/dev/null\0".as_ptr() as *const libc::c_char, libc::O_WRONLY); - libc::dup2(devnull, 2); - libc::close(devnull); - StderrGuard(saved) - } - }; - - let host = rodio::cpal::default_host(); - host.output_devices() - .map(|iter| iter.filter_map(|d| d.name().ok()).collect()) - .unwrap_or_default() +/// Device id string for the host default output (matches an entry from `audio_list_devices` when present). +#[tauri::command] +pub fn audio_default_output_device_name() -> Option { + use rodio::cpal::traits::{DeviceTrait, HostTrait}; + with_suppressed_alsa_stderr(|| { + let host = rodio::cpal::default_host(); + host.default_output_device().and_then(|d| d.name().ok()) + }) } /// Switch the audio output device. `device_name = null` → follow system default. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c2623339..01b3df6e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1714,6 +1714,7 @@ pub fn run() { audio::audio_set_crossfade, audio::audio_set_gapless, audio::audio_list_devices, + audio::audio_default_output_device_name, audio::audio_set_device, audio::audio_chain_preload, discord::discord_update_presence, diff --git a/src/locales/de.ts b/src/locales/de.ts index 51a6fb9e..7a6fac8d 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -503,6 +503,8 @@ export const deTranslation = { audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.', audioOutputDeviceDefault: 'Systemstandard', audioOutputDeviceRefresh: 'Geräteliste aktualisieren', + audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe', + audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.', hiResTitle: 'Native Hi-Res-Wiedergabe', hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren', hiResDesc: "Standardmäßig wird auf 44,1 kHz begrenzt (maximale Stabilität). Nur aktivieren, wenn Hardware und Netzwerk zuverlässig hohe Abtastraten (88,2 kHz+) unterstützen.", diff --git a/src/locales/en.ts b/src/locales/en.ts index 9722a8b4..fdd7bb0a 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -505,6 +505,8 @@ export const enTranslation = { audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.', audioOutputDeviceDefault: 'System Default', audioOutputDeviceRefresh: 'Refresh device list', + audioOutputDeviceOsDefaultNow: 'current system output', + audioOutputDeviceListError: 'Could not load the audio device list.', hiResTitle: 'Native Hi-Res Playback', hiResEnabled: 'Enable native hi-res playback', hiResDesc: "Forces 44.1 kHz output by default for maximum stability. Enable only if your hardware and network reliably support high sample rates (88.2 kHz+).", diff --git a/src/locales/es.ts b/src/locales/es.ts index 497661a3..e223cbb2 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -506,6 +506,8 @@ export const esTranslation = { audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.', audioOutputDeviceDefault: 'Predeterminado del sistema', audioOutputDeviceRefresh: 'Actualizar lista de dispositivos', + audioOutputDeviceOsDefaultNow: 'salida actual del sistema', + audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.', hiResTitle: 'Reproducción Nativa Hi-Res', hiResEnabled: 'Habilitar reproducción nativa hi-res', hiResDesc: "Fuerza 44.1 kHz de salida por defecto para máxima estabilidad. Habilita solo si tu hardware y red soportan confiablemente altas tasas de muestreo (88.2 kHz+).", diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 5f4c7d28..23e25431 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -503,6 +503,8 @@ export const frTranslation = { audioOutputDeviceDesc: 'Choisissez le périphérique audio utilisé par Psysonic. Les changements sont immédiats et relancent la piste en cours.', audioOutputDeviceDefault: 'Défaut système', audioOutputDeviceRefresh: 'Actualiser la liste', + audioOutputDeviceOsDefaultNow: 'sortie système actuelle', + audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.', hiResTitle: 'Lecture haute résolution native', hiResEnabled: 'Activer la lecture haute résolution native', hiResDesc: "Force une sortie à 44,1 kHz par défaut pour une stabilité maximale. N'activer que si le matériel et le réseau prennent en charge les hautes fréquences d'échantillonnage (88,2 kHz+).", diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 445645dd..ac991e16 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -504,6 +504,8 @@ export const nbTranslation = { audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.', audioOutputDeviceDefault: 'Systemstandard', audioOutputDeviceRefresh: 'Oppdater enhetsliste', + audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang', + audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.', hiResTitle: 'Innebygd hi-res-avspilling', hiResEnabled: 'Aktiver innebygd hi-res-avspilling', hiResDesc: "Begrenser utdata til 44,1 kHz som standard for maksimal stabilitet. Aktiver kun hvis maskinvare og nettverk støtter høye samplingsrater (88,2 kHz+) pålitelig.", diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 53001b8e..5d285840 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -502,6 +502,8 @@ export const nlTranslation = { audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.', audioOutputDeviceDefault: 'Systeemstandaard', audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen', + audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer', + audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.', hiResTitle: 'Natieve hi-res-weergave', hiResEnabled: 'Natieve hi-res-weergave inschakelen', hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index dc1bad43..29a5d4b3 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -520,6 +520,8 @@ export const ruTranslation = { audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.', audioOutputDeviceDefault: 'Системное по умолчанию', audioOutputDeviceRefresh: 'Обновить список устройств', + audioOutputDeviceOsDefaultNow: 'текущий системный вывод', + audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.', hiResTitle: 'Нативное воспроизведение Hi-Res', hiResEnabled: 'Включить нативное Hi-Res воспроизведение', hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 57dbc527..ec4bca85 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -498,6 +498,8 @@ export const zhTranslation = { audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。', audioOutputDeviceDefault: '系统默认', audioOutputDeviceRefresh: '刷新设备列表', + audioOutputDeviceOsDefaultNow: '当前系统输出', + audioOutputDeviceListError: '无法加载音频设备列表。', hiResTitle: '原生高清晰度播放', hiResEnabled: '启用原生高清晰度播放', hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。", diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 16af5266..f19d789f 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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(); + 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(null); const [hotCacheBytes, setHotCacheBytes] = useState(null); const [audioDevices, setAudioDevices] = useState([]); + const [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState(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('get_hot_cache_size', { customDir: auth.hotCacheDownloadDir || null }).then(setHotCacheBytes).catch(() => setHotCacheBytes(0)); }, [activeTab, auth.offlineDownloadDir, auth.hotCacheDownloadDir]); - const refreshAudioDevices = () => { - setDevicesLoading(true); - invoke('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('audio_list_devices').catch((e) => { + console.error(e); + showToast(t('settings.audioOutputDeviceListError'), 5000, 'error'); + return [] as string[]; + }); + const defP = invoke('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'), + )} />