1#![deny(missing_docs)]
35
36use std::fmt;
37
38use volumecontrol_core::{AudioDevice as AudioDeviceTrait, AudioError, DeviceInfo};
39
40#[cfg(feature = "pulseaudio")]
41use std::{cell::RefCell, rc::Rc};
42
43#[cfg(feature = "pulseaudio")]
44mod pulse;
45
46pub struct AudioDevice {
62 id: String,
64 name: String,
66 #[cfg(feature = "pulseaudio")]
72 conn: Rc<RefCell<Option<pulse::PulseConnection>>>,
73}
74
75impl fmt::Debug for AudioDevice {
76 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77 f.debug_struct("AudioDevice")
78 .field("id", &self.id)
79 .field("name", &self.name)
80 .finish_non_exhaustive()
81 }
82}
83
84impl fmt::Display for AudioDevice {
85 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 write!(f, "{} ({})", self.name, self.id)
88 }
89}
90
91#[cfg(feature = "pulseaudio")]
92impl AudioDevice {
93 fn get_or_connect(
99 opt: &mut Option<pulse::PulseConnection>,
100 ) -> Result<&mut pulse::PulseConnection, AudioError> {
101 if opt.is_none() {
102 *opt = Some(pulse::PulseConnection::new()?);
103 }
104 opt.as_mut()
105 .ok_or_else(|| AudioError::InitializationFailed("connection slot was empty".into()))
106 }
107}
108
109impl AudioDeviceTrait for AudioDevice {
110 fn from_default() -> Result<Self, AudioError> {
111 #[cfg(feature = "pulseaudio")]
112 {
113 let mut conn = pulse::PulseConnection::new()?;
114 let sink_name = conn.default_sink_name()?;
115 let snap = conn.sink_by_name(&sink_name)?;
116 Ok(AudioDevice {
117 id: snap.name,
118 name: snap.description,
119 conn: Rc::new(RefCell::new(Some(conn))),
120 })
121 }
122 #[cfg(not(feature = "pulseaudio"))]
123 Err(AudioError::Unsupported)
124 }
125
126 fn from_id(id: &str) -> Result<Self, AudioError> {
127 #[cfg(feature = "pulseaudio")]
128 {
129 let mut conn = pulse::PulseConnection::new()?;
130 let snap = conn.sink_by_name(id)?;
131 Ok(AudioDevice {
132 id: snap.name,
133 name: snap.description,
134 conn: Rc::new(RefCell::new(Some(conn))),
135 })
136 }
137 #[cfg(not(feature = "pulseaudio"))]
138 {
139 let _ = id;
140 Err(AudioError::Unsupported)
141 }
142 }
143
144 fn from_name(name: &str) -> Result<Self, AudioError> {
145 #[cfg(feature = "pulseaudio")]
146 {
147 let mut conn = pulse::PulseConnection::new()?;
148 let snap = conn.sink_matching_description(name)?;
149 Ok(AudioDevice {
150 id: snap.name,
151 name: snap.description,
152 conn: Rc::new(RefCell::new(Some(conn))),
153 })
154 }
155 #[cfg(not(feature = "pulseaudio"))]
156 {
157 let _ = name;
158 Err(AudioError::Unsupported)
159 }
160 }
161
162 fn list() -> Result<Vec<DeviceInfo>, AudioError> {
163 #[cfg(feature = "pulseaudio")]
164 {
165 pulse::PulseConnection::new()?.list_sinks()
166 }
167 #[cfg(not(feature = "pulseaudio"))]
168 Err(AudioError::Unsupported)
169 }
170
171 fn get_vol(&self) -> Result<u8, AudioError> {
172 #[cfg(feature = "pulseaudio")]
173 {
174 let mut guard = self.conn.borrow_mut();
175 let conn = Self::get_or_connect(&mut guard)?;
176 Ok(conn.sink_by_name(&self.id)?.volume)
177 }
178 #[cfg(not(feature = "pulseaudio"))]
179 Err(AudioError::Unsupported)
180 }
181
182 fn set_vol(&self, vol: u8) -> Result<(), AudioError> {
183 #[cfg(feature = "pulseaudio")]
184 {
185 let mut guard = self.conn.borrow_mut();
186 let conn = Self::get_or_connect(&mut guard)?;
187 conn.set_sink_volume(&self.id, vol)
188 }
189 #[cfg(not(feature = "pulseaudio"))]
190 {
191 let _ = vol;
192 Err(AudioError::Unsupported)
193 }
194 }
195
196 fn is_mute(&self) -> Result<bool, AudioError> {
197 #[cfg(feature = "pulseaudio")]
198 {
199 let mut guard = self.conn.borrow_mut();
200 let conn = Self::get_or_connect(&mut guard)?;
201 Ok(conn.sink_by_name(&self.id)?.mute)
202 }
203 #[cfg(not(feature = "pulseaudio"))]
204 Err(AudioError::Unsupported)
205 }
206
207 fn set_mute(&self, muted: bool) -> Result<(), AudioError> {
208 #[cfg(feature = "pulseaudio")]
209 {
210 let mut guard = self.conn.borrow_mut();
211 let conn = Self::get_or_connect(&mut guard)?;
212 conn.set_sink_mute(&self.id, muted)
213 }
214 #[cfg(not(feature = "pulseaudio"))]
215 {
216 let _ = muted;
217 Err(AudioError::Unsupported)
218 }
219 }
220
221 fn id(&self) -> &str {
222 &self.id
223 }
224
225 fn name(&self) -> &str {
226 &self.name
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use volumecontrol_core::AudioDevice as AudioDeviceTrait;
234
235 #[test]
237 fn display_format_is_name_paren_id() {
238 let device = AudioDevice {
239 id: "alsa_output.pci-0000_00_1b.0.analog-stereo".to_string(),
240 name: "Built-in Audio Analog Stereo".to_string(),
241 #[cfg(feature = "pulseaudio")]
242 conn: std::rc::Rc::new(std::cell::RefCell::new(None)),
243 };
244 assert_eq!(
245 device.to_string(),
246 "Built-in Audio Analog Stereo (alsa_output.pci-0000_00_1b.0.analog-stereo)"
247 );
248 }
249
250 #[cfg(not(feature = "pulseaudio"))]
251 #[test]
252 fn default_returns_unsupported_without_feature() {
253 let result = AudioDevice::from_default();
254 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
255 }
256
257 #[cfg(not(feature = "pulseaudio"))]
258 #[test]
259 fn from_id_returns_unsupported_without_feature() {
260 let result = AudioDevice::from_id("test-id");
261 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
262 }
263
264 #[cfg(not(feature = "pulseaudio"))]
265 #[test]
266 fn from_name_returns_unsupported_without_feature() {
267 let result = AudioDevice::from_name("test-name");
268 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
269 }
270
271 #[cfg(not(feature = "pulseaudio"))]
272 #[test]
273 fn list_returns_unsupported_without_feature() {
274 let result = AudioDevice::list();
275 assert!(matches!(result.unwrap_err(), AudioError::Unsupported));
276 }
277
278 #[cfg(not(feature = "pulseaudio"))]
281 #[test]
282 fn self_methods_return_unsupported_without_feature() {
283 let device = AudioDevice {
286 id: String::new(),
287 name: String::new(),
288 };
289 assert!(matches!(
290 device.get_vol().unwrap_err(),
291 AudioError::Unsupported
292 ));
293 assert!(matches!(
294 device.set_vol(50).unwrap_err(),
295 AudioError::Unsupported
296 ));
297 assert!(matches!(
298 device.is_mute().unwrap_err(),
299 AudioError::Unsupported
300 ));
301 assert!(matches!(
302 device.set_mute(false).unwrap_err(),
303 AudioError::Unsupported
304 ));
305 }
306
307 #[cfg(feature = "pulseaudio")]
319 #[test]
320 fn from_id_fails_for_nonexistent_sink() {
321 let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
322 assert!(result.is_err(), "expected an error, got Ok");
323 let err = result.unwrap_err();
324 assert!(
325 matches!(
326 err,
327 AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
328 ),
329 "unexpected error variant: {err:?}"
330 );
331 }
332
333 #[cfg(feature = "pulseaudio")]
335 #[test]
336 fn from_name_fails_for_nonexistent_description() {
337 let result = AudioDevice::from_name("__nonexistent_description_xyz__");
338 assert!(result.is_err(), "expected an error, got Ok");
339 let err = result.unwrap_err();
340 assert!(
341 matches!(
342 err,
343 AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
344 ),
345 "unexpected error variant: {err:?}"
346 );
347 }
348
349 #[cfg(feature = "pulseaudio")]
353 #[test]
354 fn list_returns_ok_or_init_failed() {
355 let result = AudioDevice::list();
356 match &result {
357 Ok(_) => {}
358 Err(AudioError::InitializationFailed(_)) => {}
359 Err(e) => panic!("unexpected error from list(): {e:?}"),
360 }
361 }
362
363 #[cfg(feature = "pulseaudio")]
366 #[test]
367 fn default_returns_ok_or_known_error() {
368 let result = AudioDevice::from_default();
369 match &result {
370 Ok(_) => {}
371 Err(AudioError::InitializationFailed(_)) | Err(AudioError::DeviceNotFound) => {}
372 Err(e) => panic!("unexpected error from from_default(): {e:?}"),
373 }
374 }
375
376 #[cfg(feature = "pulseaudio")]
384 #[test]
385 fn self_methods_fail_for_nonexistent_sink() {
386 let device = AudioDevice {
387 id: "__nonexistent_sink_xyz__".to_string(),
388 name: String::new(),
389 conn: Rc::new(RefCell::new(None)),
390 };
391
392 let result = device.get_vol();
393 assert!(result.is_err(), "get_vol: expected error, got Ok");
394 assert!(
395 matches!(
396 result.unwrap_err(),
397 AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
398 ),
399 "get_vol: unexpected error variant"
400 );
401
402 let result = device.is_mute();
403 assert!(result.is_err(), "is_mute: expected error, got Ok");
404 assert!(
405 matches!(
406 result.unwrap_err(),
407 AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
408 ),
409 "is_mute: unexpected error variant"
410 );
411
412 let result = device.set_vol(50);
415 assert!(result.is_err(), "set_vol: expected error, got Ok");
416 assert!(
417 matches!(
418 result.unwrap_err(),
419 AudioError::DeviceNotFound | AudioError::InitializationFailed(_)
420 ),
421 "set_vol: unexpected error variant"
422 );
423 }
424
425 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
433 #[test]
434 fn default_returns_ok() {
435 let device = AudioDevice::from_default();
436 assert!(device.is_ok(), "expected Ok, got {device:?}");
437 }
438
439 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
441 #[test]
442 fn list_returns_nonempty() {
443 let devices = AudioDevice::list().expect("list()");
444 assert!(
445 !devices.is_empty(),
446 "expected at least one audio device from list()"
447 );
448 for info in &devices {
449 assert!(!info.id.is_empty(), "device id must not be empty");
450 assert!(!info.name.is_empty(), "device name must not be empty");
451 }
452 }
453
454 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
457 #[test]
458 fn from_id_valid_id_returns_ok() {
459 let default_device = AudioDevice::from_default().expect("from_default()");
460 let found_device = match AudioDevice::from_id(default_device.id()) {
461 Ok(d) => d,
462 Err(e) => panic!("from_id with valid id should succeed, got {e:?}"),
463 };
464 assert_eq!(found_device.id(), default_device.id());
465 }
466
467 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
469 #[test]
470 fn from_id_nonexistent_returns_not_found() {
471 let result = AudioDevice::from_id("__nonexistent_sink_xyz__");
472 match result {
473 Err(AudioError::DeviceNotFound) => {}
474 other => panic!("expected DeviceNotFound, got {other:?}"),
475 }
476 }
477
478 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
480 #[test]
481 fn from_name_partial_match_returns_ok() {
482 let default_device = AudioDevice::from_default().expect("from_default()");
483 let partial: String = default_device.name().chars().take(3).collect();
484 let found = AudioDevice::from_name(&partial);
485 assert!(
486 found.is_ok(),
487 "from_name with partial match '{partial}' should succeed"
488 );
489 }
490
491 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
493 #[test]
494 fn from_name_case_insensitive_match_returns_ok() {
495 let default_device = AudioDevice::from_default().expect("from_default()");
498 let upper = default_device.name().to_uppercase();
499 let found = AudioDevice::from_name(&upper);
500 assert!(
501 found.is_ok(),
502 "from_name with uppercase query '{upper}' should succeed (case-insensitive)"
503 );
504 }
505
506 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
508 #[test]
509 fn from_name_no_match_returns_not_found() {
510 let result = AudioDevice::from_name("\x00\x01\x02");
511 match result {
512 Err(AudioError::DeviceNotFound) => {}
513 other => panic!("expected DeviceNotFound, got {other:?}"),
514 }
515 }
516
517 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
519 #[test]
520 fn get_vol_returns_valid_range() {
521 let device = AudioDevice::from_default().expect("from_default()");
522 let vol = device.get_vol().expect("get_vol()");
523 assert!(vol <= 100, "volume must be in 0..=100, got {vol}");
524 }
525
526 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
531 #[test]
532 fn set_vol_changes_volume() {
533 let device = AudioDevice::from_default().expect("from_default()");
534 let original = device.get_vol().expect("get_vol()");
535 let target: u8 = if original >= 50 { 30 } else { 70 };
537 device.set_vol(target).expect("set_vol()");
538 let after = device.get_vol().expect("get_vol() after set");
539 assert!(
541 after.abs_diff(target) <= 1,
542 "expected volume near {target}, got {after}"
543 );
544 device.set_vol(original).expect("restore original volume");
546 }
547
548 #[cfg(all(feature = "pulseaudio", target_os = "linux"))]
553 #[test]
554 fn set_mute_changes_mute_state() {
555 let device = AudioDevice::from_default().expect("from_default()");
556 let original = device.is_mute().expect("is_mute()");
557 let target = !original;
559 device.set_mute(target).expect("set_mute()");
560 let after = device.is_mute().expect("is_mute() after set");
561 assert_eq!(after, target, "mute state should be {target}, got {after}");
562 device
564 .set_mute(original)
565 .expect("restore original mute state");
566 }
567}