Add PipeWire device names via pactl and fix badge margins

This commit is contained in:
vicrodh committed 2026-01-11 11:37:45 -06:00
1 parent 78e3af691f
commit b6b019e659
5 files changed
+208 -18

No files matched your search

+101
View File
@@ -347,3 +347,104 @@ pub fn get_audio_output_status(
is_playing, 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<u32>,
/// 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<Vec<PipewireSink>, 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<String> = None;
let mut current_description: Option<String> = None;
let mut current_volume: Option<u32> = 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::<u32>() {
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)
}
+1
View File
@@ -235,6 +235,7 @@ pub fn run() {
commands::set_media_metadata, commands::set_media_metadata,
commands::get_audio_devices, commands::get_audio_devices,
commands::get_audio_output_status, commands::get_audio_output_status,
commands::get_pipewire_sinks,
// Queue commands // Queue commands
commands::add_to_queue, commands::add_to_queue,
commands::add_to_queue_next, commands::add_to_queue_next,
+58 -3
View File
@@ -15,6 +15,13 @@
is_playing: boolean; is_playing: boolean;
} }
interface PipewireSink {
name: string;
description: string;
volume: number | null;
is_default: boolean;
}
// Props // Props
interface Props { interface Props {
showTooltips?: boolean; showTooltips?: boolean;
@@ -25,11 +32,31 @@
// State // State
let settings = $state<AudioSettings | null>(null); let settings = $state<AudioSettings | null>(null);
let outputStatus = $state<AudioOutputStatus | null>(null); let outputStatus = $state<AudioOutputStatus | null>(null);
let pipewireSinks = $state<PipewireSink[]>([]);
let isHovering = $state(false); let isHovering = $state(false);
// Derived state // Derived state
const currentDevice = $derived(outputStatus?.device_name ?? null); 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); const isExternal = $derived(currentDevice ? isExternalDevice(currentDevice) : false);
// Badge states - based on settings AND actual device capability // Badge states - based on settings AND actual device capability
@@ -47,12 +74,14 @@
async function loadStatus() { async function loadStatus() {
try { try {
const [settingsResult, statusResult] = await Promise.all([ const [settingsResult, statusResult, sinksResult] = await Promise.all([
invoke<AudioSettings>('get_audio_settings'), invoke<AudioSettings>('get_audio_settings'),
invoke<AudioOutputStatus>('get_audio_output_status') invoke<AudioOutputStatus>('get_audio_output_status'),
invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[])
]); ]);
settings = settingsResult; settings = settingsResult;
outputStatus = statusResult; outputStatus = statusResult;
pipewireSinks = sinksResult;
} catch (err) { } catch (err) {
console.error('Failed to load audio status:', err); console.error('Failed to load audio status:', err);
} }
@@ -98,6 +127,12 @@
{#if currentDevice && currentDevice !== prettyDeviceName} {#if currentDevice && currentDevice !== prettyDeviceName}
<div class="tooltip-raw">{currentDevice}</div> <div class="tooltip-raw">{currentDevice}</div>
{/if} {/if}
{#if currentVolume !== null}
<div class="tooltip-volume">
<span class="volume-label">Volume:</span>
<span class="volume-value">{currentVolume}%</span>
</div>
{/if}
</div> </div>
{/if} {/if}
</div> </div>
@@ -190,4 +225,24 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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);
}
</style> </style>
+3 -3
View File
@@ -519,7 +519,7 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
gap: 12px; gap: 12px;
padding: 2px 12px 2px 2px; padding: 2px;
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border-radius: 8px; border-radius: 8px;
min-width: 580px; min-width: 580px;
@@ -532,7 +532,7 @@
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
margin: 2px 0 2px 2px; margin: 0;
cursor: pointer; cursor: pointer;
flex-shrink: 0; flex-shrink: 0;
line-height: 0; line-height: 0;
@@ -644,7 +644,7 @@
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
align-self: stretch; align-self: stretch;
margin: 2px 2px 2px 0; margin: 0;
} }
.audio-badges-row { .audio-badges-row {
+45 -12
View File
@@ -36,6 +36,13 @@
is_default: boolean; 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(); let { onBack, onLogout, userName = 'User', userEmail = '', subscription = 'Qobuz' }: Props = $props();
// Cache state (memory cache) // Cache state (memory cache)
@@ -52,20 +59,39 @@
// Audio device state // Audio device state
let audioDevices = $state<AudioDevice[]>([]); let audioDevices = $state<AudioDevice[]>([]);
let pipewireSinks = $state<PipewireSink[]>([]);
// Map of ALSA name -> PipeWire description (for pretty names)
const pipewireNameMap = $derived.by(() => {
const map = new Map<string, string>();
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 // Map of pretty name -> raw name
const deviceNameMap = $derived.by(() => { const deviceNameMap = $derived.by(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
map.set('System Default', 'System Default'); map.set('System Default', 'System Default');
for (const d of audioDevices) { for (const d of audioDevices) {
const pretty = getDevicePrettyName(d.name); const pretty = getPrettyName(d.name);
map.set(pretty, d.name); map.set(pretty, d.name);
} }
return map; return map;
}); });
// Options for dropdown (pretty names) // Options for dropdown (pretty names from PipeWire when available)
let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => getDevicePrettyName(d.name))]); let audioDeviceOptions = $derived(['System Default', ...audioDevices.map(d => getPrettyName(d.name))]);
// Theme mapping: display name -> data-theme value // Theme mapping: display name -> data-theme value
const themeMap: Record<string, string> = { const themeMap: Record<string, string> = {
@@ -141,9 +167,8 @@
// Load download cache stats // Load download cache stats
loadDownloadStats(); loadDownloadStats();
// Load audio devices and settings // Load audio devices first (includes PipeWire sinks), then settings
loadAudioDevices(); loadAudioDevices().then(() => loadAudioSettings());
loadAudioSettings();
// Load Last.fm state // Load Last.fm state
loadLastfmState(); loadLastfmState();
@@ -299,13 +324,21 @@
async function loadAudioDevices() { async function loadAudioDevices() {
try { try {
const devices = await invoke<AudioDevice[]>('get_audio_devices'); // Load both ALSA devices and PipeWire sinks in parallel
const [devices, sinks] = await Promise.all([
invoke<AudioDevice[]>('get_audio_devices'),
invoke<PipewireSink[]>('get_pipewire_sinks').catch(() => [] as PipewireSink[])
]);
audioDevices = devices; audioDevices = devices;
pipewireSinks = sinks;
// Debug: log raw names and their pretty versions // 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, raw: d.name,
pretty: getDevicePrettyName(d.name), pretty: getPrettyName(d.name)
isDefault: d.is_default
}))); })));
} catch (err) { } catch (err) {
console.error('Failed to load audio devices:', err); console.error('Failed to load audio devices:', err);
@@ -315,9 +348,9 @@
async function loadAudioSettings() { async function loadAudioSettings() {
try { try {
const settings = await invoke<AudioSettings>('get_audio_settings'); const settings = await invoke<AudioSettings>('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) { if (settings.output_device) {
outputDevice = getDevicePrettyName(settings.output_device); outputDevice = getPrettyName(settings.output_device);
} else { } else {
outputDevice = 'System Default'; outputDevice = 'System Default';
} }