Skip to main content

azul_core/
media_session.rs

1//! The system MEDIA SESSION - what the desktop's media widget shows.
2//!
3//! This is the second output in the input subsystem, next to [`crate::haptics`]:
4//! nothing is being reported, the app is telling the platform something. It
5//! lives here because the transport it feeds is the same one the media KEYS
6//! arrive on, and because on every platform the two are one object - an app
7//! becomes eligible to receive `Play`/`Next` precisely by declaring what it is
8//! playing.
9//!
10//! # Why the app pushes this instead of the engine deriving it
11//!
12//! Azul has no playback state machine (11c is blocked on exactly that), and it
13//! never will have one that covers the interesting cases: an app playing audio
14//! through `rodio`, through a system framework, or over the network knows what
15//! it is playing and the toolkit cannot see it. So the app pushes, and the
16//! engine's only job is to fan that out to the platform session APIs.
17//!
18//! # Where it goes
19//!
20//! | platform | sink | notes |
21//! |---|---|---|
22//! | Linux | MPRIS `org.mpris.MediaPlayer2.Player` | `Metadata`, `PlaybackStatus`, `Position` |
23//! | macOS | `MPNowPlayingInfoCenter` | artwork needs image bytes, not a URL - dropped |
24//! | Windows | - | needs an SMTC backend that does not exist yet |
25//! | iOS / Android | - | both have an equivalent; no backend yet |
26//!
27//! Publishing on a platform with no sink is a no-op, not an error: the same
28//! call site has to be correct everywhere.
29
30use azul_css::AzString;
31
32/// What the player is doing right now.
33///
34/// Deliberately three states and not four: MPRIS has exactly these, and
35/// macOS's `MPNowPlayingPlaybackState` adds `unknown` and `interrupted` which
36/// no app can meaningfully assert about itself - `interrupted` is something the
37/// SYSTEM does to you (a phone call), not something you declare.
38#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
39#[repr(C)]
40pub enum MediaPlaybackState {
41    /// Nothing is loaded, or playback finished. The desktop widget shows no
42    /// track.
43    Stopped = 0,
44    /// Advancing. A desktop extrapolates the position from here, which is why
45    /// this must be honest even when the position is not being republished.
46    Playing = 1,
47    /// Loaded and holding position.
48    Paused = 2,
49}
50
51impl Default for MediaPlaybackState {
52    fn default() -> Self {
53        MediaPlaybackState::Stopped
54    }
55}
56
57/// What the app is playing, as the system media widget should show it.
58///
59/// Every field is optional in the sense that an empty string or a zero is a
60/// valid "I do not know" - there is no `Option` here because a media widget
61/// treats an absent title and an empty title identically, and the FFI cost of
62/// six `Option<AzString>`s would buy nothing.
63#[derive(Debug, Clone, PartialEq)]
64#[repr(C)]
65pub struct NowPlayingInfo {
66    /// What the player is doing. This is the field that decides whether the
67    /// desktop shows a play or a pause button.
68    pub state: MediaPlaybackState,
69    /// Track title. Empty means unknown.
70    pub title: AzString,
71    /// Performer. Empty means unknown.
72    ///
73    /// ONE artist, not a list, even though MPRIS's `xesam:artist` is an array
74    /// of strings: every widget that displays it joins the array back into one
75    /// line, and an app with several artists can join them itself with the
76    /// separator its locale wants. The MPRIS backend wraps this in a
77    /// single-element array because the spec's type demands it.
78    pub artist: AzString,
79    /// Album title. Empty means unknown.
80    pub album: AzString,
81    /// Cover art, as a URI - `file://` or `http(s)://`.
82    ///
83    /// A URI and not bytes because that is what MPRIS wants and because the
84    /// alternative makes every publish copy an image. macOS is the platform
85    /// that pays for this: `MPMediaItemArtwork` needs a decoded image, so the
86    /// macOS backend drops this field rather than fetching a URL from inside a
87    /// UI toolkit.
88    pub artwork_url: AzString,
89    /// Track length in MILLISECONDS. `0` means unknown, which is correct for a
90    /// live stream.
91    ///
92    /// 64-bit because 32-bit milliseconds overflow at 49.7 days but, more to
93    /// the point, because a `u32` of *microseconds* - the unit MPRIS actually
94    /// wants - overflows at 71 minutes, which is an ordinary audiobook chapter.
95    /// The conversion to microseconds happens in the backend.
96    pub duration_ms: u64,
97    /// How far in, in MILLISECONDS.
98    ///
99    /// SEE [`MediaSessionManager::set`]: this field deliberately does NOT
100    /// trigger a change announcement, because MPRIS forbids announcing it.
101    pub position_ms: u64,
102    /// The app's OUTPUT VOLUME, `0.0` silent to `1.0` full, or `None` when the
103    /// app does not expose one (9h-i-a-i-b).
104    ///
105    /// azul plays no audio; this is the app's own volume, published so the
106    /// desktop can show it and ask to change it - MPRIS `Volume` is a
107    /// read/WRITE property, and some desktops render a volume slider only
108    /// when it exists. A request to change it arrives as a `MediaControl`
109    /// event of kind `SetVolume`; the app applies it to its own output and
110    /// publishes the new value here. `None` answers the property with `1.0`
111    /// and still accepts writes, so a desktop's slider is never dead.
112    /// Only MPRIS has a per-player volume: Windows SMTC, the Apple remote
113    /// command centre and Android's session (for local playback) route volume
114    /// to the system mixer and carry none.
115    pub volume: azul_css::OptionF32,
116}
117
118impl Default for NowPlayingInfo {
119    /// A stopped player with no track and no volume of its own. Spelled out
120    /// rather than derived so that the "not reported" state of every field is
121    /// visible in one place: empty strings, zero times, `None` volume.
122    fn default() -> Self {
123        Self {
124            state: MediaPlaybackState::Stopped,
125            title: AzString::from_const_str(""),
126            artist: AzString::from_const_str(""),
127            album: AzString::from_const_str(""),
128            artwork_url: AzString::from_const_str(""),
129            duration_ms: 0,
130            position_ms: 0,
131            volume: azul_css::OptionF32::None,
132        }
133    }
134}
135
136impl NowPlayingInfo {
137    /// A stopped player with no track - what an app that has published nothing
138    /// looks like.
139    pub fn empty() -> Self {
140        Self::default()
141    }
142
143    /// True when two publishes differ in a way the platform must be TOLD about,
144    /// as opposed to one it will read for itself.
145    ///
146    /// Everything except the position. See [`MediaSessionManager::set`].
147    pub fn differs_in_announced_fields(&self, other: &Self) -> bool {
148        self.state != other.state
149            || self.title != other.title
150            || self.artist != other.artist
151            || self.album != other.album
152            || self.artwork_url != other.artwork_url
153            || self.duration_ms != other.duration_ms
154            || self.volume != other.volume
155    }
156
157    /// True when this is a DIFFERENT TRACK, not merely a different state of
158    /// the same one.
159    ///
160    /// MPRIS identifies a track by `mpris:trackid`, and a desktop keys its
161    /// progress bar and its "song changed" notification on that id. Minting a
162    /// new one when the user merely hit pause would reset the progress bar and
163    /// pop a notification for a track that never changed - so pausing, seeking
164    /// and cover-art changes are all the SAME track.
165    ///
166    /// The duration counts as identity because two tracks with the same title
167    /// and artist but different lengths are a live version and a studio one.
168    pub fn is_different_track(&self, other: &Self) -> bool {
169        self.title != other.title
170            || self.artist != other.artist
171            || self.album != other.album
172            || self.duration_ms != other.duration_ms
173    }
174
175    /// The track length in MICROSECONDS, which is the unit MPRIS's
176    /// `mpris:length` and `Position` both use.
177    ///
178    /// Saturating and `i64`, because D-Bus types this signed: a nonsense
179    /// duration from an app must clamp rather than wrap into a negative
180    /// length, which some clients render as a progress bar running backwards.
181    pub fn duration_us(&self) -> i64 {
182        Self::ms_to_us(self.duration_ms)
183    }
184
185    /// The playback position in MICROSECONDS. See [`Self::duration_us`].
186    pub fn position_us(&self) -> i64 {
187        Self::ms_to_us(self.position_ms)
188    }
189
190    fn ms_to_us(ms: u64) -> i64 {
191        i64::try_from(ms.saturating_mul(1000)).unwrap_or(i64::MAX)
192    }
193}
194
195/// Milliseconds as WinRT `TimeSpan` ticks, which are 100 NANOSECONDS each.
196///
197/// A third unit, disagreeing with both of the others: MPRIS wants microseconds
198/// and macOS wants seconds. Publishing milliseconds into a `TimeSpan` makes a
199/// three-minute track show as 18 microseconds and pins the scrubber at zero -
200/// the same class of silent factor error `duration_us` guards, so it gets the
201/// same treatment and the same test.
202///
203/// Saturating, because `TimeSpan::Duration` is signed and a nonsense duration
204/// must clamp rather than wrap negative.
205#[must_use]
206pub fn ms_to_winrt_ticks(ms: u64) -> i64 {
207    i64::try_from(ms.saturating_mul(10_000)).unwrap_or(i64::MAX)
208}
209
210/// Holds the current session state and remembers whether the platform has been
211/// told about it.
212///
213/// Mirrors [`crate::haptics::HapticManager`]: the callback thread writes, the
214/// shell drains once per pass. The difference is that this is a LATEST-WINS
215/// value rather than a queue - a media widget wants the current track, not
216/// every track the app has ever played.
217/// What a control request from the platform's media controls asks for
218/// (9h-i-a-i-a, 9h-i-a-i-b): everything a transport KEY cannot carry.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
220#[repr(C)]
221pub enum MediaControlKind {
222    /// Move by `position_us` RELATIVE to the current position (MPRIS `Seek`;
223    /// negative moves back). The app clamps to the track.
224    SeekRelative,
225    /// Jump to the ABSOLUTE `position_us` (MPRIS `SetPosition`, SMTC's
226    /// position change, `MPChangePlaybackPositionCommand`, `onSeekTo`).
227    SeekAbsolute,
228    /// Open and play `uri` (MPRIS `OpenUri`). `position_us` is 0.
229    OpenUri,
230    /// Set the app's output volume to `volume` (MPRIS `Volume` written;
231    /// `0.0` silent, `1.0` full, above `1.0` is amplification a client may
232    /// ask for and the app may clamp). The app applies it and publishes the
233    /// result in `NowPlayingInfo::volume`.
234    SetVolume,
235}
236
237/// One inbound request from the platform's media controls (9h-i-a-i-a): a
238/// desktop scrubber, a lock-screen slider, `playerctl position 30`, a volume
239/// slider in the desktop's media widget.
240///
241/// Unlike the transport commands, which become media KEY events, these carry
242/// a value - a position, a URI, a volume - which is why they are their own
243/// event kind (`EventType::MediaControl`) with their own data rather than a
244/// key code.
245#[derive(Debug, Clone, PartialEq)]
246#[repr(C)]
247pub struct MediaControlRequest {
248    /// The URI for `OpenUri`, empty otherwise.
249    pub uri: AzString,
250    /// For `SeekAbsolute`: the track id the request was made against, so an
251    /// app can drop a seek meant for a track that has since ended - MPRIS
252    /// says exactly that about `SetPosition`. Empty when the platform gave
253    /// none.
254    pub track_id: AzString,
255    /// Microseconds, the MPRIS unit; relative for `SeekRelative`, absolute for
256    /// `SeekAbsolute`, 0 otherwise.
257    pub position_us: i64,
258    pub kind: MediaControlKind,
259    /// For `SetVolume`: the requested volume, `0.0`..`1.0`. `0.0` otherwise.
260    pub volume: f32,
261}
262
263impl_option!(
264    MediaControlRequest,
265    OptionMediaControlRequest,
266    copy = false,
267    [Debug, Clone, PartialEq]
268);
269
270/// What the system did with the audio the app took over (9h-i-a-i-d-i).
271///
272/// Delivered as a `SystemAudioChange` event and readable through
273/// `CallbackInfo::get_system_audio_change`. The vocabulary is the union of
274/// iOS's interruption notification and Android's audio-focus changes, each
275/// variant naming what the APP should do.
276#[repr(C)]
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
278pub enum SystemAudioChange {
279    /// The takeover is in place: the app owns the system audio. iOS session
280    /// activated, Android focus granted (possibly after a delay), or a
281    /// platform where nothing needed taking (desktop mixers share).
282    Granted,
283    /// Something took it for a while - a call, an alarm, another player:
284    /// PAUSE. iOS interruption began; Android `AUDIOFOCUS_LOSS_TRANSIENT`.
285    Interrupted,
286    /// Something short wants to be heard over the app - a navigation prompt,
287    /// a notification: LOWER the volume and keep playing. Android
288    /// `AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK`; iOS has no ducking signal, it
289    /// interrupts instead.
290    Ducked,
291    /// The interruption or ducking ended and the app SHOULD RESUME where it
292    /// was: iOS ended with the "should resume" hint, Android regained
293    /// `AUDIOFOCUS_GAIN` after a transient loss.
294    Resumed,
295    /// The interruption ended WITHOUT a resume hint (iOS): the session is the
296    /// app's again, but it must wait for the user to press play.
297    Ended,
298    /// The takeover was refused or is gone for good: iOS could not activate
299    /// the session, Android refused the request or reported
300    /// `AUDIOFOCUS_LOSS`. STOP, and take over again only on the user's ask.
301    Lost,
302}
303
304impl_option!(
305    SystemAudioChange,
306    OptionSystemAudioChange,
307    [Debug, Clone, Copy, PartialEq, Eq]
308);
309
310/// A position jump larger than this, on the same track, is a SEEK the app
311/// made on its own (a click on its own progress bar) and is announced back to
312/// the desktop (`Seeked`) so a scrubber re-syncs. A player reports its
313/// position once per frame, so the natural step is ~16 ms; 2 s is far above
314/// any frame stutter and far below any jump a person would make.
315pub const POSITION_JUMP_THRESHOLD_US: i64 = 2_000_000;
316
317#[derive(Debug, Clone, PartialEq, Default)]
318pub struct MediaSessionManager {
319    info: NowPlayingInfo,
320    /// Set when [`Self::set`] changes a field the platform must be told about.
321    needs_publish: bool,
322    /// Requests received since the last pass, delivered as `MediaControl`
323    /// events by the `EventProvider` impl and cleared by the dispatcher
324    /// afterwards.
325    pending_requests: Vec<MediaControlRequest>,
326    /// The most recent seek, kept after delivery so a callback can read it
327    /// (`CallbackInfo::get_media_control_request`).
328    last_request: Option<MediaControlRequest>,
329    /// A position jump the APP reported; the platform side announces it
330    /// (MPRIS `Seeked`) once, then this is cleared.
331    seeked_to_us: Option<i64>,
332    /// Whether the app currently ASKS to own the system audio
333    /// (`CallbackInfo::set_system_audio_takeover`), 9h-i-a-i-d-i. What the
334    /// platform did about it arrives as [`SystemAudioChange`]s.
335    system_audio_active: bool,
336    /// System audio changes received since the last pass, delivered as
337    /// `SystemAudioChange` events and cleared with the requests.
338    pending_audio_changes: Vec<SystemAudioChange>,
339    /// The most recent change, kept after delivery for
340    /// `CallbackInfo::get_system_audio_change`.
341    last_audio_change: Option<SystemAudioChange>,
342}
343
344impl MediaSessionManager {
345    pub fn new() -> Self {
346        Self::default()
347    }
348
349    /// Record what the app is playing.
350    ///
351    /// # Why a position-only change does not mark this dirty
352    ///
353    /// A player calls this once per frame with an advancing position. If that
354    /// marked the session dirty, every frame would put a `PropertiesChanged`
355    /// signal on the session bus - 60 D-Bus broadcasts a second, woken up in
356    /// every listening process on the desktop.
357    ///
358    /// It would also be WRONG rather than merely wasteful: the MPRIS spec says
359    /// `Position` must not appear in `PropertiesChanged` at all, precisely
360    /// because it changes continuously. Clients extrapolate it from
361    /// `PlaybackStatus` and `Rate` and read the property when they need a
362    /// precise value, so the stored position is served on demand instead.
363    pub fn set(&mut self, info: NowPlayingInfo) {
364        if self.info.differs_in_announced_fields(&info) {
365            self.needs_publish = true;
366        }
367        // A jump on the SAME track is a seek the app made on its own
368        // (9h-i-a-i-a): remembered for the platform to announce. A new track
369        // starting at 0 is not a seek, and neither is the ordinary per-frame
370        // advance.
371        if !self.info.is_different_track(&info)
372            && (info.position_us() - self.info.position_us()).abs() > POSITION_JUMP_THRESHOLD_US
373        {
374            self.seeked_to_us = Some(info.position_us());
375        }
376        self.info = info;
377    }
378
379    /// What the app last published, whether or not the platform has been told.
380    ///
381    /// This is what a property GETTER answers with, which is why it is
382    /// unconditional: a desktop reading `Position` must get the current value
383    /// even though no announcement was made for it.
384    pub fn current(&self) -> &NowPlayingInfo {
385        &self.info
386    }
387
388    /// The session to announce, or `None` when nothing announceable changed.
389    pub fn take_if_dirty(&mut self) -> Option<NowPlayingInfo> {
390        if core::mem::take(&mut self.needs_publish) {
391            Some(self.info.clone())
392        } else {
393            None
394        }
395    }
396
397    /// An inbound seek from the platform (9h-i-a-i-a). Queued; the next pass
398    /// delivers it as a `MediaControl` event at the root.
399    pub fn push_request(&mut self, request: MediaControlRequest) {
400        self.pending_requests.push(request);
401    }
402
403    /// The seek being delivered this pass, or the last one delivered - what
404    /// a `MediaControl` callback reads.
405    #[must_use]
406    pub fn current_request(&self) -> Option<&MediaControlRequest> {
407        self.pending_requests.first().or(self.last_request.as_ref())
408    }
409
410    /// The pass is over: the queued seeks were delivered. The newest is kept
411    /// as `last_request`.
412    pub fn clear_pending_requests(&mut self) {
413        if let Some(last) = self.pending_requests.pop() {
414            self.last_request = Some(last);
415        }
416        self.pending_requests.clear();
417        if let Some(last) = self.pending_audio_changes.pop() {
418            self.last_audio_change = Some(last);
419        }
420        self.pending_audio_changes.clear();
421    }
422
423    #[must_use]
424    pub fn has_pending_requests(&self) -> bool {
425        !self.pending_requests.is_empty() || !self.pending_audio_changes.is_empty()
426    }
427
428    /// Record what the app asked for (9h-i-a-i-d-i): `true` while it wants
429    /// to own the system audio.
430    pub fn set_system_audio_active(&mut self, active: bool) {
431        self.system_audio_active = active;
432    }
433
434    #[must_use]
435    pub fn is_system_audio_active(&self) -> bool {
436        self.system_audio_active
437    }
438
439    /// Queue what the system did with the audio; delivered as a
440    /// `SystemAudioChange` event at the root on the next pass. `Lost` also
441    /// ends the app's claim, so `is_system_audio_active` reads false after it
442    /// without the app having to clean up.
443    pub fn push_system_audio_change(&mut self, change: SystemAudioChange) {
444        if change == SystemAudioChange::Lost {
445            self.system_audio_active = false;
446        }
447        self.pending_audio_changes.push(change);
448    }
449
450    /// The change being delivered on this pass, or the last one delivered.
451    #[must_use]
452    pub fn current_system_audio_change(&self) -> Option<SystemAudioChange> {
453        self.pending_audio_changes
454            .first()
455            .copied()
456            .or(self.last_audio_change)
457    }
458
459    /// The position jump to announce (MPRIS `Seeked`), if any; cleared by
460    /// the take.
461    pub fn take_seeked(&mut self) -> Option<i64> {
462        self.seeked_to_us.take()
463    }
464}
465
466impl crate::events::EventProvider for MediaSessionManager {
467    /// One `MediaControl` event per queued request, at the root: a seek is a
468    /// window-level command like a media key, not a node's.
469    fn get_pending_events(
470        &self,
471        timestamp: crate::task::Instant,
472    ) -> Vec<crate::events::SyntheticEvent> {
473        use crate::events::{
474            EventData, EventSource, EventType, MediaControlEventData, SyntheticEvent,
475            SystemAudioEventData,
476        };
477        let controls = self.pending_requests.iter().map(|req| {
478            SyntheticEvent::new(
479                EventType::MediaControl,
480                EventSource::User,
481                crate::dom::DomNodeId::ROOT,
482                timestamp.clone(),
483                EventData::MediaControl(MediaControlEventData {
484                    volume: req.volume,
485                    kind: req.kind,
486                    position_us: req.position_us,
487                }),
488            )
489        });
490        let audio = self.pending_audio_changes.iter().map(|change| {
491            SyntheticEvent::new(
492                EventType::SystemAudioChange,
493                EventSource::User,
494                crate::dom::DomNodeId::ROOT,
495                timestamp.clone(),
496                EventData::SystemAudio(SystemAudioEventData { change: *change }),
497            )
498        });
499        controls.chain(audio).collect()
500    }
501}
502
503#[cfg(test)]
504mod seek_tests {
505    use super::*;
506    use crate::events::SystemAudioEventData;
507
508    fn at(position_us: i64) -> NowPlayingInfo {
509        let mut i = NowPlayingInfo::empty();
510        i.position_ms = u64::try_from(position_us / 1000).unwrap_or(0);
511        i
512    }
513
514    #[test]
515    fn a_queued_seek_is_delivered_once_then_readable_as_the_last() {
516        use crate::events::EventProvider;
517        let mut m = MediaSessionManager::new();
518        assert!(m.current_request().is_none());
519        m.push_request(MediaControlRequest {
520            kind: MediaControlKind::SeekAbsolute,
521            position_us: 30_000_000,
522            uri: AzString::from_const_str(""),
523            track_id: AzString::from_const_str("/org/mpris/MediaPlayer2/Track/1"),
524            volume: 0.0,
525        });
526        let events = m.get_pending_events(crate::task::Instant::Tick(crate::task::SystemTick::new(0)));
527        assert_eq!(events.len(), 1);
528        assert_eq!(events[0].event_type, crate::events::EventType::MediaControl);
529        assert_eq!(m.current_request().map(|r| r.position_us), Some(30_000_000));
530        m.clear_pending_requests();
531        assert!(!m.has_pending_requests());
532        assert!(m.get_pending_events(crate::task::Instant::Tick(crate::task::SystemTick::new(0))).is_empty());
533        assert_eq!(m.current_request().map(|r| r.position_us), Some(30_000_000), "still readable");
534    }
535
536    #[test]
537    fn a_volume_change_is_announced_and_a_set_volume_request_carries_its_value() {
538        use crate::events::{EventData, EventProvider};
539        let mut m = MediaSessionManager::new();
540        let mut i = NowPlayingInfo::empty();
541        i.volume = azul_css::OptionF32::Some(0.5);
542        m.set(i.clone());
543        assert!(m.take_if_dirty().is_some(), "a volume is an announced field (MPRIS Volume)");
544        i.volume = azul_css::OptionF32::Some(0.25);
545        m.set(i.clone());
546        assert!(m.take_if_dirty().is_some(), "and so is a change to it");
547        m.set(i);
548        assert!(m.take_if_dirty().is_none(), "the same volume again is not");
549        m.push_request(MediaControlRequest {
550            kind: MediaControlKind::SetVolume,
551            position_us: 0,
552            uri: AzString::from_const_str(""),
553            track_id: AzString::from_const_str(""),
554            volume: 0.75,
555        });
556        let events = m.get_pending_events(crate::task::Instant::Tick(crate::task::SystemTick::new(0)));
557        assert_eq!(events.len(), 1);
558        match &events[0].data {
559            EventData::MediaControl(d) => {
560                assert_eq!(d.kind, MediaControlKind::SetVolume);
561                assert_eq!(d.volume, 0.75);
562            }
563            other => panic!("not a media control event: {:?}", other),
564        }
565    }
566
567    /// 9h-i-a-i-d-i: a system audio change is delivered once, stays readable,
568    /// and `Lost` ends the app's claim by itself.
569    #[test]
570    fn a_system_audio_change_is_delivered_once_and_lost_ends_the_claim() {
571        use crate::events::{EventData, EventProvider};
572        let mut m = MediaSessionManager::new();
573        m.set_system_audio_active(true);
574        m.push_system_audio_change(SystemAudioChange::Interrupted);
575        let events = m.get_pending_events(crate::task::Instant::Tick(crate::task::SystemTick::new(0)));
576        assert_eq!(events.len(), 1);
577        assert_eq!(events[0].event_type, crate::events::EventType::SystemAudioChange);
578        assert!(matches!(
579            events[0].data,
580            EventData::SystemAudio(SystemAudioEventData {
581                change: SystemAudioChange::Interrupted
582            })
583        ));
584        assert!(m.is_system_audio_active(), "an interruption does not end the claim");
585        m.clear_pending_requests();
586        assert!(m.get_pending_events(crate::task::Instant::Tick(crate::task::SystemTick::new(0))).is_empty());
587        assert_eq!(
588            m.current_system_audio_change(),
589            Some(SystemAudioChange::Interrupted),
590            "still readable"
591        );
592        m.push_system_audio_change(SystemAudioChange::Lost);
593        assert!(!m.is_system_audio_active(), "Lost ends the claim");
594    }
595
596    #[test]
597    fn only_a_jump_on_the_same_track_is_announced_as_a_seek() {
598        let mut m = MediaSessionManager::new();
599        m.set(at(1_000_000));
600        m.set(at(1_016_000));
601        assert_eq!(m.take_seeked(), None, "the per-frame advance is not a seek");
602        m.set(at(40_000_000));
603        assert_eq!(m.take_seeked(), Some(40_000_000), "a jump is");
604        assert_eq!(m.take_seeked(), None, "announced once");
605        m.set(at(1_000_000));
606        assert_eq!(m.take_seeked(), Some(1_000_000), "backwards too");
607    }
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613
614    fn track(title: &str) -> NowPlayingInfo {
615        NowPlayingInfo {
616            state: MediaPlaybackState::Playing,
617            title: title.into(),
618            ..NowPlayingInfo::empty()
619        }
620    }
621
622    #[test]
623    fn a_fresh_manager_has_nothing_to_announce() {
624        let mut m = MediaSessionManager::new();
625        assert!(m.take_if_dirty().is_none());
626        assert_eq!(*m.current(), NowPlayingInfo::empty());
627    }
628
629    #[test]
630    fn a_new_track_is_announced_once() {
631        let mut m = MediaSessionManager::new();
632        m.set(track("Aja"));
633        assert_eq!(m.take_if_dirty().map(|i| i.title), Some("Aja".into()));
634        // Draining twice must not announce the same track again: a desktop
635        // that redraws its widget on every signal would flicker.
636        assert!(m.take_if_dirty().is_none());
637    }
638
639    /// THE POINT OF THE WHOLE DIRTY SPLIT. A player pushing an advancing
640    /// position must not put a signal on the bus per frame.
641    #[test]
642    fn a_position_only_change_is_not_announced() {
643        let mut m = MediaSessionManager::new();
644        m.set(track("Aja"));
645        let _ = m.take_if_dirty();
646
647        for pos in 1..=120u64 {
648            let mut t = track("Aja");
649            t.position_ms = pos * 16;
650            m.set(t);
651            assert!(
652                m.take_if_dirty().is_none(),
653                "a position-only change announced itself at frame {pos}"
654            );
655        }
656
657        // ...but it IS stored, because the property getter answers from here.
658        assert_eq!(m.current().position_ms, 120 * 16);
659    }
660
661    /// The other half: a real change while the position is also moving still
662    /// gets announced. Comparing the whole struct would have made this pass
663    /// for the wrong reason, so it changes the state AND the position.
664    #[test]
665    fn a_real_change_is_announced_even_while_the_position_moves() {
666        let mut m = MediaSessionManager::new();
667        m.set(track("Aja"));
668        let _ = m.take_if_dirty();
669
670        let mut paused = track("Aja");
671        paused.state = MediaPlaybackState::Paused;
672        paused.position_ms = 4_000;
673        m.set(paused);
674        assert_eq!(
675            m.take_if_dirty().map(|i| i.state),
676            Some(MediaPlaybackState::Paused)
677        );
678    }
679
680    #[test]
681    fn every_announced_field_actually_announces() {
682        let base = track("Aja");
683        let mut cases: alloc::vec::Vec<(&str, NowPlayingInfo)> = alloc::vec::Vec::new();
684
685        let mut c = base.clone();
686        c.state = MediaPlaybackState::Stopped;
687        cases.push(("state", c));
688        let mut c = base.clone();
689        c.title = "Peg".into();
690        cases.push(("title", c));
691        let mut c = base.clone();
692        c.artist = "Steely Dan".into();
693        cases.push(("artist", c));
694        let mut c = base.clone();
695        c.album = "Aja".into();
696        cases.push(("album", c));
697        let mut c = base.clone();
698        c.artwork_url = "file:///cover.png".into();
699        cases.push(("artwork_url", c));
700        let mut c = base.clone();
701        c.duration_ms = 480_000;
702        cases.push(("duration_ms", c));
703
704        for (field, changed) in cases {
705            let mut m = MediaSessionManager::new();
706            m.set(base.clone());
707            let _ = m.take_if_dirty();
708            m.set(changed);
709            assert!(
710                m.take_if_dirty().is_some(),
711                "changing `{field}` did not announce itself"
712            );
713        }
714
715        // And the one field that must NOT, stated as a case in the same list
716        // so that adding a field to the struct forces a decision about it.
717        let mut m = MediaSessionManager::new();
718        m.set(base.clone());
719        let _ = m.take_if_dirty();
720        let mut moved = base.clone();
721        moved.position_ms = 9_999;
722        m.set(moved);
723        assert!(m.take_if_dirty().is_none(), "position_ms announced itself");
724    }
725
726    /// A podcast is longer than a `u32` of microseconds can hold. The struct
727    /// stores milliseconds and the backend converts, so this pins that the
728    /// conversion has room - and that a nonsense value clamps instead of
729    /// wrapping negative, which a client would render as a backwards bar.
730    #[test]
731    fn the_microsecond_conversion_has_room_and_clamps() {
732        let mut i = NowPlayingInfo::empty();
733
734        i.duration_ms = 3 * 60 * 60 * 1000;
735        assert_eq!(i.duration_us(), 10_800_000_000);
736        assert!(i.duration_us() > i64::from(u32::MAX));
737
738        i.position_ms = 1_500;
739        assert_eq!(i.position_us(), 1_500_000);
740
741        i.duration_ms = u64::MAX;
742        assert_eq!(i.duration_us(), i64::MAX, "an absurd duration must clamp");
743        assert!(i.duration_us() > 0, "and must never come out negative");
744    }
745
746    /// A THIRD unit, and the one most likely to be got wrong because it looks
747    /// like a duration rather than a count: WinRT `TimeSpan` ticks are 100ns.
748    #[test]
749    fn winrt_ticks_are_hundred_nanoseconds_and_clamp() {
750        // One second.
751        assert_eq!(ms_to_winrt_ticks(1_000), 10_000_000);
752        // A three-minute track, the value a wrong factor makes absurd.
753        assert_eq!(ms_to_winrt_ticks(180_000), 1_800_000_000);
754        assert_eq!(ms_to_winrt_ticks(0), 0);
755        assert_eq!(ms_to_winrt_ticks(u64::MAX), i64::MAX);
756        assert!(ms_to_winrt_ticks(u64::MAX) > 0, "must never wrap negative");
757    }
758
759    /// THE DISCRIMINANTS CROSS A JNI BOUNDARY. `AzulMediaSession.publish`
760    /// switches on these exact integers to pick an Android `PlaybackState`
761    /// constant, and renumbering the enum would make a playing track report as
762    /// stopped with nothing failing to compile - the same hazard as the sensor
763    /// kind codes, and the same guard.
764    ///
765    /// APPEND, never renumber, if a state is ever added.
766    #[test]
767    fn the_playback_state_discriminants_are_the_jni_wire_codes() {
768        assert_eq!(MediaPlaybackState::Stopped as i32, 0);
769        assert_eq!(MediaPlaybackState::Playing as i32, 1);
770        assert_eq!(MediaPlaybackState::Paused as i32, 2);
771    }
772
773    /// `mpris:trackid` is what a desktop keys its progress bar on, so a pause
774    /// must not look like a new track.
775    #[test]
776    fn pausing_is_the_same_track_but_a_new_title_is_not() {
777        let playing = track("Aja");
778        let mut paused = playing.clone();
779        paused.state = MediaPlaybackState::Paused;
780        paused.position_ms = 30_000;
781        assert!(!playing.is_different_track(&paused));
782
783        // Late-arriving cover art is also still the same track.
784        let mut with_art = playing.clone();
785        with_art.artwork_url = "file:///cover.png".into();
786        assert!(!playing.is_different_track(&with_art));
787
788        let next = track("Peg");
789        assert!(playing.is_different_track(&next));
790
791        // A live version shares a title and artist but not a length.
792        let mut live = playing.clone();
793        live.duration_ms = 480_000;
794        assert!(playing.is_different_track(&live));
795    }
796}