1use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Mutex;
35
36static ENABLED: AtomicBool = AtomicBool::new(false);
40
41static RESUME: Mutex<ResumeToken> = Mutex::new(ResumeToken::Noop);
44
45#[derive(Debug, Clone, PartialEq, Eq)]
47enum ResumeToken {
48 Noop,
51 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
53 Mpris(Vec<String>),
54 #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
57 MediaKey,
58}
59
60pub fn set_enabled(enabled: bool) {
63 ENABLED.store(enabled, Ordering::Relaxed);
64}
65
66pub fn on_break_start() {
69 on_break_start_with(platform_pause);
70}
71
72fn on_break_start_with(pause: impl FnOnce() -> ResumeToken) {
78 if !ENABLED.load(Ordering::Relaxed) {
79 return;
80 }
81 let token = pause();
82 if token != ResumeToken::Noop {
83 *lock_resume() = token;
87 }
88}
89
90pub fn on_break_end() {
94 on_break_end_with(platform_resume);
95}
96
97fn on_break_end_with(resume: impl FnOnce(&ResumeToken)) {
101 let token = std::mem::replace(&mut *lock_resume(), ResumeToken::Noop);
102 resume(&token);
103}
104
105fn lock_resume() -> std::sync::MutexGuard<'static, ResumeToken> {
108 RESUME.lock().unwrap_or_else(|e| e.into_inner())
109}
110
111#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub(crate) enum PlaybackStatus {
117 Playing,
118 Paused,
119 Stopped,
120}
121
122#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
127pub(crate) fn parse_mpris_names(text: &str) -> Vec<String> {
128 const PREFIX: &str = "org.mpris.MediaPlayer2.";
129 let mut out: Vec<String> = Vec::new();
130 for (i, token) in text.split('\'').enumerate() {
133 if i % 2 == 1 && token.starts_with(PREFIX) && !out.iter().any(|n| n == token) {
134 out.push(token.to_string());
135 }
136 }
137 out
138}
139
140#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
143pub(crate) fn parse_playback_status(text: &str) -> Option<PlaybackStatus> {
144 let t = text.trim();
145 if t.contains("'Playing'") {
146 Some(PlaybackStatus::Playing)
147 } else if t.contains("'Paused'") {
148 Some(PlaybackStatus::Paused)
149 } else if t.contains("'Stopped'") {
150 Some(PlaybackStatus::Stopped)
151 } else {
152 None
153 }
154}
155
156#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
160pub(crate) fn players_to_pause(statuses: &[(String, Option<PlaybackStatus>)]) -> Vec<String> {
161 statuses
162 .iter()
163 .filter(|(_, s)| *s == Some(PlaybackStatus::Playing))
164 .map(|(name, _)| name.clone())
165 .collect()
166}
167
168const MPRIS_DBUS_DEST: &str = "org.freedesktop.DBus";
177const MPRIS_DBUS_PATH: &str = "/org/freedesktop/DBus";
178const MPRIS_PLAYER_PATH: &str = "/org/mpris/MediaPlayer2";
179const MPRIS_PLAYER_IFACE: &str = "org.mpris.MediaPlayer2.Player";
180
181#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
185type DbusCall<'a> = &'a dyn Fn(&str, &str, &str, &[&str]) -> Option<String>;
186
187#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
190fn plan_and_pause(call: DbusCall) -> ResumeToken {
191 let names = match call(
192 MPRIS_DBUS_DEST,
193 MPRIS_DBUS_PATH,
194 "org.freedesktop.DBus.ListNames",
195 &[],
196 ) {
197 Some(out) => parse_mpris_names(&out),
198 None => return ResumeToken::Noop,
199 };
200 if names.is_empty() {
201 return ResumeToken::Noop;
202 }
203 let statuses: Vec<(String, Option<PlaybackStatus>)> = names
204 .into_iter()
205 .map(|name| {
206 let status = call(
207 &name,
208 MPRIS_PLAYER_PATH,
209 "org.freedesktop.DBus.Properties.Get",
210 &[MPRIS_PLAYER_IFACE, "PlaybackStatus"],
211 )
212 .and_then(|out| parse_playback_status(&out));
213 (name, status)
214 })
215 .collect();
216 let to_pause = players_to_pause(&statuses);
217 if to_pause.is_empty() {
218 return ResumeToken::Noop;
219 }
220 for name in &to_pause {
221 call(name, MPRIS_PLAYER_PATH, &player_method("Pause"), &[]);
222 }
223 log::info!("media: paused {} MPRIS player(s) for break", to_pause.len());
224 ResumeToken::Mpris(to_pause)
225}
226
227#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
229fn resume_all(names: &[String], call: DbusCall) {
230 for name in names {
231 call(name, MPRIS_PLAYER_PATH, &player_method("Play"), &[]);
232 }
233 if !names.is_empty() {
234 log::info!("media: resumed {} MPRIS player(s) after break", names.len());
235 }
236}
237
238#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
239fn player_method(method: &str) -> String {
240 format!("{MPRIS_PLAYER_IFACE}.{method}")
241}
242
243#[cfg(target_os = "linux")]
246fn platform_pause() -> ResumeToken {
247 plan_and_pause(&linux::gdbus_call)
248}
249
250#[cfg(target_os = "linux")]
251fn platform_resume(token: &ResumeToken) {
252 if let ResumeToken::Mpris(names) = token {
253 resume_all(names, &linux::gdbus_call);
254 }
255}
256
257#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
265fn media_key_pause_allowed(media_likely_playing: bool) -> bool {
266 media_likely_playing
267}
268
269#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
274fn media_key_resume_allowed(token: &ResumeToken) -> bool {
275 matches!(token, ResumeToken::MediaKey)
276}
277
278#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
282const SILENCE_THRESHOLD: f32 = 0.003;
283
284#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
290fn is_audible(peak: f32) -> bool {
291 peak > SILENCE_THRESHOLD
292}
293
294#[cfg(any(target_os = "macos", target_os = "windows"))]
295fn platform_pause() -> ResumeToken {
296 #[cfg(target_os = "macos")]
301 let media_likely_playing = audio_tap::output_active();
302 #[cfg(target_os = "windows")]
303 let media_likely_playing = audio_session::output_active();
304 if !media_key_pause_allowed(media_likely_playing) {
305 return ResumeToken::Noop;
306 }
307 if media_key::send_play_pause() {
308 log::info!("media: sent play/pause media key for break");
309 ResumeToken::MediaKey
310 } else {
311 ResumeToken::Noop
312 }
313}
314
315#[cfg(any(target_os = "macos", target_os = "windows"))]
316fn platform_resume(token: &ResumeToken) {
317 if media_key_resume_allowed(token) && media_key::send_play_pause() {
318 log::info!("media: sent play/pause media key to resume after break");
319 }
320}
321
322#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
323fn platform_pause() -> ResumeToken {
324 ResumeToken::Noop
325}
326
327#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
328fn platform_resume(_token: &ResumeToken) {}
329
330#[cfg(target_os = "linux")]
331mod linux {
332 use std::process::Command;
333
334 use crate::proc::{CommandTimeoutExt, PROBE_TIMEOUT};
335
336 const GDBUS_BIN: &str = "/usr/bin/gdbus";
340
341 pub(super) fn gdbus_call(
347 dest: &str,
348 path: &str,
349 method: &str,
350 args: &[&str],
351 ) -> Option<String> {
352 let mut cmd = Command::new(GDBUS_BIN);
353 cmd.args([
354 "call",
355 "--session",
356 "--dest",
357 dest,
358 "--object-path",
359 path,
360 "--method",
361 method,
362 ]);
363 for arg in args {
364 cmd.arg(arg);
365 }
366 let out = cmd.output_timeout(PROBE_TIMEOUT).ok()?;
367 if !out.status.success() {
368 return None;
369 }
370 String::from_utf8(out.stdout).ok()
371 }
372}
373
374#[cfg(target_os = "macos")]
375mod media_key {
376 use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
377 use objc2_core_graphics::{CGEvent, CGEventTapLocation};
378 use objc2_foundation::NSPoint;
379
380 const NX_KEYTYPE_PLAY: isize = 16;
382 const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
383 const KEY_DOWN: isize = 0xA;
384 const KEY_UP: isize = 0xB;
385
386 pub(super) fn send_play_pause() -> bool {
387 post(KEY_DOWN) && post(KEY_UP)
389 }
390
391 fn post(key_state: isize) -> bool {
392 let data1 = (NX_KEYTYPE_PLAY << 16) | (key_state << 8);
396 let event = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
397 NSEventType::SystemDefined,
398 NSPoint::ZERO,
399 NSEventModifierFlags::empty(),
400 0.0,
401 0,
402 None,
403 NX_SUBTYPE_AUX_CONTROL_BUTTONS,
404 data1,
405 -1,
406 );
407 let Some(event) = event else {
408 return false;
409 };
410 let Some(cg_event) = event.CGEvent() else {
411 return false;
412 };
413 CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event));
414 true
415 }
416}
417
418#[cfg(target_os = "macos")]
419mod audio_tap {
420 use std::ffi::{c_void, CStr};
436 use std::ptr::NonNull;
437 use std::sync::atomic::{AtomicBool, Ordering};
438 use std::sync::Arc;
439 use std::time::{Duration, Instant};
440
441 use block2::RcBlock;
442 use objc2::runtime::AnyObject;
443 use objc2::AllocAnyThread;
444 use objc2_core_audio::{
445 kAudioAggregateDeviceIsPrivateKey, kAudioAggregateDeviceNameKey,
446 kAudioAggregateDeviceTapAutoStartKey, kAudioAggregateDeviceTapListKey,
447 kAudioAggregateDeviceUIDKey, kAudioObjectPropertyElementMain,
448 kAudioObjectPropertyScopeGlobal, kAudioSubTapDriftCompensationKey, kAudioSubTapUIDKey,
449 kAudioTapPropertyUID, AudioDeviceCreateIOProcIDWithBlock, AudioDeviceDestroyIOProcID,
450 AudioDeviceIOProcID, AudioDeviceStart, AudioDeviceStop, AudioHardwareCreateAggregateDevice,
451 AudioHardwareCreateProcessTap, AudioHardwareDestroyAggregateDevice,
452 AudioHardwareDestroyProcessTap, AudioObjectGetPropertyData, AudioObjectID,
453 AudioObjectPropertyAddress, CATapDescription, CATapMuteBehavior,
454 };
455 use objc2_core_audio_types::{AudioBufferList, AudioTimeStamp};
456 use objc2_core_foundation::{CFDictionary, CFRetained, CFString};
457 use objc2_foundation::{NSArray, NSDictionary, NSNumber, NSString, NSUUID};
458
459 const PROBE_WINDOW: Duration = Duration::from_millis(80);
465 const POLL_STEP: Duration = Duration::from_millis(10);
466
467 fn process_tap_supported() -> bool {
477 use objc2_foundation::{NSOperatingSystemVersion, NSProcessInfo};
478 let required = NSOperatingSystemVersion {
479 majorVersion: 14,
480 minorVersion: 2,
481 patchVersion: 0,
482 };
483 NSProcessInfo::processInfo().isOperatingSystemAtLeastVersion(required)
484 }
485
486 pub(super) fn output_active() -> bool {
487 if !process_tap_supported() {
488 return false;
489 }
490 measure().unwrap_or(false)
491 }
492
493 struct TapSession {
496 tap: AudioObjectID,
497 agg: AudioObjectID,
498 proc_id: AudioDeviceIOProcID,
499 started: bool,
500 }
501
502 impl Drop for TapSession {
503 fn drop(&mut self) {
504 unsafe {
507 if self.started {
508 AudioDeviceStop(self.agg, self.proc_id);
509 }
510 if self.proc_id.is_some() {
511 AudioDeviceDestroyIOProcID(self.agg, self.proc_id);
512 }
513 if self.agg != 0 {
514 AudioHardwareDestroyAggregateDevice(self.agg);
515 }
516 if self.tap != 0 {
517 AudioHardwareDestroyProcessTap(self.tap);
518 }
519 }
520 }
521 }
522
523 fn ns(key: &CStr) -> objc2::rc::Retained<NSString> {
524 NSString::from_str(key.to_str().unwrap_or_default())
525 }
526
527 fn read_uid(object: AudioObjectID, selector: u32) -> Option<objc2::rc::Retained<NSString>> {
529 let address = AudioObjectPropertyAddress {
530 mSelector: selector,
531 mScope: kAudioObjectPropertyScopeGlobal,
532 mElement: kAudioObjectPropertyElementMain,
533 };
534 let mut cf: *const CFString = std::ptr::null();
535 let mut size = std::mem::size_of::<*const CFString>() as u32;
536 let status = unsafe {
539 AudioObjectGetPropertyData(
540 object,
541 NonNull::from(&address),
542 0,
543 std::ptr::null(),
544 NonNull::from(&mut size),
545 NonNull::from(&mut cf).cast::<c_void>(),
546 )
547 };
548 if status != 0 {
552 return None;
553 }
554 let cf = NonNull::new(cf as *mut CFString)?;
555 let owned = unsafe { CFRetained::from_raw(cf) };
557 Some(NSString::from_str(&owned.to_string()))
558 }
559
560 fn aggregate_description(
562 tap_uid: &NSString,
563 ) -> objc2::rc::Retained<NSDictionary<NSString, AnyObject>> {
564 let drift = NSNumber::new_i32(1);
565 let sub_keys = [
566 &*ns(kAudioSubTapUIDKey),
567 &*ns(kAudioSubTapDriftCompensationKey),
568 ];
569 let sub_vals: [&AnyObject; 2] = [tap_uid, &drift];
570 let sub = NSDictionary::<NSString, AnyObject>::from_slices(&sub_keys, &sub_vals);
571 let tap_list = NSArray::from_retained_slice(&[sub]);
572
573 let name = NSString::from_str("entracte-playing-probe-agg");
574 let uid = NSUUID::new().UUIDString();
575 let yes = NSNumber::new_i32(1);
576 let keys = [
577 &*ns(kAudioAggregateDeviceNameKey),
578 &*ns(kAudioAggregateDeviceUIDKey),
579 &*ns(kAudioAggregateDeviceIsPrivateKey),
580 &*ns(kAudioAggregateDeviceTapAutoStartKey),
581 &*ns(kAudioAggregateDeviceTapListKey),
582 ];
583 let vals: [&AnyObject; 5] = [&name, &uid, &yes, &yes, &tap_list];
584 NSDictionary::<NSString, AnyObject>::from_slices(&keys, &vals)
585 }
586
587 fn measure() -> Option<bool> {
588 let audible = Arc::new(AtomicBool::new(false));
595 let audible_cb = audible.clone();
596 let block = RcBlock::new(
597 move |_now: NonNull<AudioTimeStamp>,
598 in_data: NonNull<AudioBufferList>,
599 _in_time: NonNull<AudioTimeStamp>,
600 _out: NonNull<AudioBufferList>,
601 _out_time: NonNull<AudioTimeStamp>| {
602 if buffers_audible(in_data) {
603 audible_cb.store(true, Ordering::Relaxed);
604 }
605 },
606 );
607
608 let empty: objc2::rc::Retained<NSArray<NSNumber>> = NSArray::from_retained_slice(&[]);
616 let desc = unsafe {
617 CATapDescription::initStereoGlobalTapButExcludeProcesses(
618 CATapDescription::alloc(),
619 &empty,
620 )
621 };
622 unsafe {
623 desc.setName(&NSString::from_str("entracte-playing-probe"));
624 desc.setPrivate(true);
625 desc.setMuteBehavior(CATapMuteBehavior::Unmuted);
626 }
627 let mut tap: AudioObjectID = 0;
628 if unsafe { AudioHardwareCreateProcessTap(Some(&desc), &mut tap) } != 0 || tap == 0 {
629 return None;
630 }
631 let mut session = TapSession {
632 tap,
633 agg: 0,
634 proc_id: None,
635 started: false,
636 };
637
638 let tap_uid = read_uid(tap, kAudioTapPropertyUID)?;
640 let dict = aggregate_description(&tap_uid);
641 let cf_dict: &CFDictionary =
642 unsafe { &*(objc2::rc::Retained::as_ptr(&dict) as *const CFDictionary) };
643 let mut agg: AudioObjectID = 0;
644 if unsafe { AudioHardwareCreateAggregateDevice(cf_dict, NonNull::from(&mut agg)) } != 0
645 || agg == 0
646 {
647 return None;
648 }
649 session.agg = agg;
650
651 let mut proc_id: AudioDeviceIOProcID = None;
653 if unsafe {
654 AudioDeviceCreateIOProcIDWithBlock(
655 NonNull::from(&mut proc_id),
656 agg,
657 None,
658 RcBlock::as_ptr(&block) as _,
659 )
660 } != 0
661 || proc_id.is_none()
662 {
663 return None;
664 }
665 session.proc_id = proc_id;
666 if unsafe { AudioDeviceStart(agg, proc_id) } != 0 {
667 return None;
668 }
669 session.started = true;
670
671 let deadline = Instant::now() + PROBE_WINDOW;
673 while Instant::now() < deadline {
674 if audible.load(Ordering::Relaxed) {
675 break;
676 }
677 std::thread::sleep(POLL_STEP);
678 }
679 Some(audible.load(Ordering::Relaxed))
680 }
682
683 fn buffers_audible(in_data: NonNull<AudioBufferList>) -> bool {
685 unsafe {
688 let list = in_data.as_ref();
689 let buffers =
690 std::slice::from_raw_parts(list.mBuffers.as_ptr(), list.mNumberBuffers as usize);
691 let mut peak: f32 = 0.0;
692 for buf in buffers {
693 if buf.mData.is_null() {
694 continue;
695 }
696 let count = buf.mDataByteSize as usize / std::mem::size_of::<f32>();
697 let samples = std::slice::from_raw_parts(buf.mData as *const f32, count);
698 for &s in samples {
699 let a = s.abs();
700 if a > peak {
701 peak = a;
702 }
703 }
704 }
705 super::is_audible(peak)
706 }
707 }
708}
709
710#[cfg(target_os = "windows")]
711mod media_key {
712 use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
713 SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP,
714 };
715
716 const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3;
717
718 pub(super) fn send_play_pause() -> bool {
719 unsafe {
722 let mut inputs: [INPUT; 2] = std::mem::zeroed();
723 inputs[0].r#type = INPUT_KEYBOARD;
724 inputs[0].Anonymous.ki = KEYBDINPUT {
725 wVk: VK_MEDIA_PLAY_PAUSE,
726 wScan: 0,
727 dwFlags: 0,
728 time: 0,
729 dwExtraInfo: 0,
730 };
731 inputs[1].r#type = INPUT_KEYBOARD;
732 inputs[1].Anonymous.ki = KEYBDINPUT {
733 wVk: VK_MEDIA_PLAY_PAUSE,
734 wScan: 0,
735 dwFlags: KEYEVENTF_KEYUP,
736 time: 0,
737 dwExtraInfo: 0,
738 };
739 let sent = SendInput(2, inputs.as_ptr(), std::mem::size_of::<INPUT>() as i32);
740 sent == 2
741 }
742 }
743}
744
745#[cfg(target_os = "windows")]
764mod audio_session {
765 use std::time::{Duration, Instant};
766 use windows::Win32::Foundation::RPC_E_CHANGED_MODE;
767 use windows::Win32::Media::Audio::Endpoints::IAudioMeterInformation;
768 use windows::Win32::Media::Audio::{
769 eMultimedia, eRender, IMMDeviceEnumerator, MMDeviceEnumerator,
770 };
771 use windows::Win32::System::Com::{
772 CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_ALL, COINIT_MULTITHREADED,
773 };
774
775 const PROBE_WINDOW: Duration = Duration::from_millis(80);
780 const POLL_STEP: Duration = Duration::from_millis(10);
781
782 struct ComGuard;
786 impl Drop for ComGuard {
787 fn drop(&mut self) {
788 unsafe { CoUninitialize() };
790 }
791 }
792
793 pub(super) fn output_active() -> bool {
798 unsafe {
803 let init = CoInitializeEx(None, COINIT_MULTITHREADED);
804 let _guard = if init == RPC_E_CHANGED_MODE {
810 None
811 } else if init.is_ok() {
812 Some(ComGuard)
813 } else {
814 return false;
815 };
816
817 let Ok(enumerator) =
818 CoCreateInstance::<_, IMMDeviceEnumerator>(&MMDeviceEnumerator, None, CLSCTX_ALL)
819 else {
820 return false;
821 };
822 let Ok(device) = enumerator.GetDefaultAudioEndpoint(eRender, eMultimedia) else {
823 return false;
824 };
825 let Ok(meter) = device.Activate::<IAudioMeterInformation>(CLSCTX_ALL, None) else {
826 return false;
827 };
828
829 let deadline = Instant::now() + PROBE_WINDOW;
830 loop {
831 if let Ok(peak) = meter.GetPeakValue() {
832 if super::is_audible(peak) {
833 return true;
834 }
835 }
836 if Instant::now() >= deadline {
837 return false;
838 }
839 std::thread::sleep(POLL_STEP);
840 }
841 }
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::*;
848
849 #[test]
850 fn parse_mpris_names_extracts_only_mpris_players() {
851 let sample = "([objectpath ], ['org.freedesktop.DBus', 'org.mpris.MediaPlayer2.vlc', \
852 ':1.42', 'org.mpris.MediaPlayer2.spotify'],)";
853 let names = parse_mpris_names(sample);
854 assert_eq!(
855 names,
856 vec![
857 "org.mpris.MediaPlayer2.vlc".to_string(),
858 "org.mpris.MediaPlayer2.spotify".to_string(),
859 ]
860 );
861 }
862
863 #[test]
864 fn parse_mpris_names_empty_when_no_players() {
865 let sample = "(['org.freedesktop.DBus', ':1.10', 'org.gnome.Shell'],)";
866 assert!(parse_mpris_names(sample).is_empty());
867 }
868
869 #[test]
870 fn parse_mpris_names_dedupes_repeated_names() {
871 let sample = "(['org.mpris.MediaPlayer2.vlc', 'org.mpris.MediaPlayer2.vlc'],)";
872 assert_eq!(
873 parse_mpris_names(sample),
874 vec!["org.mpris.MediaPlayer2.vlc".to_string()]
875 );
876 }
877
878 #[test]
879 fn parse_playback_status_reads_each_state() {
880 assert_eq!(
881 parse_playback_status("(<'Playing'>,)"),
882 Some(PlaybackStatus::Playing)
883 );
884 assert_eq!(
885 parse_playback_status("(<'Paused'>,)"),
886 Some(PlaybackStatus::Paused)
887 );
888 assert_eq!(
889 parse_playback_status("(<'Stopped'>,)"),
890 Some(PlaybackStatus::Stopped)
891 );
892 }
893
894 #[test]
895 fn parse_playback_status_none_for_unparseable() {
896 assert_eq!(parse_playback_status(""), None);
897 assert_eq!(parse_playback_status("(<''>,)"), None);
898 assert_eq!(parse_playback_status("error: no such property"), None);
899 }
900
901 #[test]
902 fn players_to_pause_keeps_only_playing() {
903 let statuses = vec![
904 (
905 "org.mpris.MediaPlayer2.vlc".to_string(),
906 Some(PlaybackStatus::Playing),
907 ),
908 (
909 "org.mpris.MediaPlayer2.spotify".to_string(),
910 Some(PlaybackStatus::Paused),
911 ),
912 (
913 "org.mpris.MediaPlayer2.firefox".to_string(),
914 Some(PlaybackStatus::Stopped),
915 ),
916 ("org.mpris.MediaPlayer2.mpv".to_string(), None),
917 ];
918 assert_eq!(
919 players_to_pause(&statuses),
920 vec!["org.mpris.MediaPlayer2.vlc".to_string()]
921 );
922 }
923
924 #[test]
925 fn players_to_pause_empty_when_nothing_playing() {
926 let statuses = vec![(
927 "org.mpris.MediaPlayer2.vlc".to_string(),
928 Some(PlaybackStatus::Paused),
929 )];
930 assert!(players_to_pause(&statuses).is_empty());
931 }
932
933 use std::cell::RefCell;
936
937 struct FakeBus {
941 names_output: Option<String>,
942 status_output: std::collections::HashMap<String, String>,
943 calls: RefCell<Vec<(String, String)>>, }
945
946 impl FakeBus {
947 fn call(&self, dest: &str, _path: &str, method: &str, _args: &[&str]) -> Option<String> {
948 self.calls
949 .borrow_mut()
950 .push((method.to_string(), dest.to_string()));
951 if method == "org.freedesktop.DBus.ListNames" {
952 self.names_output.clone()
953 } else if method == "org.freedesktop.DBus.Properties.Get" {
954 self.status_output.get(dest).cloned()
955 } else {
956 Some("()".to_string())
958 }
959 }
960
961 fn methods_called(&self, needle: &str) -> Vec<String> {
962 self.calls
963 .borrow()
964 .iter()
965 .filter(|(m, _)| m.ends_with(needle))
966 .map(|(_, dest)| dest.clone())
967 .collect()
968 }
969 }
970
971 fn status_map(pairs: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
972 pairs
973 .iter()
974 .map(|(name, status)| (name.to_string(), format!("(<'{status}'>,)")))
975 .collect()
976 }
977
978 #[test]
979 fn plan_and_pause_pauses_only_playing_players() {
980 let bus = FakeBus {
981 names_output: Some(
982 "(['org.mpris.MediaPlayer2.vlc', 'org.mpris.MediaPlayer2.spotify'],)".to_string(),
983 ),
984 status_output: status_map(&[
985 ("org.mpris.MediaPlayer2.vlc", "Playing"),
986 ("org.mpris.MediaPlayer2.spotify", "Paused"),
987 ]),
988 calls: RefCell::new(Vec::new()),
989 };
990 let token = plan_and_pause(&|d, p, m, a| bus.call(d, p, m, a));
991 assert_eq!(
992 token,
993 ResumeToken::Mpris(vec!["org.mpris.MediaPlayer2.vlc".to_string()])
994 );
995 assert_eq!(
997 bus.methods_called("Pause"),
998 vec!["org.mpris.MediaPlayer2.vlc".to_string()]
999 );
1000 }
1001
1002 #[test]
1003 fn plan_and_pause_noop_when_no_players() {
1004 let bus = FakeBus {
1005 names_output: Some("(['org.freedesktop.DBus', ':1.5'],)".to_string()),
1006 status_output: status_map(&[]),
1007 calls: RefCell::new(Vec::new()),
1008 };
1009 assert_eq!(
1010 plan_and_pause(&|d, p, m, a| bus.call(d, p, m, a)),
1011 ResumeToken::Noop
1012 );
1013 assert!(bus.methods_called("Pause").is_empty());
1014 }
1015
1016 #[test]
1017 fn plan_and_pause_noop_when_nothing_playing() {
1018 let bus = FakeBus {
1019 names_output: Some("(['org.mpris.MediaPlayer2.vlc'],)".to_string()),
1020 status_output: status_map(&[("org.mpris.MediaPlayer2.vlc", "Paused")]),
1021 calls: RefCell::new(Vec::new()),
1022 };
1023 assert_eq!(
1024 plan_and_pause(&|d, p, m, a| bus.call(d, p, m, a)),
1025 ResumeToken::Noop
1026 );
1027 assert!(bus.methods_called("Pause").is_empty());
1028 }
1029
1030 #[test]
1031 fn plan_and_pause_noop_when_listnames_fails() {
1032 let bus = FakeBus {
1033 names_output: None,
1034 status_output: status_map(&[]),
1035 calls: RefCell::new(Vec::new()),
1036 };
1037 assert_eq!(
1038 plan_and_pause(&|d, p, m, a| bus.call(d, p, m, a)),
1039 ResumeToken::Noop
1040 );
1041 }
1042
1043 #[test]
1044 fn plan_and_pause_skips_players_with_unreadable_status() {
1045 let bus = FakeBus {
1048 names_output: Some(
1049 "(['org.mpris.MediaPlayer2.vlc', 'org.mpris.MediaPlayer2.broken'],)".to_string(),
1050 ),
1051 status_output: status_map(&[("org.mpris.MediaPlayer2.vlc", "Playing")]),
1052 calls: RefCell::new(Vec::new()),
1053 };
1054 let token = plan_and_pause(&|d, p, m, a| bus.call(d, p, m, a));
1055 assert_eq!(
1056 token,
1057 ResumeToken::Mpris(vec!["org.mpris.MediaPlayer2.vlc".to_string()])
1058 );
1059 }
1060
1061 #[test]
1062 fn resume_all_plays_each_named_player() {
1063 let bus = FakeBus {
1064 names_output: None,
1065 status_output: status_map(&[]),
1066 calls: RefCell::new(Vec::new()),
1067 };
1068 let names = vec![
1069 "org.mpris.MediaPlayer2.vlc".to_string(),
1070 "org.mpris.MediaPlayer2.spotify".to_string(),
1071 ];
1072 resume_all(&names, &|d, p, m, a| bus.call(d, p, m, a));
1073 assert_eq!(bus.methods_called("Play"), names);
1074 }
1075
1076 #[test]
1077 fn resume_all_empty_is_a_noop() {
1078 let bus = FakeBus {
1079 names_output: None,
1080 status_output: status_map(&[]),
1081 calls: RefCell::new(Vec::new()),
1082 };
1083 resume_all(&[], &|d, p, m, a| bus.call(d, p, m, a));
1084 assert!(bus.calls.borrow().is_empty());
1085 }
1086
1087 #[test]
1088 fn player_method_qualifies_with_interface() {
1089 assert_eq!(
1090 player_method("Pause"),
1091 "org.mpris.MediaPlayer2.Player.Pause"
1092 );
1093 assert_eq!(player_method("Play"), "org.mpris.MediaPlayer2.Player.Play");
1094 }
1095
1096 #[test]
1097 fn media_key_pause_allowed_only_when_media_likely_playing() {
1098 assert!(media_key_pause_allowed(true));
1099 assert!(!media_key_pause_allowed(false));
1100 }
1101
1102 #[test]
1103 fn media_key_resume_allowed_only_for_media_key_token() {
1104 assert!(media_key_resume_allowed(&ResumeToken::MediaKey));
1105 assert!(!media_key_resume_allowed(&ResumeToken::Noop));
1106 assert!(!media_key_resume_allowed(&ResumeToken::Mpris(vec![
1107 "org.mpris.MediaPlayer2.vlc".to_string()
1108 ])));
1109 }
1110
1111 #[test]
1115 fn is_audible_separates_silence_from_signal() {
1116 assert!(!is_audible(0.0));
1117 assert!(!is_audible(0.001));
1118 assert!(!is_audible(SILENCE_THRESHOLD));
1120 assert!(is_audible(0.05));
1121 assert!(is_audible(0.9));
1122 }
1123
1124 #[cfg(target_os = "macos")]
1132 #[test]
1133 fn output_active_probe_resolves_without_panicking() {
1134 let _: bool = audio_tap::output_active();
1135 }
1136
1137 #[cfg(target_os = "windows")]
1145 #[test]
1146 fn windows_output_active_probe_resolves_without_panicking() {
1147 let _: bool = audio_session::output_active();
1148 }
1149
1150 #[test]
1151 fn start_gates_on_enabled_and_end_always_drains_token() {
1152 use std::cell::Cell;
1163
1164 fn paused_media_key() -> ResumeToken {
1168 ResumeToken::MediaKey
1169 }
1170
1171 *lock_resume() = ResumeToken::Noop;
1177 set_enabled(false);
1178 on_break_start_with(paused_media_key);
1179 on_break_start();
1180 assert_eq!(*lock_resume(), ResumeToken::Noop);
1181
1182 set_enabled(true);
1184 on_break_start_with(paused_media_key);
1185 assert_eq!(*lock_resume(), ResumeToken::MediaKey);
1186
1187 let resumed_with: Cell<Option<ResumeToken>> = Cell::new(None);
1191 on_break_end_with(|t| resumed_with.set(Some(t.clone())));
1192 assert_eq!(resumed_with.take(), Some(ResumeToken::MediaKey));
1193 assert_eq!(*lock_resume(), ResumeToken::Noop);
1194
1195 set_enabled(false);
1198 on_break_end();
1199 assert_eq!(*lock_resume(), ResumeToken::Noop);
1200 }
1201}