mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
fix(audio): Linux output device selection and watcher (#176)
* fix(audio): stabilize Linux output device picker and watcher Keep pinned ALSA/cpal device ids stable when enumeration omits the active sink or returns an equivalent name. Stop Linux device-watcher from clearing the pin based on missing list entries; macOS and Windows still treat repeated absence as unplugged. Settings refresh flow calls canonicalize and refetches the list; add i18n for the out-of-list device label. * fix(settings): sort audio output devices by label cpal enumeration order is arbitrary; order the dropdown by readable label and place the current OS default device first among concrete outputs.
This commit is contained in:
+114
-21
@@ -2936,11 +2936,7 @@ fn with_suppressed_alsa_stderr<R>(f: impl FnOnce() -> R) -> R {
|
|||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the names of all available audio output devices on the current host.
|
fn enumerate_output_device_names() -> Vec<String> {
|
||||||
/// 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<String> {
|
|
||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
with_suppressed_alsa_stderr(|| {
|
with_suppressed_alsa_stderr(|| {
|
||||||
let host = rodio::cpal::default_host();
|
let host = rodio::cpal::default_host();
|
||||||
@@ -2950,6 +2946,92 @@ pub fn audio_list_devices() -> Vec<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Linux ALSA-style cpal names: same physical sink can appear with different suffixes;
|
||||||
|
/// busy devices are sometimes omitted from `output_devices()` while playback works.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_alsa_sink_fingerprint(name: &str) -> Option<(String, String, u32)> {
|
||||||
|
const IFACES: &[&str] = &[
|
||||||
|
"hdmi", "hw", "plughw", "sysdefault", "iec958", "front", "dmix", "surround40",
|
||||||
|
"surround51", "surround71",
|
||||||
|
];
|
||||||
|
let colon = name.find(':')?;
|
||||||
|
let iface = name[..colon].to_ascii_lowercase();
|
||||||
|
if !IFACES.iter().any(|&i| i == iface.as_str()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let card = name.split("CARD=").nth(1)?.split(',').next()?.to_string();
|
||||||
|
let dev = name
|
||||||
|
.split("DEV=")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split(',').next())
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
Some((iface, card, dev))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
#[inline]
|
||||||
|
fn linux_alsa_sink_fingerprint(_name: &str) -> Option<(String, String, u32)> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn output_devices_logically_same(a: &str, b: &str) -> bool {
|
||||||
|
if a == b {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match (
|
||||||
|
linux_alsa_sink_fingerprint(a),
|
||||||
|
linux_alsa_sink_fingerprint(b),
|
||||||
|
) {
|
||||||
|
(Some(fa), Some(fb)) => fa == fb,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `pinned` is the same sink as some entry (exact or Linux ALSA logical match).
|
||||||
|
fn output_enumeration_includes_pinned(available: &[String], pinned: &str) -> bool {
|
||||||
|
available
|
||||||
|
.iter()
|
||||||
|
.any(|d| output_devices_logically_same(d, pinned))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the pinned id is missing from cpal's list but another listed id is the same
|
||||||
|
/// physical sink (e.g. suffix drift), rewrite `selected_device` to the listed form.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_canonicalize_selected_device(state: State<'_, AudioEngine>) -> Option<String> {
|
||||||
|
let pinned = state.selected_device.lock().unwrap().clone()?;
|
||||||
|
if pinned.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let list = enumerate_output_device_names();
|
||||||
|
if list.iter().any(|d| d == &pinned) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let canon = list
|
||||||
|
.iter()
|
||||||
|
.find(|d| output_devices_logically_same(d, &pinned))?
|
||||||
|
.clone();
|
||||||
|
*state.selected_device.lock().unwrap() = Some(canon.clone());
|
||||||
|
Some(canon)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
///
|
||||||
|
/// The user-pinned device name is appended when cpal omits it (e.g. HDMI busy while
|
||||||
|
/// streaming) so the Settings dropdown still matches `audioOutputDevice`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn audio_list_devices(state: State<'_, AudioEngine>) -> Vec<String> {
|
||||||
|
let mut list = enumerate_output_device_names();
|
||||||
|
if let Some(ref name) = *state.selected_device.lock().unwrap() {
|
||||||
|
if !name.is_empty() && !output_enumeration_includes_pinned(&list, name) {
|
||||||
|
list.push(name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list
|
||||||
|
}
|
||||||
|
|
||||||
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_default_output_device_name() -> Option<String> {
|
pub fn audio_default_output_device_name() -> Option<String> {
|
||||||
@@ -3006,9 +3088,10 @@ pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
|
|||||||
// Polls every 3 s for two conditions:
|
// Polls every 3 s for two conditions:
|
||||||
// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no
|
// 1. System default device changed (Bluetooth, USB DAC plug/unplug) while no
|
||||||
// device is pinned → reopen on new default, emit audio:device-changed.
|
// device is pinned → reopen on new default, emit audio:device-changed.
|
||||||
// 2. User-pinned device disappeared (DAC unplugged) → fall back to system
|
// 2. (macOS / Windows only) User-pinned device disappeared from cpal's list →
|
||||||
// default, clear selected_device, emit audio:device-reset so the frontend
|
// fall back to system default, clear selected_device, emit audio:device-reset.
|
||||||
// can reset its dropdown and persist the change.
|
// Linux: case 2 is disabled — ALSA/cpal often omit the active sink from
|
||||||
|
// enumeration while streaming, which caused false resets to system default.
|
||||||
|
|
||||||
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
||||||
let reopen_tx = engine.stream_reopen_tx.clone();
|
let reopen_tx = engine.stream_reopen_tx.clone();
|
||||||
@@ -3026,10 +3109,8 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
.and_then(|d| d.name().ok())
|
.and_then(|d| d.name().ok())
|
||||||
}).await.unwrap_or(None);
|
}).await.unwrap_or(None);
|
||||||
|
|
||||||
// How many consecutive checks the pinned device has been absent.
|
// macOS/Windows: consecutive polls where a pinned device is absent from cpal's list.
|
||||||
// ALSA can temporarily fail to enumerate a device that is busy (e.g. actively
|
#[cfg(not(target_os = "linux"))]
|
||||||
// streaming), so we require 3 consecutive misses (~9 s) before treating it
|
|
||||||
// as truly unplugged.
|
|
||||||
let mut pinned_miss_count: u32 = 0;
|
let mut pinned_miss_count: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -3060,14 +3141,31 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
(default, available)
|
(default, available)
|
||||||
}).await.unwrap_or((None, vec![]));
|
}).await.unwrap_or((None, vec![]));
|
||||||
|
|
||||||
|
// Empty list almost always means a transient enumeration failure, not
|
||||||
|
// that every output device vanished. Treating it as "pinned missing"
|
||||||
|
// caused false audio:device-reset (UI jumped back to system default)
|
||||||
|
// when switching to external USB / class-compliant interfaces.
|
||||||
|
if available.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let pinned = selected_device.lock().unwrap().clone();
|
let pinned = selected_device.lock().unwrap().clone();
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if pinned.is_some() {
|
||||||
|
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
|
||||||
|
// ALSA/cpal often omit the active HDMI/USB sink from enumeration for the
|
||||||
|
// whole session — any miss counter eventually tripped audio:device-reset.
|
||||||
|
// Clearing the pin is left to the user (Settings → System Default) or
|
||||||
|
// to a future explicit error signal from the output stream.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Case 2 (non-Linux): pinned device disappeared from enumeration ─
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
if let Some(ref dev_name) = pinned {
|
if let Some(ref dev_name) = pinned {
|
||||||
// ── Case 2: pinned device disappeared ────────────────────────
|
if !output_enumeration_includes_pinned(&available, dev_name) {
|
||||||
if !available.iter().any(|d| d == dev_name) {
|
|
||||||
pinned_miss_count += 1;
|
pinned_miss_count += 1;
|
||||||
// Only act after 3 consecutive misses (~9 s) to avoid false
|
|
||||||
// positives when ALSA temporarily hides a busy device.
|
|
||||||
if pinned_miss_count < 3 {
|
if pinned_miss_count < 3 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -3075,7 +3173,6 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
pinned_miss_count = 0;
|
pinned_miss_count = 0;
|
||||||
*selected_device.lock().unwrap() = None;
|
*selected_device.lock().unwrap() = None;
|
||||||
|
|
||||||
// Debounce so the OS finishes reconfiguring.
|
|
||||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
let rate = stream_rate.load(Ordering::Relaxed);
|
let rate = stream_rate.load(Ordering::Relaxed);
|
||||||
@@ -3093,17 +3190,13 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
|
|||||||
*stream_handle.lock().unwrap() = handle;
|
*stream_handle.lock().unwrap() = handle;
|
||||||
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
|
if let Some(s) = current.lock().unwrap().sink.take() { s.stop(); }
|
||||||
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
if let Some(s) = fading_out.lock().unwrap().take() { s.stop(); }
|
||||||
// audio:device-reset tells the frontend to clear its
|
|
||||||
// audioOutputDevice setting and restart playback.
|
|
||||||
app.emit("audio:device-reset", ()).ok();
|
app.emit("audio:device-reset", ()).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
last_default = current_default;
|
last_default = current_default;
|
||||||
} else {
|
} else {
|
||||||
// Device is present — reset miss counter.
|
|
||||||
pinned_miss_count = 0;
|
pinned_miss_count = 0;
|
||||||
}
|
}
|
||||||
// Pinned device still present (or not yet confirmed gone) — nothing to do.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1714,6 +1714,7 @@ pub fn run() {
|
|||||||
audio::audio_set_crossfade,
|
audio::audio_set_crossfade,
|
||||||
audio::audio_set_gapless,
|
audio::audio_set_gapless,
|
||||||
audio::audio_list_devices,
|
audio::audio_list_devices,
|
||||||
|
audio::audio_canonicalize_selected_device,
|
||||||
audio::audio_default_output_device_name,
|
audio::audio_default_output_device_name,
|
||||||
audio::audio_set_device,
|
audio::audio_set_device,
|
||||||
audio::audio_chain_preload,
|
audio::audio_chain_preload,
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ export const deTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
|
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
|
||||||
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
|
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
|
||||||
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
|
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'nicht in der aktuellen Liste',
|
||||||
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
hiResTitle: 'Native Hi-Res-Wiedergabe',
|
||||||
hiResEnabled: 'Native Hi-Res-Wiedergabe aktivieren',
|
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.",
|
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.",
|
||||||
|
|||||||
@@ -507,6 +507,7 @@ export const enTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Refresh device list',
|
audioOutputDeviceRefresh: 'Refresh device list',
|
||||||
audioOutputDeviceOsDefaultNow: 'current system output',
|
audioOutputDeviceOsDefaultNow: 'current system output',
|
||||||
audioOutputDeviceListError: 'Could not load the audio device list.',
|
audioOutputDeviceListError: 'Could not load the audio device list.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'not in current list',
|
||||||
hiResTitle: 'Native Hi-Res Playback',
|
hiResTitle: 'Native Hi-Res Playback',
|
||||||
hiResEnabled: 'Enable 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -508,6 +508,7 @@ export const esTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
|
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
|
||||||
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
|
audioOutputDeviceOsDefaultNow: 'salida actual del sistema',
|
||||||
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
|
audioOutputDeviceListError: 'No se pudo cargar la lista de dispositivos de audio.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'no está en la lista actual',
|
||||||
hiResTitle: 'Reproducción Nativa Hi-Res',
|
hiResTitle: 'Reproducción Nativa Hi-Res',
|
||||||
hiResEnabled: 'Habilitar 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -505,6 +505,7 @@ export const frTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Actualiser la liste',
|
audioOutputDeviceRefresh: 'Actualiser la liste',
|
||||||
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
|
audioOutputDeviceOsDefaultNow: 'sortie système actuelle',
|
||||||
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
|
audioOutputDeviceListError: 'Impossible de charger la liste des périphériques audio.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'absent de la liste actuelle',
|
||||||
hiResTitle: 'Lecture haute résolution native',
|
hiResTitle: 'Lecture haute résolution native',
|
||||||
hiResEnabled: 'Activer la 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+).",
|
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+).",
|
||||||
|
|||||||
@@ -506,6 +506,7 @@ export const nbTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
|
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
|
||||||
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
|
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
|
||||||
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
|
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'ikke i gjeldende liste',
|
||||||
hiResTitle: 'Innebygd hi-res-avspilling',
|
hiResTitle: 'Innebygd hi-res-avspilling',
|
||||||
hiResEnabled: 'Aktiver 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.",
|
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.",
|
||||||
|
|||||||
@@ -504,6 +504,7 @@ export const nlTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
|
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
|
||||||
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
|
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
|
||||||
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
|
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'staat niet in de huidige lijst',
|
||||||
hiResTitle: 'Natieve hi-res-weergave',
|
hiResTitle: 'Natieve hi-res-weergave',
|
||||||
hiResEnabled: 'Natieve hi-res-weergave inschakelen',
|
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.",
|
hiResDesc: "Beperkt de uitvoer standaard tot 44,1 kHz voor maximale stabiliteit. Alleen inschakelen als hardware en netwerk hoge samplerates (88,2 kHz+) ondersteunen.",
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ export const ruTranslation = {
|
|||||||
audioOutputDeviceRefresh: 'Обновить список устройств',
|
audioOutputDeviceRefresh: 'Обновить список устройств',
|
||||||
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
|
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
|
||||||
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
|
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
|
||||||
|
audioOutputDeviceNotInCurrentList: 'нет в текущем списке',
|
||||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||||
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||||
|
|||||||
@@ -500,6 +500,7 @@ export const zhTranslation = {
|
|||||||
audioOutputDeviceRefresh: '刷新设备列表',
|
audioOutputDeviceRefresh: '刷新设备列表',
|
||||||
audioOutputDeviceOsDefaultNow: '当前系统输出',
|
audioOutputDeviceOsDefaultNow: '当前系统输出',
|
||||||
audioOutputDeviceListError: '无法加载音频设备列表。',
|
audioOutputDeviceListError: '无法加载音频设备列表。',
|
||||||
|
audioOutputDeviceNotInCurrentList: '不在当前列表中',
|
||||||
hiResTitle: '原生高清晰度播放',
|
hiResTitle: '原生高清晰度播放',
|
||||||
hiResEnabled: '启用原生高清晰度播放',
|
hiResEnabled: '启用原生高清晰度播放',
|
||||||
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||||
|
|||||||
+44
-4
@@ -300,17 +300,44 @@ function disambiguatedAudioDeviceLabel(raw: string, baseLabel: string, duplicate
|
|||||||
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
|
return `${baseLabel} · ${audioDeviceDuplicateHint(raw)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** cpal order is arbitrary; sort by readable label, current OS default first. */
|
||||||
|
function sortAudioDeviceIds(devices: string[], osDefaultDeviceId: string | null): string[] {
|
||||||
|
return [...devices].sort((a, b) => {
|
||||||
|
const aDef = osDefaultDeviceId && a === osDefaultDeviceId;
|
||||||
|
const bDef = osDefaultDeviceId && b === osDefaultDeviceId;
|
||||||
|
if (aDef !== bDef) return aDef ? -1 : 1;
|
||||||
|
const la = formatAudioDeviceLabel(a);
|
||||||
|
const lb = formatAudioDeviceLabel(b);
|
||||||
|
const byLabel = la.localeCompare(lb, undefined, { sensitivity: 'base' });
|
||||||
|
if (byLabel !== 0) return byLabel;
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildAudioDeviceSelectOptions(
|
function buildAudioDeviceSelectOptions(
|
||||||
devices: string[],
|
devices: string[],
|
||||||
defaultLabel: string,
|
defaultLabel: string,
|
||||||
osDefaultDeviceId: string | null,
|
osDefaultDeviceId: string | null,
|
||||||
osDefaultMark: string,
|
osDefaultMark: string,
|
||||||
|
pinnedDevice: string | null,
|
||||||
|
notInListSuffix: string,
|
||||||
): { value: string; label: string }[] {
|
): { value: string; label: string }[] {
|
||||||
const baseLabels = devices.map(formatAudioDeviceLabel);
|
const baseLabels = devices.map(formatAudioDeviceLabel);
|
||||||
const countByBase = new Map<string, number>();
|
const countByBase = new Map<string, number>();
|
||||||
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
|
for (const b of baseLabels) countByBase.set(b, (countByBase.get(b) ?? 0) + 1);
|
||||||
|
const pinned = pinnedDevice?.trim() || null;
|
||||||
|
const pinnedNotListed = !!(pinned && !devices.includes(pinned));
|
||||||
|
const ghost: { value: string; label: string }[] = pinnedNotListed
|
||||||
|
? (() => {
|
||||||
|
const base = formatAudioDeviceLabel(pinned);
|
||||||
|
let label = `${base} · ${notInListSuffix}`;
|
||||||
|
if (osDefaultDeviceId && pinned === osDefaultDeviceId) label = `${label} · ${osDefaultMark}`;
|
||||||
|
return [{ value: pinned, label }];
|
||||||
|
})()
|
||||||
|
: [];
|
||||||
return [
|
return [
|
||||||
{ value: '', label: defaultLabel },
|
{ value: '', label: defaultLabel },
|
||||||
|
...ghost,
|
||||||
...devices.map((d, i) => {
|
...devices.map((d, i) => {
|
||||||
const base = baseLabels[i];
|
const base = baseLabels[i];
|
||||||
const dup = (countByBase.get(base) ?? 0) > 1;
|
const dup = (countByBase.get(base) ?? 0) > 1;
|
||||||
@@ -390,9 +417,20 @@ export default function Settings() {
|
|||||||
});
|
});
|
||||||
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
|
const defP = invoke<string | null>('audio_default_output_device_name').catch(() => null);
|
||||||
Promise.all([listP, defP])
|
Promise.all([listP, defP])
|
||||||
.then(([devices, osDefault]) => {
|
.then(async ([devices, osDefault]) => {
|
||||||
setAudioDevices(devices);
|
let canon: string | null = null;
|
||||||
setOsDefaultAudioDeviceId(osDefault ?? null);
|
try {
|
||||||
|
canon = await invoke<string | null>('audio_canonicalize_selected_device');
|
||||||
|
if (canon) useAuthStore.getState().setAudioOutputDevice(canon);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
const finalList = canon
|
||||||
|
? await invoke<string[]>('audio_list_devices').catch(() => devices)
|
||||||
|
: devices;
|
||||||
|
const defId = osDefault ?? null;
|
||||||
|
setAudioDevices(sortAudioDeviceIds(finalList, defId));
|
||||||
|
setOsDefaultAudioDeviceId(defId);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (!silent) setDevicesLoading(false);
|
if (!silent) setDevicesLoading(false);
|
||||||
@@ -671,11 +709,13 @@ export default function Settings() {
|
|||||||
t('settings.audioOutputDeviceDefault'),
|
t('settings.audioOutputDeviceDefault'),
|
||||||
osDefaultAudioDeviceId,
|
osDefaultAudioDeviceId,
|
||||||
t('settings.audioOutputDeviceOsDefaultNow'),
|
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||||
|
auth.audioOutputDevice,
|
||||||
|
t('settings.audioOutputDeviceNotInCurrentList'),
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
onClick={refreshAudioDevices}
|
onClick={() => refreshAudioDevices()}
|
||||||
disabled={devicesLoading || deviceSwitching}
|
disabled={devicesLoading || deviceSwitching}
|
||||||
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
data-tooltip={t('settings.audioOutputDeviceRefresh')}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user