From b6b019e659f5dca333f78b58599d7f21055f453b Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Sun, 11 Jan 2026 11:37:45 -0600 Subject: [PATCH] Add PipeWire device names via pactl and fix badge margins --- src-tauri/src/commands/playback.rs | 101 +++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/lib/components/AudioOutputBadges.svelte | 61 ++++++++++- src/lib/components/NowPlayingBar.svelte | 6 +- src/lib/components/views/SettingsView.svelte | 57 ++++++++--- 5 files changed, 208 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/commands/playback.rs b/src-tauri/src/commands/playback.rs index f55d76ab6..569134154 100644 --- a/src-tauri/src/commands/playback.rs +++ b/src-tauri/src/commands/playback.rs @@ -347,3 +347,104 @@ pub fn get_audio_output_status( is_playing, }) } + +/// PipeWire/PulseAudio sink information +#[derive(serde::Serialize, Clone)] +pub struct PipewireSink { + /// Internal name (e.g., "alsa_output.usb-XXX") + pub name: String, + /// User-friendly description (e.g., "AB13X Headset Adapter Analog Stereo") + pub description: String, + /// Current volume percentage (0-100) + pub volume: Option, + /// Whether this is the default sink + pub is_default: bool, +} + +/// Get PipeWire/PulseAudio sink information with friendly names +#[tauri::command] +pub fn get_pipewire_sinks() -> Result, String> { + log::info!("Command: get_pipewire_sinks"); + + use std::process::Command; + + // Get default sink name first + let default_sink = Command::new("pactl") + .args(["get-default-sink"]) + .output() + .ok() + .and_then(|o| { + if o.status.success() { + String::from_utf8(o.stdout).ok().map(|s| s.trim().to_string()) + } else { + None + } + }); + + // Get all sinks + let output = Command::new("pactl") + .args(["list", "sinks"]) + .output() + .map_err(|e| format!("Failed to run pactl: {}. Is PulseAudio/PipeWire installed?", e))?; + + if !output.status.success() { + return Err("pactl command failed".to_string()); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let mut sinks = Vec::new(); + + // Parse pactl output + let mut current_name: Option = None; + let mut current_description: Option = None; + let mut current_volume: Option = None; + + for line in stdout.lines() { + let line = line.trim(); + + // New sink starts with "Sink #" + if line.starts_with("Sink #") { + // Save previous sink if complete + if let (Some(name), Some(desc)) = (current_name.take(), current_description.take()) { + let is_default = default_sink.as_ref().map(|d| d == &name).unwrap_or(false); + sinks.push(PipewireSink { + name, + description: desc, + volume: current_volume.take(), + is_default, + }); + } + current_volume = None; + } else if line.starts_with("Name:") { + current_name = Some(line.trim_start_matches("Name:").trim().to_string()); + } else if line.starts_with("Description:") { + current_description = Some(line.trim_start_matches("Description:").trim().to_string()); + } else if line.starts_with("Volume:") { + // Parse volume like "Volume: front-left: 65536 / 100% / 0.00 dB" + if let Some(percent_pos) = line.find('%') { + // Find the number before % + let before_percent = &line[..percent_pos]; + if let Some(slash_pos) = before_percent.rfind('/') { + let vol_str = before_percent[slash_pos + 1..].trim(); + if let Ok(vol) = vol_str.parse::() { + current_volume = Some(vol); + } + } + } + } + } + + // Don't forget the last sink + if let (Some(name), Some(desc)) = (current_name, current_description) { + let is_default = default_sink.as_ref().map(|d| d == &name).unwrap_or(false); + sinks.push(PipewireSink { + name, + description: desc, + volume: current_volume, + is_default, + }); + } + + log::info!("Found {} PipeWire sinks", sinks.len()); + Ok(sinks) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e7492a0df..93d60b9de 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -235,6 +235,7 @@ pub fn run() { commands::set_media_metadata, commands::get_audio_devices, commands::get_audio_output_status, + commands::get_pipewire_sinks, // Queue commands commands::add_to_queue, commands::add_to_queue_next, diff --git a/src/lib/components/AudioOutputBadges.svelte b/src/lib/components/AudioOutputBadges.svelte index 4ecf320da..25bb1174e 100644 --- a/src/lib/components/AudioOutputBadges.svelte +++ b/src/lib/components/AudioOutputBadges.svelte @@ -15,6 +15,13 @@ is_playing: boolean; } + interface PipewireSink { + name: string; + description: string; + volume: number | null; + is_default: boolean; + } + // Props interface Props { showTooltips?: boolean; @@ -25,11 +32,31 @@ // State let settings = $state(null); let outputStatus = $state(null); + let pipewireSinks = $state([]); let isHovering = $state(false); // Derived state const currentDevice = $derived(outputStatus?.device_name ?? null); - const prettyDeviceName = $derived(currentDevice ? getDevicePrettyName(currentDevice) : 'No device'); + + // Get PipeWire description for current device + const pipewireDescription = $derived.by(() => { + if (!currentDevice) return null; + const sink = pipewireSinks.find(s => s.name === currentDevice); + return sink?.description ?? null; + }); + + // Get volume for current device from PipeWire + const currentVolume = $derived.by(() => { + if (!currentDevice) return null; + const sink = pipewireSinks.find(s => s.name === currentDevice); + return sink?.volume ?? null; + }); + + // Use PipeWire description if available, otherwise fall back to heuristic + const prettyDeviceName = $derived( + pipewireDescription ?? (currentDevice ? getDevicePrettyName(currentDevice) : 'No device') + ); + const isExternal = $derived(currentDevice ? isExternalDevice(currentDevice) : false); // Badge states - based on settings AND actual device capability @@ -47,12 +74,14 @@ async function loadStatus() { try { - const [settingsResult, statusResult] = await Promise.all([ + const [settingsResult, statusResult, sinksResult] = await Promise.all([ invoke('get_audio_settings'), - invoke('get_audio_output_status') + invoke('get_audio_output_status'), + invoke('get_pipewire_sinks').catch(() => [] as PipewireSink[]) ]); settings = settingsResult; outputStatus = statusResult; + pipewireSinks = sinksResult; } catch (err) { console.error('Failed to load audio status:', err); } @@ -98,6 +127,12 @@ {#if currentDevice && currentDevice !== prettyDeviceName}
{currentDevice}
{/if} + {#if currentVolume !== null} +
+ Volume: + {currentVolume}% +
+ {/if} {/if} @@ -190,4 +225,24 @@ overflow: hidden; text-overflow: ellipsis; } + + .tooltip-volume { + display: flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .volume-label { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + } + + .volume-value { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); + } diff --git a/src/lib/components/NowPlayingBar.svelte b/src/lib/components/NowPlayingBar.svelte index 33bdc6261..877092484 100644 --- a/src/lib/components/NowPlayingBar.svelte +++ b/src/lib/components/NowPlayingBar.svelte @@ -519,7 +519,7 @@ display: flex; align-items: stretch; gap: 12px; - padding: 2px 12px 2px 2px; + padding: 2px; background: rgba(255, 255, 255, 0.04); border-radius: 8px; min-width: 580px; @@ -532,7 +532,7 @@ background: none; border: none; padding: 0; - margin: 2px 0 2px 2px; + margin: 0; cursor: pointer; flex-shrink: 0; line-height: 0; @@ -644,7 +644,7 @@ flex-direction: column; gap: 2px; align-self: stretch; - margin: 2px 2px 2px 0; + margin: 0; } .audio-badges-row { diff --git a/src/lib/components/views/SettingsView.svelte b/src/lib/components/views/SettingsView.svelte index 77459db02..e9b8bfbb1 100644 --- a/src/lib/components/views/SettingsView.svelte +++ b/src/lib/components/views/SettingsView.svelte @@ -36,6 +36,13 @@ is_default: boolean; } + interface PipewireSink { + name: string; + description: string; + volume: number | null; + is_default: boolean; + } + let { onBack, onLogout, userName = 'User', userEmail = '', subscription = 'Qobuz' }: Props = $props(); // Cache state (memory cache) @@ -52,20 +59,39 @@ // Audio device state let audioDevices = $state([]); + let pipewireSinks = $state([]); + + // Map of ALSA name -> PipeWire description (for pretty names) + const pipewireNameMap = $derived.by(() => { + const map = new Map(); + for (const sink of pipewireSinks) { + map.set(sink.name, sink.description); + } + return map; + }); + + // Get pretty name: prefer PipeWire description, fall back to heuristic + function getPrettyName(alsaName: string): string { + // Try PipeWire description first + const pwDesc = pipewireNameMap.get(alsaName); + if (pwDesc) return pwDesc; + // Fall back to heuristic + return getDevicePrettyName(alsaName); + } // 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); + const pretty = getPrettyName(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))]); + // Options for dropdown (pretty names from PipeWire when available) + let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => getPrettyName(d.name))]); // Theme mapping: display name -> data-theme value const themeMap: Record = { @@ -141,9 +167,8 @@ // Load download cache stats loadDownloadStats(); - // Load audio devices and settings - loadAudioDevices(); - loadAudioSettings(); + // Load audio devices first (includes PipeWire sinks), then settings + loadAudioDevices().then(() => loadAudioSettings()); // Load Last.fm state loadLastfmState(); @@ -299,13 +324,21 @@ async function loadAudioDevices() { try { - const devices = await invoke('get_audio_devices'); + // Load both ALSA devices and PipeWire sinks in parallel + const [devices, sinks] = await Promise.all([ + invoke('get_audio_devices'), + invoke('get_pipewire_sinks').catch(() => [] as PipewireSink[]) + ]); + audioDevices = devices; + pipewireSinks = sinks; + // Debug: log raw names and their pretty versions - console.log('[Audio] Devices loaded:', devices.map(d => ({ + console.log('[Audio] ALSA devices:', devices.map(d => d.name)); + console.log('[Audio] PipeWire sinks:', sinks.map(s => ({ name: s.name, desc: s.description }))); + console.log('[Audio] Final device options:', devices.map(d => ({ raw: d.name, - pretty: getDevicePrettyName(d.name), - isDefault: d.is_default + pretty: getPrettyName(d.name) }))); } catch (err) { console.error('Failed to load audio devices:', err); @@ -315,9 +348,9 @@ async function loadAudioSettings() { try { const settings = await invoke('get_audio_settings'); - // Convert raw name to pretty name for display + // Convert raw name to pretty name for display (uses PipeWire when available) if (settings.output_device) { - outputDevice = getDevicePrettyName(settings.output_device); + outputDevice = getPrettyName(settings.output_device); } else { outputDevice = 'System Default'; }