diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index 162a246f3..f55d76ab6 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -326,3 +326,24 @@ pub fn get_audio_devices() -> Result, String> { log::info!("Found {} audio output devices", devices.len()); Ok(devices) } + +/// Current audio output status +#[derive(serde::Serialize)] +pub struct AudioOutputStatus { + pub device_name: Option, + pub is_playing: bool, +} + +/// Get current audio output status (what device is actually being used) +#[tauri::command] +pub fn get_audio_output_status( + state: tauri::State<'_, crate::AppState>, +) -> Result { + let device_name = state.player.state.current_device(); + let is_playing = state.player.state.is_playing(); + + Ok(AudioOutputStatus { + device_name, + is_playing, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b00b2080f..e7492a0df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -234,6 +234,7 @@ pub fn run() { commands::get_playback_state, commands::set_media_metadata, commands::get_audio_devices, + commands::get_audio_output_status, // Queue commands commands::add_to_queue, commands::add_to_queue_next, diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index 2961989cc..65e5e0c9b 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -63,6 +63,8 @@ pub struct SharedState { playback_start_millis: Arc, /// Position when playback was started/resumed (in seconds) position_at_start: Arc, + /// Current output device name + current_device: Arc>>, } impl Default for SharedState { @@ -81,9 +83,20 @@ impl SharedState { volume: Arc::new(AtomicU64::new(75)), playback_start_millis: Arc::new(AtomicU64::new(0)), position_at_start: Arc::new(AtomicU64::new(0)), + current_device: Arc::new(std::sync::RwLock::new(None)), } } + pub fn set_current_device(&self, device: Option) { + if let Ok(mut d) = self.current_device.write() { + *d = device; + } + } + + pub fn current_device(&self) -> Option { + self.current_device.read().ok().and_then(|d| d.clone()) + } + /// Get current position based on elapsed time since playback started pub fn current_position(&self) -> u64 { if !self.is_playing.load(Ordering::SeqCst) { @@ -198,11 +211,13 @@ impl Player { Some(d) => { if let Ok(name) = d.name() { log::info!("Using audio device: {}", name); + thread_state.set_current_device(Some(name)); } d } None => { log::error!("No audio output device available"); + thread_state.set_current_device(None); // Keep thread alive to receive commands loop { match rx.recv() { diff --git a/src/lib/components/AudioOutputBadges.svelte b/src/lib/components/AudioOutputBadges.svelte new file mode 100644 index 000000000..29f70f0fc --- /dev/null +++ b/src/lib/components/AudioOutputBadges.svelte @@ -0,0 +1,201 @@ + + +{#if shouldShowBadges} +
isHovering = true} + onmouseleave={() => isHovering = false} + > + +
+ DAC +
+ + +
+ EXC +
+ + + {#if isHovering && showTooltips} +
+
Output Device
+
{prettyDeviceName}
+ {#if currentDevice && currentDevice !== prettyDeviceName} +
{currentDevice}
+ {/if} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/NowPlayingBar.svelte b/src/lib/components/NowPlayingBar.svelte index 1f158b8d3..d7c9455dd 100644 --- a/src/lib/components/NowPlayingBar.svelte +++ b/src/lib/components/NowPlayingBar.svelte @@ -18,6 +18,7 @@ Maximize2 } from 'lucide-svelte'; import QualityBadge from './QualityBadge.svelte'; + import AudioOutputBadges from './AudioOutputBadges.svelte'; interface Props { artwork?: string; @@ -277,6 +278,7 @@
+
{:else} @@ -633,6 +635,9 @@ .quality-indicator { flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; } .empty-state { diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index bf2dd2bbc..c047517d1 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -14,6 +14,7 @@ type DownloadCacheStats } from '$lib/stores/downloadState'; import { clearCache as clearLyricsCache } from '$lib/stores/lyricsStore'; + import { getDevicePrettyName } from '$lib/utils/audioDeviceNames'; interface Props { onBack?: () => void; @@ -51,7 +52,20 @@ // Audio device state let audioDevices = $state([]); - let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => d.name)]); + + // Map of pretty name -> raw name + const deviceNameMap = $derived.by(() => { + const map = new Map(); + map.set('System Default', 'System Default'); + for (const d of audioDevices) { + const pretty = getDevicePrettyName(d.name); + map.set(pretty, d.name); + } + return map; + }); + + // Options for dropdown (pretty names) + let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => getDevicePrettyName(d.name))]); // Theme mapping: display name -> data-theme value const themeMap: Record = { @@ -295,7 +309,12 @@ async function loadAudioSettings() { try { const settings = await invoke('get_audio_settings'); - outputDevice = settings.output_device ?? 'System Default'; + // Convert raw name to pretty name for display + if (settings.output_device) { + outputDevice = getDevicePrettyName(settings.output_device); + } else { + outputDevice = 'System Default'; + } exclusiveMode = settings.exclusive_mode; dacPassthrough = settings.dac_passthrough; } catch (err) { @@ -303,13 +322,15 @@ } } - async function handleOutputDeviceChange(device: string) { - outputDevice = device; + async function handleOutputDeviceChange(prettyName: string) { + outputDevice = prettyName; + // Convert pretty name back to raw name for saving + const rawName = deviceNameMap.get(prettyName) ?? prettyName; try { await invoke('set_audio_output_device', { - device: device === 'System Default' ? null : device + device: rawName === 'System Default' ? null : rawName }); - console.log('Audio output device saved:', device); + console.log('Audio output device saved:', rawName, '(displayed as:', prettyName, ')'); } catch (err) { console.error('Failed to save audio output device:', err); } diff --git a/src/lib/utils/audioDeviceNames.ts b/src/lib/utils/audioDeviceNames.ts new file mode 100644 index 000000000..9073e7269 --- /dev/null +++ b/src/lib/utils/audioDeviceNames.ts @@ -0,0 +1,114 @@ +/** + * Helper to convert ALSA device names to more user-friendly display names. + * This is a best-effort mapping - some names may not be recognized. + */ + +// Common device name patterns and their friendly names +const DEVICE_PATTERNS: [RegExp, string | ((match: RegExpMatchArray) => string)][] = [ + // PipeWire virtual devices + [/^default$/, 'System Default'], + [/^pipewire$/i, 'PipeWire'], + + // USB Audio devices - extract product name + [/alsa_output\.usb-([^.]+)\.([^.]+)/i, (m) => cleanProductName(m[1], m[2])], + [/usb-([^-]+)-([^-]+)/i, (m) => cleanProductName(m[1], m[2])], + + // PCI Audio (Intel HD Audio, etc) + [/alsa_output\.pci-.*\.hdmi-stereo/i, 'HDMI Audio Output'], + [/alsa_output\.pci-.*\.analog-stereo/i, 'Analog Audio Output'], + [/alsa_output\.pci-.*\.iec958-stereo/i, 'S/PDIF Digital Output'], + + // HDMI patterns + [/hdmi[:\-_]?(\d+)/i, (m) => `HDMI Output ${parseInt(m[1]) + 1}`], + [/DisplayPort/i, 'DisplayPort Audio'], + + // Common audio interfaces + [/Focusrite/i, (m) => 'Focusrite Scarlett'], + [/Steinberg/i, 'Steinberg Interface'], + [/MOTU/i, 'MOTU Interface'], + [/PreSonus/i, 'PreSonus Interface'], + + // DACs + [/DAC|Topping|SMSL|FiiO|iFi|Schiit|Dragonfly|Modi|Magni/i, (m) => `USB DAC (${m[0]})`], + + // Hardware device patterns (hw:X,Y) + [/^hw:(\d+),(\d+)$/, (m) => `Hardware Device ${m[1]}:${m[2]}`], + [/^plughw:(\d+),(\d+)$/, (m) => `Hardware Device ${m[1]}:${m[2]} (Plugin)`], + + // Intel/AMD audio + [/Intel.*HDA|HDA Intel/i, 'Intel HD Audio'], + [/AMD.*Audio|Audio.*AMD/i, 'AMD Audio'], + [/Realtek/i, 'Realtek Audio'], + + // Headphones/Headsets + [/headphone|headset/i, 'Headphones'], + [/speaker/i, 'Speakers'], + + // Bluetooth (if visible through ALSA) + [/bluez|bluetooth/i, 'Bluetooth Audio'], +]; + +/** + * Clean up USB product names by removing underscores and fixing case + */ +function cleanProductName(vendor: string, product: string): string { + const clean = (s: string) => s + .replace(/_/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .trim(); + + const v = clean(vendor); + const p = clean(product); + + // If product contains vendor name, just use product + if (p.toLowerCase().includes(v.toLowerCase())) { + return p; + } + + return `${v} ${p}`.trim(); +} + +/** + * Get a user-friendly name for an ALSA device name. + * Falls back to the original name if no pattern matches. + */ +export function getDevicePrettyName(alsaName: string): string { + if (!alsaName) return 'Unknown Device'; + + for (const [pattern, replacement] of DEVICE_PATTERNS) { + const match = alsaName.match(pattern); + if (match) { + if (typeof replacement === 'function') { + return replacement(match); + } + return replacement; + } + } + + // Fallback: clean up the raw name a bit + return alsaName + .replace(/alsa_output\./g, '') + .replace(/\./g, ' ') + .replace(/_/g, ' ') + .replace(/-/g, ' ') + .replace(/\s+/g, ' ') + .trim() || alsaName; +} + +/** + * Check if a device name appears to be a DAC or external audio interface + */ +export function isExternalDevice(deviceName: string): boolean { + if (!deviceName) return false; + const lower = deviceName.toLowerCase(); + return ( + lower.includes('usb') || + lower.includes('dac') || + lower.includes('interface') || + lower.includes('focusrite') || + lower.includes('steinberg') || + lower.includes('motu') || + lower.includes('presonus') || + /topping|smsl|fiio|ifi|schiit|dragonfly/i.test(lower) + ); +}