#![deny(missing_docs)]
use std::fmt;
use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
#[cfg(feature = "pulseaudio")]
use std::{cell::RefCell, rc::Rc};
#[cfg(feature = "pulseaudio")]
mod pulse;
pub struct AudioDevice {
id: String,
name: String,
#[cfg(feature = "pulseaudio")]
conn: Rc<RefCell<Option<pulse::PulseConnection>>>,
}
impl fmt::Debug for AudioDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AudioDevice")
.field("id", &self.id)
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl fmt::Display for AudioDevice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.name, self.id)
}
}
#[cfg(feature = "pulseaudio")]
impl AudioDevice {
fn get_or_connect(
opt: &mut Option<pulse::PulseConnection>,
) -> Result<&mut pulse::PulseConnection, AudioError> {
if opt.is_none() {
*opt = Some(pulse::PulseConnection::new()?);
}
opt.as_mut()
.ok_or_else(|| AudioError::InitializationFailed("connection slot was empty".into()))
}
}
impl AudioDeviceTrait for AudioDevice {
fn from_default() -> Result<Self, AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut conn = pulse::PulseConnection::new()?;
let sink_name = conn.default_sink_name()?;
let snap = conn.sink_by_name(&sink_name)?;
Ok(AudioDevice {
id: snap.name,
name: snap.description,
conn: Rc::new(RefCell::new(Some(conn))),
})
}
#[cfg(not(feature = "pulseaudio"))]
Err(AudioError::Unsupported)
}
fn from_id(id: &str) -> Result<Self, AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut conn = pulse::PulseConnection::new()?;
let snap = conn.sink_by_name(id)?;
Ok(AudioDevice {
id: snap.name,
name: snap.description,
conn: Rc::new(RefCell::new(Some(conn))),
})
}
#[cfg(not(feature = "pulseaudio"))]
{
let _ = id;
Err(AudioError::Unsupported)
}
}
fn from_name(name: &str) -> Result<Self, AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut conn = pulse::PulseConnection::new()?;
let snap = conn.sink_matching_description(name)?;
Ok(AudioDevice {
id: snap.name,
name: snap.description,
conn: Rc::new(RefCell::new(Some(conn))),
})
}
#[cfg(not(feature = "pulseaudio"))]
{
let _ = name;
Err(AudioError::Unsupported)
}
}
fn list() -> Result<Vec<DeviceInfo>, AudioError> {
#[cfg(feature = "pulseaudio")]
{
pulse::PulseConnection::new()?.list_sinks()
}
#[cfg(not(feature = "pulseaudio"))]
Err(AudioError::Unsupported)
}
fn get_vol(&self) -> Result<u8, AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut guard = self.conn.borrow_mut();
let conn = Self::get_or_connect(&mut guard)?;
Ok(conn.sink_by_name(&self.id)?.volume)
}
#[cfg(not(feature = "pulseaudio"))]
Err(AudioError::Unsupported)
}
fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut guard = self.conn.borrow_mut();
let conn = Self::get_or_connect(&mut guard)?;
conn.set_sink_volume(&self.id, vol)
}
#[cfg(not(feature = "pulseaudio"))]
{
let _ = vol;
Err(AudioError::Unsupported)
}
}
fn is_mute(&self) -> Result<bool, AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut guard = self.conn.borrow_mut();
let conn = Self::get_or_connect(&mut guard)?;
Ok(conn.sink_by_name(&self.id)?.mute)
}
#[cfg(not(feature = "pulseaudio"))]
Err(AudioError::Unsupported)
}
fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
#[cfg(feature = "pulseaudio")]
{
let mut guard = self.conn.borrow_mut();
let conn = Self::get_or_connect(&mut guard)?;
conn.set_sink_mute(&self.id, muted)
}
#[cfg(not(feature = "pulseaudio"))]
{
let _ = muted;
Err(AudioError::Unsupported)
}
}
fn id(&self) -> &str {
&self.id
}
fn name(&self) -> &str {
&self.name
}
}
#[cfg(test)]
mod tests {
use super::*;
use volumecontrol_core::AudioDevice as AudioDeviceTrait;
#[test]
fn display_format_is_name_paren_id() {
let device = AudioDevice {
id: "alsa_output.pci-0000_00_1b.0.analog-stereo".to_string(),
name: "Built-in Audio Analog Stereo".to_string(),
#[cfg(feature = "pulseaudio")]
conn: std::rc::Rc::new(std::cell::RefCell::new(None)),
};
assert_eq!(
device.to_string(),
"Built-in Audio Analog Stereo (alsa_output.pci-0000_00_1b.0.analog-stereo)"
);
}
#[cfg(not(feature = "pulseaudio"))]
#[test]
fn default_returns_unsupported_without_feature() {
let result = AudioDevice::from_default();
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "pulseaudio"))]
#[test]
fn from_id_returns_unsupported_without_feature() {
let result = AudioDevice::from_id("test-id");
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "pulseaudio"))]
#[test]
fn from_name_returns_unsupported_without_feature() {
let result = AudioDevice::from_name("test-name");
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "pulseaudio"))]
#[test]
fn list_returns_unsupported_without_feature() {
let result = AudioDevice::list();
assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
}
#[cfg(not(feature = "pulseaudio"))]
#[test]
fn self_methods_return_unsupported_without_feature() {
let device = AudioDevice {
id: String::new(),
name: String::new(),
};
assert!(matches!(
device.get_vol().unwrap_err(),
AudioError::Unsupported
));
assert!(matches!(
device.set_vol(50).unwrap_err(),
AudioError::Unsupported
));
assert!(matches!(
device.is_mute().unwrap_err(),
AudioError::Unsupported
));
assert!(matches!(
device.set_mute(false).unwrap_err(),
AudioError::Unsupported
));
}
#[cfg(feature = "pulseaudio")]
#[test]
fn from_id_fails_for_nonexistent_sink() {
let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
assert!(result.is_err(), "expected an error, got Ok");
let err = result.unwrap_err();
assert!(
matches!(
err,
AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
),
"unexpected error variant: {err:?}"
);
}
#[cfg(feature = "pulseaudio")]
#[test]
fn from_name_fails_for_nonexistent_description() {
let result = AudioDevice::from_name("__nonexistent_description_xyz__");
assert!(result.is_err(), "expected an error, got Ok");
let err = result.unwrap_err();
assert!(
matches!(
err,
AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
),
"unexpected error variant: {err:?}"
);
}
#[cfg(feature = "pulseaudio")]
#[test]
fn list_returns_ok_or_init_failed() {
let result = AudioDevice::list();
match &result {
Ok(_) => {}
Err(AudioError::InitializationFailed(_)) => {}
Err(e) => panic!("unexpected error from list(): {e:?}"),
}
}
#[cfg(feature = "pulseaudio")]
#[test]
fn default_returns_ok_or_known_error() {
let result = AudioDevice::from_default();
match &result {
Ok(_) => {}
Err(AudioError::InitializationFailed(_)) | Err(AudioError::DeviceNotFound) => {}
Err(e) => panic!("unexpected error from from_default(): {e:?}"),
}
}
#[cfg(feature = "pulseaudio")]
#[test]
fn self_methods_fail_for_nonexistent_sink() {
let device = AudioDevice {
id: "__nonexistent_sink_xyz__".to_string(),
name: String::new(),
conn: Rc::new(RefCell::new(None)),
};
let result = device.get_vol();
assert!(result.is_err(), "get_vol: expected error, got Ok");
assert!(
matches!(
result.unwrap_err(),
AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
),
"get_vol: unexpected error variant"
);
let result = device.is_mute();
assert!(result.is_err(), "is_mute: expected error, got Ok");
assert!(
matches!(
result.unwrap_err(),
AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
),
"is_mute: unexpected error variant"
);
let result = device.set_vol(50);
assert!(result.is_err(), "set_vol: expected error, got Ok");
assert!(
matches!(
result.unwrap_err(),
AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
),
"set_vol: unexpected error variant"
);
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn default_returns_ok() {
let device = AudioDevice::from_default();
assert!(device.is_ok(), "expected Ok, got {device:?}");
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn list_returns_nonempty() {
let devices = AudioDevice::list().expect("list()");
assert!(
!devices.is_empty(),
"expected at least one audio device from list()"
);
for info in &devices {
assert!(!info.id.is_empty(), "device id must not be empty");
assert!(!info.name.is_empty(), "device name must not be empty");
}
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn from_id_valid_id_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
let found_device = match AudioDevice::from_id(default_device.id()) {
Ok(d) => d,
Err(e) => panic!("from_id with valid id should succeed, got {e:?}"),
};
assert_eq!(found_device.id(), default_device.id());
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn from_id_nonexistent_returns_not_found() {
let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
match result {
Err(AudioError::DeviceNotFound) => {}
other => panic!("expected DeviceNotFound, got {other:?}"),
}
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn from_name_partial_match_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
let partial: String = default_device.name().chars().take(3).collect();
let found = AudioDevice::from_name(&partial);
assert!(
found.is_ok(),
"from_name with partial match '{partial}' should succeed"
);
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn from_name_case_insensitive_match_returns_ok() {
let default_device = AudioDevice::from_default().expect("from_default()");
let upper = default_device.name().to_uppercase();
let found = AudioDevice::from_name(&upper);
assert!(
found.is_ok(),
"from_name with uppercase query '{upper}' should succeed (case-insensitive)"
);
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn from_name_no_match_returns_not_found() {
let result = AudioDevice::from_name("\x00\x01\x02");
match result {
Err(AudioError::DeviceNotFound) => {}
other => panic!("expected DeviceNotFound, got {other:?}"),
}
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn get_vol_returns_valid_range() {
let device = AudioDevice::from_default().expect("from_default()");
let vol = device.get_vol().expect("get_vol()");
assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn set_vol_changes_volume() {
let device = AudioDevice::from_default().expect("from_default()");
let original = device.get_vol().expect("get_vol()");
let target: u8 = if original >= 50 { 30 } else { 70 };
device.set_vol(target).expect("set_vol()");
let after = device.get_vol().expect("get_vol() after set");
assert!(
after.abs_diff(target) <= 1,
"expected volume near {target}, got {after}"
);
device.set_vol(original).expect("restore original volume");
}
#[cfg(all(feature = "pulseaudio", target_os = "linux"))]
#[test]
fn set_mute_changes_mute_state() {
let device = AudioDevice::from_default().expect("from_default()");
let original = device.is_mute().expect("is_mute()");
let target = !original;
device.set_mute(target).expect("set_mute()");
let after = device.is_mute().expect("is_mute() after set");
assert_eq!(after, target, "mute state should be {target}, got {after}");
device
.set_mute(original)
.expect("restore original mute state");
}
}