mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +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:
+40
-21
@@ -2911,34 +2911,53 @@ pub async fn audio_play_radio(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ALSA probes noisy plugins during device queries — suppress stderr on Unix.
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn with_suppressed_alsa_stderr<R>(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<R>(f: impl FnOnce() -> R) -> R {
|
||||||
|
f()
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the names of all available audio output devices on the current host.
|
/// 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
|
/// 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.
|
/// stderr. We suppress fd 2 for the duration of enumeration to keep the terminal clean.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn audio_list_devices() -> Vec<String> {
|
pub fn audio_list_devices() -> Vec<String> {
|
||||||
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
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)]
|
/// Device id string for the host default output (matches an entry from `audio_list_devices` when present).
|
||||||
let _guard = {
|
#[tauri::command]
|
||||||
struct StderrGuard(i32);
|
pub fn audio_default_output_device_name() -> Option<String> {
|
||||||
impl Drop for StderrGuard {
|
use rodio::cpal::traits::{DeviceTrait, HostTrait};
|
||||||
fn drop(&mut self) {
|
with_suppressed_alsa_stderr(|| {
|
||||||
unsafe { libc::dup2(self.0, 2); libc::close(self.0); }
|
let host = rodio::cpal::default_host();
|
||||||
}
|
host.default_output_device().and_then(|d| d.name().ok())
|
||||||
}
|
})
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switch the audio output device. `device_name = null` → follow system default.
|
/// Switch the audio output device. `device_name = null` → follow system default.
|
||||||
|
|||||||
@@ -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_default_output_device_name,
|
||||||
audio::audio_set_device,
|
audio::audio_set_device,
|
||||||
audio::audio_chain_preload,
|
audio::audio_chain_preload,
|
||||||
discord::discord_update_presence,
|
discord::discord_update_presence,
|
||||||
|
|||||||
@@ -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.',
|
audioOutputDeviceDesc: 'Wähle das Audiogerät, über das Psysonic spielt. Änderungen werden sofort übernommen und starten den aktuellen Track neu.',
|
||||||
audioOutputDeviceDefault: 'Systemstandard',
|
audioOutputDeviceDefault: 'Systemstandard',
|
||||||
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
|
audioOutputDeviceRefresh: 'Geräteliste aktualisieren',
|
||||||
|
audioOutputDeviceOsDefaultNow: 'aktuelle Systemausgabe',
|
||||||
|
audioOutputDeviceListError: 'Audiogeräteliste konnte nicht geladen werden.',
|
||||||
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.",
|
||||||
|
|||||||
@@ -505,6 +505,8 @@ export const enTranslation = {
|
|||||||
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
|
audioOutputDeviceDesc: 'Select which audio device Psysonic plays through. Changes take effect immediately and restart the current track.',
|
||||||
audioOutputDeviceDefault: 'System Default',
|
audioOutputDeviceDefault: 'System Default',
|
||||||
audioOutputDeviceRefresh: 'Refresh device list',
|
audioOutputDeviceRefresh: 'Refresh device list',
|
||||||
|
audioOutputDeviceOsDefaultNow: 'current system output',
|
||||||
|
audioOutputDeviceListError: 'Could not load the audio device 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+).",
|
||||||
|
|||||||
@@ -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.',
|
audioOutputDeviceDesc: 'Selecciona el dispositivo de audio que usa Psysonic. Los cambios surten efecto de inmediato y reinician la pista actual.',
|
||||||
audioOutputDeviceDefault: 'Predeterminado del sistema',
|
audioOutputDeviceDefault: 'Predeterminado del sistema',
|
||||||
audioOutputDeviceRefresh: 'Actualizar lista de dispositivos',
|
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',
|
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+).",
|
||||||
|
|||||||
@@ -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.',
|
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',
|
audioOutputDeviceDefault: 'Défaut système',
|
||||||
audioOutputDeviceRefresh: 'Actualiser la liste',
|
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',
|
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+).",
|
||||||
|
|||||||
@@ -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.',
|
audioOutputDeviceDesc: 'Velg hvilken lydenhet Psysonic spiller gjennom. Endringer trer i kraft umiddelbart og starter gjeldende spor på nytt.',
|
||||||
audioOutputDeviceDefault: 'Systemstandard',
|
audioOutputDeviceDefault: 'Systemstandard',
|
||||||
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
|
audioOutputDeviceRefresh: 'Oppdater enhetsliste',
|
||||||
|
audioOutputDeviceOsDefaultNow: 'gjeldende systemutgang',
|
||||||
|
audioOutputDeviceListError: 'Kunne ikke laste listen over lydenheter.',
|
||||||
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.",
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ export const nlTranslation = {
|
|||||||
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
|
audioOutputDeviceDesc: 'Kies via welk audioapparaat Psysonic afspeelt. Wijzigingen worden direct toegepast en starten het huidige nummer opnieuw.',
|
||||||
audioOutputDeviceDefault: 'Systeemstandaard',
|
audioOutputDeviceDefault: 'Systeemstandaard',
|
||||||
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
|
audioOutputDeviceRefresh: 'Apparatenlijst vernieuwen',
|
||||||
|
audioOutputDeviceOsDefaultNow: 'huidige systeemuitvoer',
|
||||||
|
audioOutputDeviceListError: 'De lijst met audio-apparaten kon niet worden geladen.',
|
||||||
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.",
|
||||||
|
|||||||
@@ -520,6 +520,8 @@ export const ruTranslation = {
|
|||||||
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
|
audioOutputDeviceDesc: 'Выберите аудиоустройство для воспроизведения. Изменения применяются немедленно и перезапускают текущий трек.',
|
||||||
audioOutputDeviceDefault: 'Системное по умолчанию',
|
audioOutputDeviceDefault: 'Системное по умолчанию',
|
||||||
audioOutputDeviceRefresh: 'Обновить список устройств',
|
audioOutputDeviceRefresh: 'Обновить список устройств',
|
||||||
|
audioOutputDeviceOsDefaultNow: 'текущий системный вывод',
|
||||||
|
audioOutputDeviceListError: 'Не удалось загрузить список аудиоустройств.',
|
||||||
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
hiResTitle: 'Нативное воспроизведение Hi-Res',
|
||||||
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
hiResEnabled: 'Включить нативное Hi-Res воспроизведение',
|
||||||
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
hiResDesc: "По умолчанию вывод ограничен до 44,1 кГц для максимальной стабильности. Включайте только если оборудование и сеть надёжно поддерживают высокие частоты (88,2 кГц+).",
|
||||||
|
|||||||
@@ -498,6 +498,8 @@ export const zhTranslation = {
|
|||||||
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
|
audioOutputDeviceDesc: '选择 Psysonic 播放音频的设备。更改立即生效并重新开始当前曲目。',
|
||||||
audioOutputDeviceDefault: '系统默认',
|
audioOutputDeviceDefault: '系统默认',
|
||||||
audioOutputDeviceRefresh: '刷新设备列表',
|
audioOutputDeviceRefresh: '刷新设备列表',
|
||||||
|
audioOutputDeviceOsDefaultNow: '当前系统输出',
|
||||||
|
audioOutputDeviceListError: '无法加载音频设备列表。',
|
||||||
hiResTitle: '原生高清晰度播放',
|
hiResTitle: '原生高清晰度播放',
|
||||||
hiResEnabled: '启用原生高清晰度播放',
|
hiResEnabled: '启用原生高清晰度播放',
|
||||||
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
hiResDesc: "默认强制 44.1 kHz 输出以获得最大稳定性。仅在硬件和网络可靠支持高采样率(88.2 kHz+)时启用。",
|
||||||
|
|||||||
+130
-19
@@ -11,6 +11,7 @@ import i18n from '../i18n';
|
|||||||
import { exportBackup, importBackup } from '../utils/backup';
|
import { exportBackup, importBackup } from '../utils/backup';
|
||||||
import { showToast } from '../utils/toast';
|
import { showToast } from '../utils/toast';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
|
import { listen } from '@tauri-apps/api/event';
|
||||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||||
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
import { getImageCacheSize, clearImageCache } from '../utils/imageCache';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
@@ -224,22 +225,95 @@ function snapHotCacheMb(v: number): number {
|
|||||||
/** Makes raw ALSA device names more readable on Linux.
|
/** 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.
|
* Values are kept as-is (rodio needs the ALSA name); only the displayed label is cleaned.
|
||||||
* e.g. "sysdefault:CARD=U192k" → "U192k"
|
* 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)"
|
* "iec958:CARD=PCH,DEV=0" → "PCH (S/PDIF)"
|
||||||
* Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
|
* Names without ALSA prefix (pipewire, pulse, default…) are returned unchanged. */
|
||||||
function formatAudioDeviceLabel(name: string): string {
|
function formatAudioDeviceLabel(name: string): string {
|
||||||
const cardMatch = name.match(/CARD=([^,]+)/);
|
const cardMatch = name.match(/CARD=([^,]+)/);
|
||||||
if (!cardMatch) return name;
|
if (!cardMatch) return name;
|
||||||
const card = cardMatch[1];
|
const card = cardMatch[1];
|
||||||
if (name.startsWith('iec958:')) return `${card} (S/PDIF)`;
|
const devM = name.match(/DEV=(\d+)/);
|
||||||
if (name.startsWith('sysdefault:')) return card;
|
const devNum = devM ? parseInt(devM[1], 10) : null;
|
||||||
if (name.startsWith('plughw:')) return card;
|
const subM = name.match(/SUBDEV=(\d+)/);
|
||||||
if (name.startsWith('hw:')) return `${card} (hw)`;
|
const subNum = subM ? parseInt(subM[1], 10) : null;
|
||||||
if (name.startsWith('front:')) return `${card} (Front)`;
|
|
||||||
if (name.startsWith('surround')) return `${card} (${name.split(':')[0]})`;
|
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;
|
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() {
|
export default function Settings() {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const theme = useThemeStore();
|
const theme = useThemeStore();
|
||||||
@@ -280,6 +354,7 @@ export default function Settings() {
|
|||||||
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
const [offlineCacheBytes, setOfflineCacheBytes] = useState<number | null>(null);
|
||||||
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 [osDefaultAudioDeviceId, setOsDefaultAudioDeviceId] = useState<string | null>(null);
|
||||||
const [deviceSwitching, setDeviceSwitching] = useState(false);
|
const [deviceSwitching, setDeviceSwitching] = useState(false);
|
||||||
const [devicesLoading, setDevicesLoading] = useState(false);
|
const [devicesLoading, setDevicesLoading] = useState(false);
|
||||||
const [showClearConfirm, setShowClearConfirm] = 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));
|
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]);
|
||||||
|
|
||||||
const refreshAudioDevices = () => {
|
const refreshAudioDevices = useCallback((opts?: { silent?: boolean }) => {
|
||||||
setDevicesLoading(true);
|
const silent = !!opts?.silent;
|
||||||
invoke<string[]>('audio_list_devices')
|
if (!silent) setDevicesLoading(true);
|
||||||
.then(setAudioDevices)
|
const listP = invoke<string[]>('audio_list_devices').catch((e) => {
|
||||||
.catch(() => {})
|
console.error(e);
|
||||||
.finally(() => setDevicesLoading(false));
|
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.
|
// Load available audio output devices when Audio tab opens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'audio') return;
|
if (activeTab !== 'audio') return;
|
||||||
refreshAudioDevices();
|
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). */
|
/** Live disk usage for hot cache while Audio settings are open (interval + refresh when index changes). */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -550,10 +659,12 @@ export default function Settings() {
|
|||||||
} catch { /* device open failed — don't persist */ }
|
} catch { /* device open failed — don't persist */ }
|
||||||
setDeviceSwitching(false);
|
setDeviceSwitching(false);
|
||||||
}}
|
}}
|
||||||
options={[
|
options={buildAudioDeviceSelectOptions(
|
||||||
{ value: '', label: t('settings.audioOutputDeviceDefault') },
|
audioDevices,
|
||||||
...audioDevices.map(d => ({ value: d, label: formatAudioDeviceLabel(d) })),
|
t('settings.audioOutputDeviceDefault'),
|
||||||
]}
|
osDefaultAudioDeviceId,
|
||||||
|
t('settings.audioOutputDeviceOsDefaultNow'),
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
className="icon-btn"
|
className="icon-btn"
|
||||||
|
|||||||
Reference in New Issue
Block a user