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}