Add PipeWire device names via pactl and fix badge margins
This commit is contained in:
1 parent
78e3af691f
commit
b6b019e659
5 files changed
+208
-18
No files matched your search
@@ -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)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user