Skip to main content

entracte_lib/
media.rs

1//! Pause and resume external media around breaks (issue #77).
2//!
3//! When the user enables "Pause media while a break is showing", the
4//! scheduler calls [`on_break_start`] as a break overlay opens and
5//! [`on_break_end`] when it closes. Notification-only breaks don't block
6//! the screen and have no defined end, so they intentionally don't reach
7//! here — only the overlay path (`fire_break`) does.
8//!
9//! Platform behaviour differs by what each OS lets us inspect:
10//!
11//! - **Linux** is precise. We enumerate MPRIS players on the session bus
12//!   via the `gdbus` CLI (the same dependency-free approach the DnD probe
13//!   uses), pause only the players currently reporting `Playing`,
14//!   remember them, and resume exactly those when the break ends.
15//! - **macOS / Windows** have no portable way to enumerate players, so we
16//!   synthesise the system Play/Pause media key — a best-effort toggle.
17//!   Because the key is a toggle (there's no separate "pause" key), we only
18//!   send it when an "is media actually playing?" probe says yes, so we don't
19//!   accidentally *start* media that was paused: a real audio-output probe that
20//!   tells a paused player apart from one merely holding the audio device open
21//!   — a CoreAudio process tap on macOS (#233) and a WASAPI endpoint peak meter
22//!   on Windows (#234). The matching resume sends the same key again.
23//!
24//! The testable core is pure and lives at module scope so it compiles and
25//! is unit-tested on every OS, mirroring [`crate::video`]: the gdbus output
26//! parsers and the "which players are Playing" decision (Linux), and the
27//! "may the blind toggle fire?" guards ([`media_key_pause_allowed`] /
28//! [`media_key_resume_allowed`], macOS/Windows). The guards keep the toggle
29//! from ever *starting* media the user had paused (#104): pause only when the
30//! platform probe says something is playing, and resume only a toggle we
31//! ourselves sent.
32
33use std::sync::atomic::{AtomicBool, Ordering};
34use std::sync::Mutex;
35
36/// Mirrors `Settings::pause_media_during_breaks`. The scheduler refreshes
37/// this each tick so the synchronous overlay path ([`on_break_start`])
38/// can read it without locking the async settings mutex.
39static ENABLED: AtomicBool = AtomicBool::new(false);
40
41/// What [`on_break_start`] did, so [`on_break_end`] reverses exactly that
42/// and never blindly toggles media that was already paused.
43static RESUME: Mutex<ResumeToken> = Mutex::new(ResumeToken::Noop);
44
45/// Records the action a break-start pause took.
46#[derive(Debug, Clone, PartialEq, Eq)]
47enum ResumeToken {
48    /// Nothing to resume: feature off, nothing was playing, or the
49    /// platform isn't supported.
50    Noop,
51    /// Linux: the MPRIS bus names we paused (they were `Playing`).
52    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
53    Mpris(Vec<String>),
54    /// macOS / Windows: we sent a best-effort Play/Pause media key, so the
55    /// resume sends it again.
56    #[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
57    MediaKey,
58}
59
60/// Mirror the current setting into the process-wide flag. Called by the
61/// scheduler run loop once per tick.
62pub fn set_enabled(enabled: bool) {
63    ENABLED.store(enabled, Ordering::Relaxed);
64}
65
66/// Called as a break overlay opens. No-op unless the feature is enabled.
67/// Performs the (fast, infrequent) platform media-pause inline.
68pub fn on_break_start() {
69    on_break_start_with(platform_pause);
70}
71
72/// Testable core of [`on_break_start`]: the enabled-gate and token
73/// bookkeeping, with the platform pause action injected. The injection
74/// keeps unit tests off the real key-send — `platform_pause` posts a
75/// genuine system Play/Pause media key on macOS/Windows, which would
76/// toggle whatever the developer is playing every time the suite runs.
77fn 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        // If a previous break never resumed (app killed mid-break, say),
84        // its media is already paused; overwrite the stale token rather
85        // than stacking — the target players are the same either way.
86        *lock_resume() = token;
87    }
88}
89
90/// Called as a break overlay closes. Resumes whatever [`on_break_start`]
91/// paused. Deliberately NOT gated on `ENABLED`: if the user toggled the
92/// feature off mid-break, we still resume what we paused.
93pub fn on_break_end() {
94    on_break_end_with(platform_resume);
95}
96
97/// Testable core of [`on_break_end`]: always drains the stored token and
98/// hands it to the injected resume action (see [`on_break_start_with`]
99/// for why the action is injected rather than called directly).
100fn on_break_end_with(resume: impl FnOnce(&ResumeToken)) {
101    let token = std::mem::replace(&mut *lock_resume(), ResumeToken::Noop);
102    resume(&token);
103}
104
105/// A poisoned lock only means a previous holder panicked; the media state
106/// is best-effort, so recover the guard and carry on rather than panic.
107fn lock_resume() -> std::sync::MutexGuard<'static, ResumeToken> {
108    RESUME.lock().unwrap_or_else(|e| e.into_inner())
109}
110
111// --- Pure, cross-platform cores (compiled and tested on every OS) -------
112
113/// Playback state from an MPRIS player's `PlaybackStatus` property.
114#[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/// Parse `gdbus call … org.freedesktop.DBus.ListNames` output into the
123/// MPRIS player bus names. gdbus prints one GVariant tuple, e.g.
124/// `([... 'org.mpris.MediaPlayer2.vlc', 'org.freedesktop.DBus', ...],)`;
125/// we collect every single-quoted token with the MPRIS prefix, de-duped.
126#[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    // Tokens are wrapped in single quotes; the odd-indexed splits are the
131    // quoted contents.
132    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/// Parse `gdbus call … Properties.Get … PlaybackStatus` output. gdbus
141/// prints the variant-wrapped value, e.g. `(<'Playing'>,)`.
142#[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/// Given each player's status, the bus names to pause: only those
157/// actively `Playing`. Pure, so the pause set is testable without a
158/// session bus.
159#[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
168// --- MPRIS orchestration (Linux) ---------------------------------------
169//
170// The decide-and-act flow is parameterised by a session-bus `call` so it
171// can be unit-tested with a faked bus on any OS. Only the real `gdbus`
172// subprocess wrapper (`linux::gdbus_call`) stays platform-bound. Kept at
173// module level (like the parsers above) so it compiles and is tested on
174// every CI runner; `allow(dead_code)` off-Linux mirrors the parsers.
175
176const 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/// A session-bus call: `(dest, object_path, method, args) -> stdout`, or
182/// `None` on failure. The production impl shells out to `gdbus`; tests
183/// pass a fake.
184#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
185type DbusCall<'a> = &'a dyn Fn(&str, &str, &str, &[&str]) -> Option<String>;
186
187/// List MPRIS players, pause the ones currently `Playing`, and return a
188/// token naming exactly those (so resume reverses only what we paused).
189#[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/// Resume the named players (those a prior `plan_and_pause` paused).
228#[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// --- Platform dispatch --------------------------------------------------
244
245#[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/// Decide whether the macOS/Windows blind Play/Pause toggle may fire on
258/// break start. The toggle has no separate "pause" key, so sending it when
259/// nothing is playing would *start* media the user had paused (issue #104).
260/// We therefore only allow it when the platform's "is media actually
261/// playing?" probe says yes — a real audio-output probe on both macOS (a
262/// CoreAudio tap, #233) and Windows (a WASAPI peak meter, #234). Pure so it's
263/// unit-tested without FFI on every OS.
264#[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/// Decide whether the resume toggle may fire on break end. Only reverse a
270/// toggle we actually sent — never blindly hit the media key for a break we
271/// did not pause, so we can't *start* media the user left paused (#104).
272/// Pure so it's unit-tested without FFI on every OS.
273#[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/// A measured output peak above this counts as real audio. Digital silence is
279/// ~0.0, so any small positive floor cleanly separates a paused player (no
280/// output) from an active one; the margin ignores denormal/dither noise.
281#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
282const SILENCE_THRESHOLD: f32 = 0.003;
283
284/// Pure decision shared by both audio-output probes: is a measured peak
285/// amplitude loud enough to be real playback? One threshold so the macOS tap
286/// (#233) and the Windows peak meter (#234) judge "audible" identically instead
287/// of drifting apart behind two copies. Pure, so it's unit-tested without FFI
288/// on every OS (like [`media_key_pause_allowed`] just above).
289#[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    // Both platforms now gate on real audio output, true only when something
297    // is actually making sound: a CoreAudio process tap on macOS (#233), a
298    // WASAPI endpoint peak meter on Windows (#234). Neither is fooled by a
299    // paused player that merely holds the audio device open.
300    #[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    // Absolute path so a planted `gdbus` earlier in `$PATH` can't
337    // intercept the session-bus calls. `/usr/bin/gdbus` ships in
338    // glib2/libglib2.0-bin on every distro we target.
339    const GDBUS_BIN: &str = "/usr/bin/gdbus";
340
341    /// The real session-bus call: shell out to `gdbus`. This is the only
342    /// platform-bound piece — the decide-and-act flow lives in
343    /// `super::plan_and_pause` / `super::resume_all`, which take this as a
344    /// `DbusCall` and are unit-tested with a fake. A thin subprocess
345    /// wrapper with no branching of its own.
346    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    // System-defined event for the aux media buttons.
381    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        // A media keypress is a down followed by an up.
388        post(KEY_DOWN) && post(KEY_UP)
389    }
390
391    fn post(key_state: isize) -> bool {
392        // Construct an NSSystemDefined aux-button event and post its backing
393        // CGEvent to the HID tap. `data1` packs the key code and up/down
394        // state the way the media-key HID protocol expects.
395        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    //! Detect whether macOS is producing *real audio output right now* by
421    //! briefly tapping the system output and measuring the actual signal.
422    //!
423    //! Every cheaper signal we tried lies about paused media: a display-wake
424    //! assertion (#103) and CoreAudio `DeviceIsRunningSomewhere` (#233) both
425    //! read "active" while Chrome / Spotify / Apple Music sit paused but keep
426    //! the output device's IOProc alive. The private MediaRemote now-playing
427    //! API reads the true state but is restricted to Apple platform binaries
428    //! on macOS 15.4+, so a third-party app can't use it. A CoreAudio *process
429    //! tap* (macOS 14.2+) is the one public, unentitled signal that reflects
430    //! reality: it captures the samples actually being mixed to the device, so
431    //! a paused player contributes digital silence (exactly 0.0) and reads as
432    //! not playing. That is what keeps a break from *starting* media the user
433    //! had paused.
434
435    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    // Upper bound on how long we wait for output before concluding it's silent.
460    // We early-exit the instant an audible sample arrives (real playback lands
461    // in the first buffer or two, ~10-20ms), so this bound is only paid on the
462    // silent case — which runs synchronously on the break-fire path before the
463    // overlay opens, so keep it tight to avoid janking every silent break.
464    const PROBE_WINDOW: Duration = Duration::from_millis(80);
465    const POLL_STEP: Duration = Duration::from_millis(10);
466
467    /// True when the system is emitting real audio output right now. Opens a
468    /// private global process tap, measures the live output signal for at most
469    /// [`PROBE_WINDOW`], and tears the tap down. Any FFI failure degrades to
470    /// `false` — never a blind Play/Pause toggle on a guess.
471    /// Process taps are macOS 14.2+. On older systems the tap symbols are
472    /// weak-linked (see `build.rs`) and resolve to null, so we must never call
473    /// them. Gating here degrades cleanly: `output_active` returns false, the
474    /// media key is not toggled, so a break neither pauses playing media nor
475    /// starts paused media on < 14.2 — the feature is simply inert there.
476    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    /// Owns the tap + aggregate device + IOProc so every early return tears
494    /// them down in order, leaving no private CoreAudio objects behind.
495    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            // SAFETY: each id is either zero/None (skipped) or a live object we
505            // created; teardown is the documented reverse of construction.
506            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    /// Read a CFString-valued audio object property into an owned `NSString`.
528    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        // SAFETY: reads a single CFStringRef-sized value into `cf`; `address`
537        // and `size` are valid for the call.
538        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        // Check the status before adopting the pointer: on a non-zero status we
549        // must not take ownership of whatever `cf` holds (it may be an
550        // uninitialised or non-owned value), so bail before `CFRetained`.
551        if status != 0 {
552            return None;
553        }
554        let cf = NonNull::new(cf as *mut CFString)?;
555        // `Get` returns a +1 reference we own; `CFRetained` releases it.
556        let owned = unsafe { CFRetained::from_raw(cf) };
557        Some(NSString::from_str(&owned.to_string()))
558    }
559
560    /// Build the aggregate-device description embedding our tap.
561    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        // The IOProc flag and its block are created first, so `block` is
589        // declared before `session`: locals drop in reverse declaration order,
590        // so `session` (whose Drop calls AudioDeviceDestroyIOProcID) tears down
591        // *before* our RcBlock reference is released, on every return path.
592        // CoreAudio Block_copy's the block too, but this makes the ordering
593        // correct by construction instead of relying on that.
594        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        // 1) Private global output tap (exclude no processes = tap everything).
609        //    KNOWN LIMITATION (#233 follow-up): a global tap answers "is *any*
610        //    audio playing", not "is the media player playing", so a stray
611        //    system/notification sound inside the probe window can read as
612        //    audible and let the blind Play/Pause key fire. The tight
613        //    PROBE_WINDOW keeps the exposure small; per-source attribution
614        //    isn't available from a global tap.
615        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        // 2) Aggregate device wrapping the tap so we can run an IOProc on it.
639        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        // 3) Install the IOProc (built above) so it flips the flag on audio.
652        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        // 4) Wait until we hear something or the window elapses.
672        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        // `session` drops here, tearing the tap down.
681    }
682
683    /// True if any sample across the buffer list exceeds the silence floor.
684    fn buffers_audible(in_data: NonNull<AudioBufferList>) -> bool {
685        // SAFETY: CoreAudio hands us a valid AudioBufferList for the IO cycle;
686        // each buffer's `mData`/`mDataByteSize` describe a float32 sample run.
687        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        // SAFETY: a fixed two-element INPUT array (key-down, key-up) for a
720        // single virtual key, passed to SendInput with the matching size.
721        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/// Windows analogue of macOS's [`audio_tap`] (#234): "is real audio coming out
746/// of the default render device right now?" measured from the WASAPI endpoint
747/// peak meter, so the blind Play/Pause key only fires when something is
748/// genuinely playing. This replaces the old display-wake proxy, which a paused
749/// player holding a display request could trip — starting media the user had
750/// paused, the same class of bug #233 fixed on macOS.
751///
752/// KNOWN LIMITATIONS (parity with the macOS global tap, and strictly better
753/// than the old display-wake proxy):
754/// - The endpoint meter sums *all* sessions on the device, so it answers "is
755///   any audio playing", not "is the media player playing" — a stray
756///   system/notification sound inside the probe window can read as audible and
757///   let the blind toggle fire. The tight [`PROBE_WINDOW`] keeps the exposure
758///   small; per-source attribution isn't available from the endpoint meter.
759/// - It meters only the *default* multimedia render endpoint, whereas the
760///   macOS tap is global. Media routed to a non-default output device reads as
761///   silent, so it won't be paused — a false negative, i.e. the safe direction
762///   for #234 (which was about false positives *starting* paused media).
763#[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    // Upper bound on how long we sample before concluding it's silent. We
776    // early-exit the instant an audible peak arrives, so this bound is only
777    // paid on the silent case; it runs synchronously on the break-fire path
778    // before the overlay opens, so keep it tight to avoid janking a break.
779    const PROBE_WINDOW: Duration = Duration::from_millis(80);
780    const POLL_STEP: Duration = Duration::from_millis(10);
781
782    /// Balances a `CoInitializeEx` that we owned (returned `S_OK`/`S_FALSE`).
783    /// Declared before the COM interface bindings so it drops *last* —
784    /// `CoUninitialize` must run only after every interface pointer is released.
785    struct ComGuard;
786    impl Drop for ComGuard {
787        fn drop(&mut self) {
788            // SAFETY: paired with the matching CoInitializeEx on this thread.
789            unsafe { CoUninitialize() };
790        }
791    }
792
793    /// True when the default render endpoint is emitting real audio right now.
794    /// Samples the WASAPI peak meter for at most [`PROBE_WINDOW`]. Any COM
795    /// failure — no audio device, metering unavailable — degrades to `false`,
796    /// never a blind Play/Pause toggle on a guess.
797    pub(super) fn output_active() -> bool {
798        // SAFETY: a standard WASAPI endpoint-metering sequence. Every COM object
799        // is created here; the apartment we open is released by `ComGuard` and
800        // the interface pointers drop at scope end (before the guard, which is
801        // declared first and so dropped last).
802        unsafe {
803            let init = CoInitializeEx(None, COINIT_MULTITHREADED);
804            // RPC_E_CHANGED_MODE: this thread already joined a different (STA)
805            // apartment — COM is still usable, but we must not pair a
806            // CoUninitialize we don't own. S_OK / S_FALSE: we (re)initialised
807            // and own the matching CoUninitialize. Any other HRESULT is a real
808            // failure, so bail without toggling.
809            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    // ----- plan_and_pause / resume_all: MPRIS orchestration over a fake bus -----
934
935    use std::cell::RefCell;
936
937    /// A fake `gdbus` caller: answers ListNames + per-player PlaybackStatus
938    /// from canned maps and records every Pause/Play method invoked, so the
939    /// orchestration is exercised without a real session bus.
940    struct FakeBus {
941        names_output: Option<String>,
942        status_output: std::collections::HashMap<String, String>,
943        calls: RefCell<Vec<(String, String)>>, // (method, dest)
944    }
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                // Pause / Play — gdbus returns an empty success tuple.
957                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        // Only the Playing player was sent Pause.
996        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        // A player whose PlaybackStatus can't be read is treated as
1046        // not-playing and left alone.
1047        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    // The shared output-probe decision (#233/#234): digital silence is ~0.0, so
1112    // only a positive peak past the small floor counts as real playback. One
1113    // test for the one threshold both platforms' probes now feed.
1114    #[test]
1115    fn is_audible_separates_silence_from_signal() {
1116        assert!(!is_audible(0.0));
1117        assert!(!is_audible(0.001));
1118        // The threshold is exclusive: a peak exactly at the floor is silence.
1119        assert!(!is_audible(SILENCE_THRESHOLD));
1120        assert!(is_audible(0.05));
1121        assert!(is_audible(0.9));
1122    }
1123
1124    // Smoke-test the macOS output-tap FFI end to end: create the global process
1125    // tap + aggregate device + IOProc, sample, and tear it all down. The value
1126    // is environment-dependent (false on a silent runner), so we only assert it
1127    // returns within the probe window without panicking or leaking — that
1128    // exercises the CoreAudio/objc2 wiring a mismatch would crash on. macOS
1129    // only, where the module exists; absent from the Linux coverage build like
1130    // the other platform FFI.
1131    #[cfg(target_os = "macos")]
1132    #[test]
1133    fn output_active_probe_resolves_without_panicking() {
1134        let _: bool = audio_tap::output_active();
1135    }
1136
1137    // Smoke-test the Windows WASAPI probe end to end: initialise COM, resolve
1138    // the default render endpoint, activate the peak meter, sample, and tear it
1139    // down. The value is environment-dependent (false on a headless runner with
1140    // no audio device), so we only assert it resolves within the probe window
1141    // without panicking — that exercises the COM wiring a signature or feature
1142    // mismatch would crash on. Windows only, where the module exists; absent
1143    // from the Linux coverage build like the other platform FFI.
1144    #[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        // One test for the global-state orchestration so it can't race a
1153        // sibling on the process-wide statics (these are the only tests
1154        // that touch ENABLED / RESUME).
1155        //
1156        // Drive the platform action through the injectable cores rather
1157        // than the real `on_break_start`/`on_break_end`: the latter post a
1158        // genuine system Play/Pause media key on macOS/Windows, which would
1159        // toggle whatever the developer is playing every test run. The
1160        // public wrappers are still exercised via the safe disabled path
1161        // below.
1162        use std::cell::Cell;
1163
1164        // Stand-in for the platform pause that always reports it paused
1165        // something. Used for both the disabled and enabled cases below;
1166        // the enabled case exercises its body so no line is left uncovered.
1167        fn paused_media_key() -> ResumeToken {
1168            ResumeToken::MediaKey
1169        }
1170
1171        // Feature off: the platform pause is never consulted, so nothing is
1172        // recorded for end to undo — driving the core with a pause that
1173        // *would* report MediaKey, the resume slot still stays Noop. The
1174        // real `on_break_start` is safe here (it returns before touching the
1175        // platform), so it also covers the public wrapper.
1176        *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        // Feature on: start stores whatever the platform pause returns.
1183        set_enabled(true);
1184        on_break_start_with(paused_media_key);
1185        assert_eq!(*lock_resume(), ResumeToken::MediaKey);
1186
1187        // End always drains the stored token and hands it to resume,
1188        // regardless of the enabled flag — capture it with a spy instead of
1189        // posting a real media key.
1190        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        // A Noop token is safe to run through the real `on_break_end`, so
1196        // use it to cover the public wrapper.
1197        set_enabled(false);
1198        on_break_end();
1199        assert_eq!(*lock_resume(), ResumeToken::Noop);
1200    }
1201}