refactor(v2): migrate get_pipewire_sinks to v2

Path C: legacy CPAL output-sink enumeration was inlined directly in
commands/playback.rs (cfg-gated Linux + non-Linux variants), with
no equivalent in any domain crate. Extract a domain function
`qbz_audio::output_sinks::list_output_sinks` that returns the same
{name, description, volume, is_default} shape (mirrored as the new
public `OutputSinkInfo` struct) for both Linux and non-Linux
platforms, preserving the original logging cadence and the rule
that name == CPAL device name (so saved IDs reopen correctly).

Add v2_get_pipewire_sinks in commands_v2/audio.rs that calls into
qbz_audio directly — no `crate::commands::*` delegation. Updates the
two frontend call sites (AudioOutputBadges.loadStatus and
SettingsView.loadAudioDevices). Legacy command in
commands/playback.rs is preserved untouched; the audio-protected
files (pipewire_backend.rs, audio_settings.rs, player init_device)
are not modified.

cc
This commit is contained in:
vicrodh committed 2026-04-26 12:03:28 -06:00
1 parent 1abb57bd7f
commit c70db4157a
6 files changed
+187 -2

No files matched your search

+2
View File
@@ -44,6 +44,7 @@ pub mod dynamic_amplify;
pub mod loudness;
pub mod loudness_analyzer;
pub mod loudness_cache;
pub mod output_sinks;
pub mod settings;
pub mod visualizer;
@@ -65,6 +66,7 @@ pub use dynamic_amplify::DynamicAmplify;
pub use loudness::{calculate_gain_factor, db_to_linear, extract_replaygain, ReplayGainData};
pub use loudness_analyzer::LoudnessAnalyzer;
pub use loudness_cache::LoudnessCache;
pub use output_sinks::{list_output_sinks, OutputSinkInfo};
pub use settings::AudioSettings;
pub use visualizer::{RingBuffer, TappedSource, VisualizerTap};
+170
View File
@@ -0,0 +1,170 @@
//! Output sink enumeration (frontend-shaped diagnostic).
//!
//! Provides a small, frontend-facing struct (`OutputSinkInfo`) listing the
//! available CPAL output devices. This is the same data the legacy
//! `get_pipewire_sinks` command exposed — the simpler shape (`name`,
//! `description`, `volume`, `is_default`) used by the audio settings UI
//! and the AudioOutputBadges component.
//!
//! NOTE: This is the same CPAL host the Player itself opens, so the
//! `name` returned here is guaranteed to be a valid identifier the
//! audio backend can re-open later. It is intentionally NOT the richer
//! `AudioDevice` struct from `backend::AudioBackend::enumerate_devices`,
//! which carries sample-rate probing data the Settings UI does not need.
use serde::Serialize;
/// Frontend-shaped info for a single audio output device.
///
/// Mirrors the legacy `PipewireSink` struct so the existing TypeScript
/// `PipewireSink` interface can consume V2 output unchanged.
#[derive(Debug, Clone, Serialize)]
pub struct OutputSinkInfo {
/// Internal name (e.g. CPAL device name; on Linux this is the
/// PipeWire/PulseAudio sink name like `alsa_output.usb-XXX`).
pub name: String,
/// User-friendly description. On PipeWire the CPAL name is already
/// user-readable; on macOS/Windows the name itself is descriptive.
pub description: String,
/// Current volume percentage (0–100). CPAL does not expose this so
/// it is always `None` here; preserved for API compatibility.
pub volume: Option<u32>,
/// Whether this is the default sink.
pub is_default: bool,
}
/// Resolve the CPAL `description().name()` for a device, returning `None`
/// if the description cannot be queried.
fn cpal_device_name(device: &rodio::cpal::Device) -> Option<String> {
use rodio::cpal::traits::DeviceTrait;
device
.description()
.ok()
.map(|description| description.name().to_string())
}
/// Enumerate the system's CPAL output devices.
///
/// On Linux the CPAL host is PipeWire/PulseAudio (rodio defaults to
/// CPAL's PipeWire host on modern distros); on macOS/Windows it is the
/// platform default. Output is shaped for the audio settings UI.
///
/// CRITICAL: The returned `name` is exactly the CPAL device name, so it
/// matches what the audio backend uses to re-open the device later. Do
/// NOT substitute a friendlier description for `name`.
#[cfg(target_os = "linux")]
pub fn list_output_sinks() -> Result<Vec<OutputSinkInfo>, String> {
log::debug!("[qbz-audio] list_output_sinks (Linux, using CPAL)");
use rodio::cpal::traits::{DeviceTrait, HostTrait};
let host = rodio::cpal::default_host();
let default_device_name = host
.default_output_device()
.and_then(|d| cpal_device_name(&d));
log::debug!(
"[qbz-audio] CPAL default device: {:?}",
default_device_name
);
let sinks: Vec<OutputSinkInfo> = host
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.enumerate()
.filter_map(|(idx, device)| {
let name = match cpal_device_name(&device) {
Some(name) => name,
None => {
log::warn!("[qbz-audio] [{}] Failed to get device description", idx);
return None;
}
};
let is_default = default_device_name
.as_ref()
.map(|d| d == &name)
.unwrap_or(false);
// Same diagnostic logging as the legacy command, so log output
// for the V2 command matches what users / support reports
// already document.
let configs_info = device
.supported_output_configs()
.ok()
.map(|configs| {
let config_strs: Vec<String> = configs
.take(3)
.map(|c| format!("{}ch/{}Hz", c.channels(), c.max_sample_rate()))
.collect();
config_strs.join(", ")
})
.unwrap_or_else(|| "no configs".to_string());
log::debug!(
"[qbz-audio] [{}] Device: '{}' (default: {}) - Configs: {}",
idx,
name,
is_default,
configs_info
);
// Use the CPAL name for both `name` and `description`: PipeWire
// CPAL names are already user-friendly, and storing the same
// value as `name` guarantees the saved id reopens correctly.
Some(OutputSinkInfo {
name: name.clone(),
description: name,
volume: None,
is_default,
})
})
.collect();
log::debug!(
"[qbz-audio] Found {} audio output devices via CPAL",
sinks.len()
);
Ok(sinks)
}
/// Enumerate the system's CPAL output devices (macOS/Windows).
///
/// CPAL device names on these platforms are already descriptive enough
/// to display directly to the user.
#[cfg(not(target_os = "linux"))]
pub fn list_output_sinks() -> Result<Vec<OutputSinkInfo>, String> {
log::info!("[qbz-audio] list_output_sinks (non-Linux, using CPAL)");
use rodio::cpal::traits::HostTrait;
let host = rodio::cpal::default_host();
let default_device_name = host
.default_output_device()
.and_then(|d| cpal_device_name(&d));
let sinks: Vec<OutputSinkInfo> = host
.output_devices()
.map_err(|e| format!("Failed to enumerate devices: {}", e))?
.filter_map(|device| {
cpal_device_name(&device).map(|name| {
let is_default = default_device_name
.as_ref()
.map(|d| d == &name)
.unwrap_or(false);
OutputSinkInfo {
name: name.clone(),
description: name,
volume: None,
is_default,
}
})
})
.collect();
log::info!("[qbz-audio] Found {} audio output devices", sinks.len());
Ok(sinks)
}