Skip to main content

cranpose_services/
media.rs

1//! Media playback: one item at a time, observable rather than polled.
2//!
3//! A media player is not a sound-effect engine. [`audio`](crate::audio) mixes
4//! short decoded cues; this plays one long encoded item — a track, a podcast,
5//! a recording — through whatever the platform already uses for media, and
6//! answers the four questions every player screen asks:
7//!
8//! * **What is it doing?** [`PlaybackState`] is published, not polled. A screen
9//!   that asks "is it playing yet?" every frame does that work whether or not
10//!   anything changed, and learns about a failure only by noticing that the
11//!   position stopped moving.
12//! * **Where is it?** [`PlaybackProgress`] carries position, duration and how
13//!   much is buffered, published by the backend as it moves. A seek bar reads
14//!   [`playback_progress`] while it drags and collects
15//!   [`rememberPlaybackProgress`] otherwise.
16//! * **May it be heard?** Audio focus is a contract with the rest of the
17//!   device, and every application gets it wrong in the same way: it ducks and
18//!   forgets to un-duck, or it resumes after a phone call it never paused for.
19//!   The policy lives here — see [`publish_audio_focus`] — so a backend only
20//!   has to report what the platform told it.
21//! * **What does the lock screen say?** [`MediaMetadata`] goes to the platform
22//!   media session, and the buttons on it come back as [`MediaCommand`]s. The
23//!   transport commands are carried out here; the ones that need a playlist are
24//!   handed to the application, because the framework does not have one.
25//!
26//! Analysis samples are **optional and capability-gated**. A visualiser wants
27//! the samples that are being heard; not every platform media stack will give
28//! them up, so [`MediaCapabilities::analysis`] says whether this one does
29//! instead of publishing silence that looks like a bug. When it does, samples
30//! are latest-wins and bounded exactly like camera frames: a visualiser that
31//! falls behind draws the sound that is playing now and counts what it missed.
32
33use crate::background::{acquire_background_work, BackgroundWorkLease};
34use crate::host::LifecycleEvent;
35use crate::host::LifecycleState;
36use crate::registry::ServiceRegistry;
37use cranpose_core::{rememberEventStream, EventStream, State};
38use parking_lot::Mutex;
39use std::path::{Path, PathBuf};
40use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
41use std::sync::Arc;
42use std::time::Duration;
43
44/// The gain applied while another app is being heard over this one.
45///
46/// Ducking rather than pausing is what the platforms ask for on a transient
47/// interruption that can share the output — a navigation prompt over music.
48pub const DUCKED_GAIN: f32 = 0.2;
49
50/// Artwork for the platform media session.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct MediaArtwork {
53    /// The encoded image, in whatever the tag carried.
54    pub bytes: Arc<[u8]>,
55    /// The image's media type, `image/jpeg` and `image/png` being what tags
56    /// actually contain.
57    pub mime: String,
58}
59
60impl MediaArtwork {
61    /// Artwork from encoded bytes.
62    pub fn new(bytes: impl Into<Arc<[u8]>>, mime: impl Into<String>) -> MediaArtwork {
63        MediaArtwork {
64            bytes: bytes.into(),
65            mime: mime.into(),
66        }
67    }
68}
69
70/// What the platform media session shows: the lock screen, the notification,
71/// the car head unit.
72#[derive(Clone, Debug, Default, PartialEq, Eq)]
73pub struct MediaMetadata {
74    pub title: String,
75    pub artist: String,
76    pub album: String,
77    /// The item's length when it is known before playback starts — from a tag,
78    /// or from a previous play. `None` means "ask the backend once it has
79    /// opened the item", which is what [`PlaybackProgress::duration`] reports.
80    pub duration: Option<Duration>,
81    pub artwork: Option<MediaArtwork>,
82}
83
84impl MediaMetadata {
85    /// Metadata carrying only a title, which is what a bare file name gives.
86    pub fn titled(title: impl Into<String>) -> MediaMetadata {
87        MediaMetadata {
88            title: title.into(),
89            ..MediaMetadata::default()
90        }
91    }
92
93    /// Sets the performer.
94    pub fn artist(mut self, artist: impl Into<String>) -> MediaMetadata {
95        self.artist = artist.into();
96        self
97    }
98
99    /// Sets the album.
100    pub fn album(mut self, album: impl Into<String>) -> MediaMetadata {
101        self.album = album.into();
102        self
103    }
104
105    /// Sets the length known ahead of playback.
106    pub fn duration(mut self, duration: Duration) -> MediaMetadata {
107        self.duration = Some(duration);
108        self
109    }
110
111    /// Sets the artwork.
112    pub fn artwork(mut self, artwork: MediaArtwork) -> MediaMetadata {
113        self.artwork = Some(artwork);
114        self
115    }
116
117    /// Whether there is anything worth showing on a lock screen.
118    pub fn is_empty(&self) -> bool {
119        self.title.is_empty() && self.artist.is_empty() && self.album.is_empty()
120    }
121}
122
123/// One playable item.
124///
125/// The source is a URI because that is the one form every platform media stack
126/// takes: `file:` and `content:` on Android, `file:` on desktop and iOS,
127/// `blob:` for a file the browser handed over, `http:` and `https:` everywhere.
128/// Handing the platform a URI is also what keeps a streamed item streaming
129/// instead of being read into memory first.
130#[derive(Clone, Debug, Default, PartialEq, Eq)]
131pub struct MediaItem {
132    pub uri: String,
133    pub metadata: MediaMetadata,
134}
135
136impl MediaItem {
137    /// An item at `uri`, with no metadata yet.
138    pub fn new(uri: impl Into<String>) -> MediaItem {
139        MediaItem {
140            uri: uri.into(),
141            metadata: MediaMetadata::default(),
142        }
143    }
144
145    /// The same item with metadata attached.
146    pub fn with_metadata(mut self, metadata: MediaMetadata) -> MediaItem {
147        self.metadata = metadata;
148        self
149    }
150
151    /// The title, falling back to the last path segment of the URI so a screen
152    /// always has something to show.
153    pub fn display_title(&self) -> &str {
154        if !self.metadata.title.is_empty() {
155            return &self.metadata.title;
156        }
157        let path = self.uri.split(['?', '#']).next().unwrap_or(&self.uri);
158        match path.rsplit(['/', '\\']).next() {
159            Some(name) if !name.is_empty() => name,
160            _ => &self.uri,
161        }
162    }
163}
164
165/// What a platform media stack can actually do.
166///
167/// Reported rather than assumed: a screen greys out a speed control the device
168/// will not honour instead of offering one that silently does nothing.
169#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
170pub struct MediaCapabilities {
171    /// Whether [`seek_media`] moves the position.
172    pub seeking: bool,
173    /// Whether [`set_media_speed`] changes the rate.
174    pub speed: bool,
175    /// Whether [`set_media_looping`] repeats the item when it ends.
176    pub looping: bool,
177    /// Whether the backend can publish [`MediaSamples`] while it plays.
178    pub analysis: bool,
179    /// Whether metadata reaches a platform media session — the lock screen,
180    /// the notification, the headset buttons.
181    pub session: bool,
182    /// Whether the backend has an equalizer. The bands it has are reported by
183    /// [`media_equalizer_bands`], because a platform effect has the bands its
184    /// implementation has rather than the ones a screen would like.
185    pub equalizer: bool,
186    /// Whether [`probe_media_duration`] can read an item's length without
187    /// playing it. A playlist that shows durations for entries nobody has
188    /// opened needs this; one that does not, does not.
189    pub probing: bool,
190}
191
192impl MediaCapabilities {
193    /// A backend that plays, pauses and seeks and does nothing else, which is
194    /// the floor for anything worth calling a media player.
195    pub const TRANSPORT: MediaCapabilities = MediaCapabilities {
196        seeking: true,
197        speed: false,
198        looping: true,
199        analysis: false,
200        session: false,
201        equalizer: false,
202        probing: false,
203    };
204}
205
206/// One frequency band of a backend's equalizer.
207#[derive(Clone, Copy, Debug, PartialEq)]
208pub struct EqualizerBand {
209    /// The frequency the band is centred on, in hertz.
210    pub center_hz: f32,
211    /// The most this band can cut, in decibels — a negative number.
212    pub min_gain_db: f32,
213    /// The most this band can lift, in decibels.
214    pub max_gain_db: f32,
215}
216
217impl EqualizerBand {
218    /// A band centred on `center_hz` with a symmetric range.
219    pub fn new(center_hz: f32, range_db: f32) -> EqualizerBand {
220        let range = range_db.abs();
221        EqualizerBand {
222            center_hz,
223            min_gain_db: -range,
224            max_gain_db: range,
225        }
226    }
227
228    /// Brings `gain_db` inside what this band can actually do.
229    pub fn clamp_gain(&self, gain_db: f32) -> f32 {
230        gain_db.clamp(self.min_gain_db, self.max_gain_db)
231    }
232}
233
234/// The octave centres a graphic equalizer is built on, in hertz.
235///
236/// The set a hardware graphic equalizer has had since long before software
237/// ones. A backend that builds its own filters — the desktop one, the browser
238/// one — reports these, so the same curve means the same thing on both. A
239/// platform effect reports whatever bands its implementation has instead.
240pub const OCTAVE_BAND_CENTERS_HZ: [f32; 10] = [
241    31.0, 62.0, 125.0, 250.0, 500.0, 1_000.0, 2_000.0, 4_000.0, 8_000.0, 16_000.0,
242];
243
244/// [`OCTAVE_BAND_CENTERS_HZ`] as bands, each able to lift or cut by `range_db`.
245pub fn octave_equalizer_bands(range_db: f32) -> Vec<EqualizerBand> {
246    OCTAVE_BAND_CENTERS_HZ
247        .iter()
248        .map(|center| EqualizerBand::new(*center, range_db))
249        .collect()
250}
251
252/// What an equalizer is set to.
253///
254/// `gains_db` is read alongside the bands [`media_equalizer_bands`] reported:
255/// entry `n` is band `n`. A shorter list leaves the remaining bands flat, and a
256/// longer one is truncated, so a screen built for one band layout still says
257/// something sensible on a device with another.
258#[derive(Clone, Debug, Default, PartialEq)]
259pub struct EqualizerSettings {
260    /// Whether the equalizer is in circuit at all. A flat, disabled equalizer
261    /// is not the same as a flat, enabled one: the disabled one costs nothing.
262    pub enabled: bool,
263    /// Gain applied ahead of the bands, in decibels.
264    pub preamp_db: f32,
265    /// Per-band gain in decibels, in the order the bands were reported.
266    pub gains_db: Vec<f32>,
267}
268
269impl EqualizerSettings {
270    /// An enabled equalizer with every band flat.
271    pub fn flat(bands: usize) -> EqualizerSettings {
272        EqualizerSettings {
273            enabled: true,
274            preamp_db: 0.0,
275            gains_db: vec![0.0; bands],
276        }
277    }
278
279    /// This setting with every gain brought inside what `bands` can do, and
280    /// its length matched to theirs.
281    pub fn clamped_to(&self, bands: &[EqualizerBand]) -> EqualizerSettings {
282        EqualizerSettings {
283            enabled: self.enabled,
284            preamp_db: self.preamp_db,
285            gains_db: bands
286                .iter()
287                .enumerate()
288                .map(|(index, band)| {
289                    band.clamp_gain(self.gains_db.get(index).copied().unwrap_or(0.0))
290                })
291                .collect(),
292        }
293    }
294}
295
296#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
297pub enum MediaError {
298    /// No media backend on this platform.
299    #[error("media playback is not supported here")]
300    Unsupported,
301    /// The backend cannot open this URI — an unknown scheme, a codec it has
302    /// no decoder for, a file that is not there.
303    #[error("cannot play {0}")]
304    UnsupportedSource(String),
305    /// A transport call arrived before anything was opened.
306    #[error("no media item is loaded")]
307    NothingLoaded,
308    /// The backend has no seek for this item — a live stream, or a container
309    /// without an index.
310    #[error("this item cannot be seeked")]
311    NotSeekable,
312    /// Any other failure the platform reported.
313    #[error("{0}")]
314    Failed(String),
315}
316
317/// What the player is doing.
318#[derive(Clone, Debug, Default, PartialEq, Eq)]
319pub enum PlaybackState {
320    /// Nothing is open.
321    #[default]
322    Idle,
323    /// An item is opening or refilling its buffer. A separate state rather
324    /// than a gap, because opening a network item takes long enough that a
325    /// screen has to say so.
326    Loading,
327    /// Sound is coming out.
328    Playing,
329    /// An item is open and positioned, and stopped.
330    Paused,
331    /// The item played to its end. Distinct from [`Paused`](Self::Paused):
332    /// this is what advances a playlist.
333    Ended,
334    /// The item could not be played, or playback ended in a failure.
335    Failed(MediaError),
336}
337
338impl PlaybackState {
339    /// Whether sound is coming out now.
340    pub fn is_playing(&self) -> bool {
341        matches!(self, PlaybackState::Playing)
342    }
343
344    /// Whether an item is open — playing, paused, or still opening.
345    pub fn is_active(&self) -> bool {
346        matches!(
347            self,
348            PlaybackState::Loading | PlaybackState::Playing | PlaybackState::Paused
349        )
350    }
351
352    /// The failure, if playback ended in one.
353    pub fn failure(&self) -> Option<&MediaError> {
354        match self {
355            PlaybackState::Failed(error) => Some(error),
356            _ => None,
357        }
358    }
359}
360
361/// Where the open item is.
362#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
363pub struct PlaybackProgress {
364    /// How far in the item playback has reached.
365    pub position: Duration,
366    /// The item's length, or `None` for a stream that has none.
367    pub duration: Option<Duration>,
368    /// How far ahead of the position the buffer reaches. Equal to `duration`
369    /// for a local file, which is what makes a local file's buffer bar full.
370    pub buffered: Duration,
371}
372
373impl PlaybackProgress {
374    /// Progress through an item of known length.
375    pub fn new(position: Duration, duration: Duration) -> PlaybackProgress {
376        PlaybackProgress {
377            position: position.min(duration),
378            duration: Some(duration),
379            buffered: duration,
380        }
381    }
382
383    /// How far through the item this is, or `None` when it has no length.
384    pub fn fraction(&self) -> Option<f32> {
385        let duration = self.duration?;
386        if duration.is_zero() {
387            return Some(0.0);
388        }
389        Some((self.position.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
390    }
391
392    /// How much of the item is buffered, or `None` when it has no length.
393    pub fn buffered_fraction(&self) -> Option<f32> {
394        let duration = self.duration?;
395        if duration.is_zero() {
396            return Some(0.0);
397        }
398        Some((self.buffered.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
399    }
400}
401
402/// A button pressed somewhere the application does not draw: a lock screen, a
403/// notification, a headset, a car.
404#[derive(Clone, Copy, Debug, PartialEq, Eq)]
405pub enum MediaCommand {
406    Play,
407    Pause,
408    /// The one button a headset has.
409    TogglePlayPause,
410    Stop,
411    /// Needs a playlist, so it is reported and not carried out.
412    Next,
413    /// Needs a playlist, so it is reported and not carried out.
414    Previous,
415    SeekTo(Duration),
416}
417
418impl MediaCommand {
419    /// Whether this command is one the framework carries out itself.
420    ///
421    /// The transport is player state and lives here. [`Next`](Self::Next) and
422    /// [`Previous`](Self::Previous) need an order the framework does not have,
423    /// so they are only reported.
424    pub fn is_transport(self) -> bool {
425        !matches!(self, MediaCommand::Next | MediaCommand::Previous)
426    }
427}
428
429/// What the rest of the device is doing with the output.
430#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
431pub enum AudioFocus {
432    /// This app may be heard at its own volume.
433    #[default]
434    Gained,
435    /// Something short is being said over the top — a navigation prompt.
436    /// Playback continues at [`DUCKED_GAIN`].
437    Ducked,
438    /// Something else has the output for a moment — a call, another player.
439    /// Playback pauses and resumes on the next [`Gained`](Self::Gained).
440    LostTransient,
441    /// Something else has the output for good. Playback stops and does not
442    /// come back on its own.
443    Lost,
444}
445
446/// Samples as they are being heard, for a visualiser.
447#[derive(Clone, Debug, PartialEq)]
448pub struct MediaSamples {
449    /// Samples per second per channel.
450    pub sample_rate: u32,
451    /// How many channels are interleaved in [`samples`](Self::samples).
452    pub channels: u16,
453    /// Interleaved samples, nominally in `[-1, 1]`.
454    pub samples: Arc<[f32]>,
455    /// Which block this is, so a visualiser can tell a repeat from a new one
456    /// and count what it missed.
457    pub sequence: u64,
458}
459
460impl MediaSamples {
461    /// A block of samples, or `None` when the layout does not describe the
462    /// data — which reads as a broken visualiser rather than as an error if it
463    /// is let through.
464    pub fn new(
465        sample_rate: u32,
466        channels: u16,
467        sequence: u64,
468        samples: impl Into<Arc<[f32]>>,
469    ) -> Option<MediaSamples> {
470        let samples = samples.into();
471        if sample_rate == 0 || channels == 0 || samples.len() % channels as usize != 0 {
472            return None;
473        }
474        Some(MediaSamples {
475            sample_rate,
476            channels,
477            samples,
478            sequence,
479        })
480    }
481
482    /// How many samples there are per channel.
483    pub fn frames(&self) -> usize {
484        self.samples.len() / self.channels.max(1) as usize
485    }
486
487    /// How long this block lasts.
488    pub fn span(&self) -> Duration {
489        if self.sample_rate == 0 {
490            return Duration::ZERO;
491        }
492        Duration::from_secs_f64(self.frames() as f64 / self.sample_rate as f64)
493    }
494}
495
496/// A platform media stack.
497///
498/// A backend opens items, drives the transport, and publishes what happens
499/// through [`publish_playback_state`], [`publish_playback_progress`],
500/// [`publish_audio_focus`], [`publish_media_command`] and
501/// [`publish_media_samples`]. Nothing here is polled, and no method blocks for
502/// the length of an item.
503///
504/// Applications call the free functions — [`open_media`], [`play_media`],
505/// [`seek_media`] — rather than this trait: the free functions are where volume
506/// is combined with the focus gain, where the background-work lease is held,
507/// and where a seek is clamped to the item.
508pub trait MediaPlayer: Send + Sync {
509    /// What this backend can do. Read by screens to decide which controls
510    /// exist at all.
511    fn capabilities(&self) -> MediaCapabilities;
512
513    /// Opens `item` and gets it ready to play, without playing it.
514    ///
515    /// Returns as soon as the request is accepted; the item's progress arrives
516    /// as [`PlaybackState`], because opening a network item takes as long as
517    /// the network does.
518    fn prepare(&self, item: &MediaItem) -> Result<(), MediaError>;
519
520    /// Starts, or resumes, the open item.
521    fn play(&self) -> Result<(), MediaError>;
522
523    /// Stops without giving up the position.
524    fn pause(&self);
525
526    /// Stops, closes the item and releases the output device.
527    fn stop(&self);
528
529    /// Moves the position within the open item.
530    fn seek_to(&self, _position: Duration) -> Result<(), MediaError> {
531        Err(MediaError::NotSeekable)
532    }
533
534    /// Sets the output gain, already combined with the audio-focus gain by
535    /// [`set_media_volume`]. `0.0` is silent, `1.0` is the item as recorded.
536    fn set_volume(&self, volume: f32);
537
538    /// Sets the playback rate, `1.0` being as recorded. Returns `false` where
539    /// the backend does not have one.
540    fn set_speed(&self, _speed: f32) -> bool {
541        false
542    }
543
544    /// Repeats the open item when it reaches its end.
545    fn set_looping(&self, _looping: bool) {}
546
547    /// Starts or stops publishing [`MediaSamples`]. Returns `false` where the
548    /// backend cannot produce them, which is also what
549    /// [`MediaCapabilities::analysis`] reports.
550    fn set_analysis_enabled(&self, _enabled: bool) -> bool {
551        false
552    }
553
554    /// Hands metadata to the platform media session. Called again whenever the
555    /// application learns more about the open item, because tags are often
556    /// parsed after playback has already started.
557    fn set_session_metadata(&self, _metadata: &MediaMetadata) {}
558
559    /// The equalizer bands this backend has, centre frequency and range.
560    ///
561    /// Empty where there is no equalizer, which is also what
562    /// [`MediaCapabilities::equalizer`] reports. A backend states its real
563    /// bands: a platform effect has the ones its implementation has, and a
564    /// screen that wants a different layout maps onto these rather than being
565    /// told a layout that is not there.
566    fn equalizer_bands(&self) -> Vec<EqualizerBand> {
567        Vec::new()
568    }
569
570    /// Applies an equalizer setting, already clamped to this backend's bands.
571    fn set_equalizer(&self, _settings: &EqualizerSettings) {}
572
573    /// Reads how long `item` is without opening it for playback.
574    ///
575    /// A playlist shows the length of entries nobody has played yet, and the
576    /// only thing that can answer is the stack that reads the container.
577    /// `None` where this backend cannot tell, which is also what
578    /// [`MediaCapabilities::probing`] reports; a screen leaves the duration
579    /// blank rather than treating it as an error.
580    fn probe_duration(&self, _item: &MediaItem) -> Option<Duration> {
581        None
582    }
583}
584
585/// Shared handle to the platform media player.
586pub type MediaPlayerRef = Arc<dyn MediaPlayer>;
587
588static PLATFORM_MEDIA: ServiceRegistry<dyn MediaPlayer> = ServiceRegistry::new();
589
590/// Installs the platform media player, replacing any previous one.
591pub fn set_platform_media_player(player: MediaPlayerRef) {
592    PLATFORM_MEDIA.set(player);
593}
594
595/// Removes the platform media player and forgets everything it published.
596pub fn clear_platform_media_player() {
597    if let Some(player) = PLATFORM_MEDIA.get() {
598        player.stop();
599    }
600    PLATFORM_MEDIA.clear();
601    STATE_OBSERVERS.clear();
602    PROGRESS_OBSERVERS.clear();
603    COMMAND_OBSERVERS.clear();
604    FOCUS_OBSERVERS.clear();
605    SAMPLE_OBSERVERS.clear();
606    *STATE.lock() = PlaybackState::Idle;
607    *PROGRESS.lock() = PlaybackProgress::default();
608    *CURRENT_ITEM.lock() = None;
609    *LATEST_SAMPLES.lock() = None;
610    *FOCUS.lock() = AudioFocus::Gained;
611    *VOLUME.lock() = 1.0;
612    PAUSED_BY_FOCUS.store(false, Ordering::Release);
613    DROPPED_SAMPLES.store(0, Ordering::Release);
614    release_background_lease();
615}
616
617/// The installed media player, or `None` where this platform has none.
618pub fn media_player() -> Option<MediaPlayerRef> {
619    PLATFORM_MEDIA.get()
620}
621
622/// Whether this platform can play media at all.
623pub fn media_playback_supported() -> bool {
624    PLATFORM_MEDIA.get().is_some()
625}
626
627/// What the installed backend can do, or [`MediaCapabilities::default`] — every
628/// capability absent — when there is none.
629pub fn media_capabilities() -> MediaCapabilities {
630    media_player()
631        .map(|player| player.capabilities())
632        .unwrap_or_default()
633}
634
635// -- Published state ---------------------------------------------------------
636
637static STATE: Mutex<PlaybackState> = Mutex::new(PlaybackState::Idle);
638static PROGRESS: Mutex<PlaybackProgress> = Mutex::new(PlaybackProgress {
639    position: Duration::ZERO,
640    duration: None,
641    buffered: Duration::ZERO,
642});
643static CURRENT_ITEM: Mutex<Option<MediaItem>> = Mutex::new(None);
644static LATEST_SAMPLES: Mutex<Option<MediaSamples>> = Mutex::new(None);
645static FOCUS: Mutex<AudioFocus> = Mutex::new(AudioFocus::Gained);
646/// The volume the application asked for, before the focus gain is applied.
647static VOLUME: Mutex<f32> = Mutex::new(1.0);
648/// The equalizer curve the application asked for. Kept whether or not a
649/// platform can apply it, so a stored user setting survives a device that
650/// cannot honour it and reaches one that can.
651static EQUALIZER: Mutex<EqualizerSettings> = Mutex::new(EqualizerSettings {
652    enabled: false,
653    preamp_db: 0.0,
654    gains_db: Vec::new(),
655});
656static PAUSED_BY_FOCUS: AtomicBool = AtomicBool::new(false);
657
658/// Sample blocks produced while every observer was still busy with an earlier
659/// one. Counted rather than queued, for the same reason camera frames are: a
660/// visualiser that falls behind should draw the sound that is playing now.
661static DROPPED_SAMPLES: AtomicU64 = AtomicU64::new(0);
662
663/// What the player is doing.
664pub fn playback_state() -> PlaybackState {
665    STATE.lock().clone()
666}
667
668/// Where the open item is.
669///
670/// Read outside composition — while a seek bar is being dragged, or during
671/// draw — so a moving position costs no recomposition.
672pub fn playback_progress() -> PlaybackProgress {
673    *PROGRESS.lock()
674}
675
676/// The open item, or `None` when nothing is.
677pub fn current_media_item() -> Option<MediaItem> {
678    CURRENT_ITEM.lock().clone()
679}
680
681/// The last block of samples, or `None` when analysis is off or nothing has
682/// played yet. Read during draw, so a visualiser never draws a stale block.
683pub fn latest_media_samples() -> Option<MediaSamples> {
684    LATEST_SAMPLES.lock().clone()
685}
686
687/// How many sample blocks were produced while every observer was still busy.
688pub fn dropped_media_samples() -> u64 {
689    DROPPED_SAMPLES.load(Ordering::Acquire)
690}
691
692/// What the rest of the device is doing with the output.
693pub fn audio_focus() -> AudioFocus {
694    *FOCUS.lock()
695}
696
697/// The volume the application asked for, before the audio-focus gain.
698pub fn media_volume() -> f32 {
699    *VOLUME.lock()
700}
701
702// -- Observers ---------------------------------------------------------------
703
704/// One registry of callbacks. Every published signal has the same shape, so it
705/// is written once and instantiated per signal rather than copied per signal.
706struct ObserverList<T: ?Sized> {
707    entries: Mutex<Vec<(u64, Arc<T>)>>,
708}
709
710impl<T: ?Sized> ObserverList<T> {
711    const fn new() -> ObserverList<T> {
712        ObserverList {
713            entries: Mutex::new(Vec::new()),
714        }
715    }
716
717    fn add(&self, observer: Arc<T>) -> u64 {
718        let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed);
719        self.entries.lock().push((id, observer));
720        id
721    }
722
723    fn remove(&self, id: u64) {
724        self.entries.lock().retain(|(entry, _)| *entry != id);
725    }
726
727    fn snapshot(&self) -> Vec<Arc<T>> {
728        self.entries
729            .lock()
730            .iter()
731            .map(|(_, observer)| Arc::clone(observer))
732            .collect()
733    }
734
735    fn clear(&self) {
736        self.entries.lock().clear();
737    }
738}
739
740static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1);
741
742type StateObserverFn = dyn Fn(PlaybackState) + Send + Sync;
743type ProgressObserverFn = dyn Fn(PlaybackProgress) + Send + Sync;
744type CommandObserverFn = dyn Fn(MediaCommand) + Send + Sync;
745type FocusObserverFn = dyn Fn(AudioFocus) + Send + Sync;
746type SampleObserverFn = dyn Fn(MediaSamples) + Send + Sync;
747
748static STATE_OBSERVERS: ObserverList<StateObserverFn> = ObserverList::new();
749static PROGRESS_OBSERVERS: ObserverList<ProgressObserverFn> = ObserverList::new();
750static COMMAND_OBSERVERS: ObserverList<CommandObserverFn> = ObserverList::new();
751static FOCUS_OBSERVERS: ObserverList<FocusObserverFn> = ObserverList::new();
752static SAMPLE_OBSERVERS: ObserverList<SampleObserverFn> = ObserverList::new();
753
754/// Keeps a media observer registered until it is dropped.
755pub struct MediaObserver {
756    id: u64,
757    remove: fn(u64),
758}
759
760impl Drop for MediaObserver {
761    fn drop(&mut self) {
762        (self.remove)(self.id);
763    }
764}
765
766/// Registers `observer` for playback state. The current state is delivered at
767/// once, so a screen composed mid-item shows what is happening rather than
768/// waiting for the next change.
769pub fn observe_playback_state(
770    observer: impl Fn(PlaybackState) + Send + Sync + 'static,
771) -> MediaObserver {
772    let observer: Arc<StateObserverFn> = Arc::new(observer);
773    let id = STATE_OBSERVERS.add(Arc::clone(&observer));
774    observer(playback_state());
775    MediaObserver {
776        id,
777        remove: |id| STATE_OBSERVERS.remove(id),
778    }
779}
780
781/// Registers `observer` for position updates. The current position is
782/// delivered at once.
783pub fn observe_playback_progress(
784    observer: impl Fn(PlaybackProgress) + Send + Sync + 'static,
785) -> MediaObserver {
786    let observer: Arc<ProgressObserverFn> = Arc::new(observer);
787    let id = PROGRESS_OBSERVERS.add(Arc::clone(&observer));
788    observer(playback_progress());
789    MediaObserver {
790        id,
791        remove: |id| PROGRESS_OBSERVERS.remove(id),
792    }
793}
794
795/// Registers `observer` for media-session commands.
796pub fn observe_media_commands(
797    observer: impl Fn(MediaCommand) + Send + Sync + 'static,
798) -> MediaObserver {
799    let id = COMMAND_OBSERVERS.add(Arc::new(observer));
800    MediaObserver {
801        id,
802        remove: |id| COMMAND_OBSERVERS.remove(id),
803    }
804}
805
806/// Registers `observer` for audio-focus changes. The current focus is
807/// delivered at once.
808pub fn observe_audio_focus(observer: impl Fn(AudioFocus) + Send + Sync + 'static) -> MediaObserver {
809    let observer: Arc<FocusObserverFn> = Arc::new(observer);
810    let id = FOCUS_OBSERVERS.add(Arc::clone(&observer));
811    observer(audio_focus());
812    MediaObserver {
813        id,
814        remove: |id| FOCUS_OBSERVERS.remove(id),
815    }
816}
817
818/// Registers `observer` for analysis samples.
819pub fn observe_media_samples(
820    observer: impl Fn(MediaSamples) + Send + Sync + 'static,
821) -> MediaObserver {
822    let id = SAMPLE_OBSERVERS.add(Arc::new(observer));
823    MediaObserver {
824        id,
825        remove: |id| SAMPLE_OBSERVERS.remove(id),
826    }
827}
828
829// -- Publishing (called by backends) -----------------------------------------
830
831/// Publishes what the player is doing.
832///
833/// This is also where the background-work lease is taken and given up: an app
834/// that is playing has work the runtime must keep turning for even with its
835/// surface gone, and an app that has stopped does not.
836pub fn publish_playback_state(state: PlaybackState) {
837    {
838        let mut current = STATE.lock();
839        if *current == state {
840            return;
841        }
842        *current = state.clone();
843    }
844    if state.is_playing() {
845        acquire_background_lease();
846    } else {
847        release_background_lease();
848    }
849    if !state.is_active() {
850        *PROGRESS.lock() = PlaybackProgress::default();
851        *LATEST_SAMPLES.lock() = None;
852    }
853    if matches!(state, PlaybackState::Idle) {
854        *CURRENT_ITEM.lock() = None;
855        DROPPED_SAMPLES.store(0, Ordering::Release);
856    }
857    for observer in STATE_OBSERVERS.snapshot() {
858        observer(state.clone());
859    }
860}
861
862/// Publishes where the open item is. Backends call this as the position moves,
863/// which for a local file is a handful of times a second.
864pub fn publish_playback_progress(progress: PlaybackProgress) {
865    let progress = clamp_progress(progress);
866    {
867        let mut current = PROGRESS.lock();
868        if *current == progress {
869            return;
870        }
871        *current = progress;
872    }
873    for observer in PROGRESS_OBSERVERS.snapshot() {
874        observer(progress);
875    }
876}
877
878fn clamp_progress(mut progress: PlaybackProgress) -> PlaybackProgress {
879    if let Some(duration) = progress.duration {
880        progress.position = progress.position.min(duration);
881        progress.buffered = progress.buffered.min(duration);
882    }
883    progress
884}
885
886/// Publishes a button pressed outside the application's own UI.
887///
888/// The transport commands are carried out here before observers are told, so an
889/// application that only wants to advance its playlist has nothing to wire up:
890/// it collects [`rememberMediaCommands`] and reacts to
891/// [`MediaCommand::Next`] and [`MediaCommand::Previous`].
892pub fn publish_media_command(command: MediaCommand) {
893    match command {
894        MediaCommand::Play => {
895            let _ = play_media();
896        }
897        MediaCommand::Pause => pause_media(),
898        MediaCommand::TogglePlayPause => toggle_media(),
899        MediaCommand::Stop => stop_media(),
900        MediaCommand::SeekTo(position) => {
901            let _ = seek_media(position);
902        }
903        MediaCommand::Next | MediaCommand::Previous => {}
904    }
905    for observer in COMMAND_OBSERVERS.snapshot() {
906        observer(command);
907    }
908}
909
910/// Publishes what the rest of the device is doing with the output, and applies
911/// the policy that goes with it.
912///
913/// The policy is the whole point of this living in the framework:
914///
915/// * [`Ducked`](AudioFocus::Ducked) lowers the gain to [`DUCKED_GAIN`] and
916///   keeps playing; regaining focus puts the application's own volume back,
917///   whatever it changed to in the meantime.
918/// * [`LostTransient`](AudioFocus::LostTransient) pauses **and remembers that
919///   it did**, so the next [`Gained`](AudioFocus::Gained) resumes — and a
920///   [`Gained`](AudioFocus::Gained) that follows a user's own pause does not.
921/// * [`Lost`](AudioFocus::Lost) stops and forgets, because focus lost for good
922///   does not come back.
923pub fn publish_audio_focus(focus: AudioFocus) {
924    {
925        let mut current = FOCUS.lock();
926        if *current == focus {
927            return;
928        }
929        *current = focus;
930    }
931    apply_volume();
932    match focus {
933        AudioFocus::Gained => {
934            if PAUSED_BY_FOCUS.swap(false, Ordering::AcqRel) {
935                let _ = play_media();
936            }
937        }
938        AudioFocus::Ducked => {}
939        AudioFocus::LostTransient => {
940            if playback_state().is_playing() {
941                PAUSED_BY_FOCUS.store(true, Ordering::Release);
942                pause_media();
943            }
944        }
945        AudioFocus::Lost => {
946            PAUSED_BY_FOCUS.store(false, Ordering::Release);
947            stop_media();
948        }
949    }
950    for observer in FOCUS_OBSERVERS.snapshot() {
951        observer(focus);
952    }
953}
954
955/// Publishes a block of samples as it is heard.
956///
957/// The newest block always replaces the stored one, so a visualiser drawing
958/// [`latest_media_samples`] never draws a stale one; observers that keep up see
959/// every block, and blocks nobody could take are counted in
960/// [`dropped_media_samples`] rather than queued behind.
961pub fn publish_media_samples(samples: MediaSamples) {
962    *LATEST_SAMPLES.lock() = Some(samples.clone());
963    let observers = SAMPLE_OBSERVERS.snapshot();
964    if observers.is_empty() {
965        return;
966    }
967    for observer in observers {
968        observer(samples.clone());
969    }
970}
971
972/// Records that the backend produced a block nobody could take.
973pub fn record_dropped_media_samples() {
974    DROPPED_SAMPLES.fetch_add(1, Ordering::AcqRel);
975}
976
977// -- Transport (called by applications) --------------------------------------
978
979/// Opens `item`, publishing [`PlaybackState::Loading`] before the backend is
980/// asked so a screen shows the wait rather than a gap.
981///
982/// The item is not played: an application that wants it to start calls
983/// [`play_media`] when the backend publishes [`PlaybackState::Paused`], or
984/// simply calls it straight away — a backend queues the request against the
985/// item it is opening.
986pub fn open_media(item: MediaItem) -> Result<(), MediaError> {
987    let Some(player) = media_player() else {
988        publish_playback_state(PlaybackState::Failed(MediaError::Unsupported));
989        return Err(MediaError::Unsupported);
990    };
991    PAUSED_BY_FOCUS.store(false, Ordering::Release);
992    DROPPED_SAMPLES.store(0, Ordering::Release);
993    *CURRENT_ITEM.lock() = Some(item.clone());
994    publish_playback_progress(PlaybackProgress {
995        position: Duration::ZERO,
996        duration: item.metadata.duration,
997        buffered: Duration::ZERO,
998    });
999    publish_playback_state(PlaybackState::Loading);
1000    if player.capabilities().session {
1001        player.set_session_metadata(&item.metadata);
1002    }
1003    player.prepare(&item).inspect_err(|error| {
1004        publish_playback_state(PlaybackState::Failed(error.clone()));
1005    })
1006}
1007
1008/// Starts, or resumes, the open item.
1009pub fn play_media() -> Result<(), MediaError> {
1010    let Some(player) = media_player() else {
1011        return Err(MediaError::Unsupported);
1012    };
1013    if CURRENT_ITEM.lock().is_none() {
1014        return Err(MediaError::NothingLoaded);
1015    }
1016    player.play().inspect_err(|error| {
1017        publish_playback_state(PlaybackState::Failed(error.clone()));
1018    })
1019}
1020
1021/// Stops without giving up the position.
1022pub fn pause_media() {
1023    if let Some(player) = media_player() {
1024        player.pause();
1025    }
1026}
1027
1028/// Stops, closes the item and releases the output device.
1029pub fn stop_media() {
1030    PAUSED_BY_FOCUS.store(false, Ordering::Release);
1031    if let Some(player) = media_player() {
1032        player.stop();
1033    }
1034    publish_playback_state(PlaybackState::Idle);
1035}
1036
1037/// Pauses what is playing and plays what is paused — the one button a headset
1038/// has, and the space bar.
1039pub fn toggle_media() {
1040    if playback_state().is_playing() {
1041        pause_media();
1042    } else {
1043        let _ = play_media();
1044    }
1045}
1046
1047/// Moves the position within the open item.
1048///
1049/// Clamped to the item's length here rather than in every backend, because a
1050/// seek past the end means different things to different platform stacks and
1051/// none of them mean what the seek bar meant.
1052pub fn seek_media(position: Duration) -> Result<(), MediaError> {
1053    let Some(player) = media_player() else {
1054        return Err(MediaError::Unsupported);
1055    };
1056    if CURRENT_ITEM.lock().is_none() {
1057        return Err(MediaError::NothingLoaded);
1058    }
1059    if !player.capabilities().seeking {
1060        return Err(MediaError::NotSeekable);
1061    }
1062    let position = match playback_progress().duration {
1063        Some(duration) => position.min(duration),
1064        None => position,
1065    };
1066    player.seek_to(position)
1067}
1068
1069/// Moves the position to a fraction of the item, which is what a seek bar has.
1070///
1071/// Reports [`MediaError::NotSeekable`] for an item with no length, because a
1072/// fraction of an unknown length is not a position.
1073pub fn seek_media_fraction(fraction: f32) -> Result<(), MediaError> {
1074    let Some(duration) = playback_progress().duration else {
1075        return Err(MediaError::NotSeekable);
1076    };
1077    let fraction = fraction.clamp(0.0, 1.0) as f64;
1078    seek_media(Duration::from_secs_f64(duration.as_secs_f64() * fraction))
1079}
1080
1081/// Sets the volume the application asks for, `1.0` being the item as recorded.
1082///
1083/// What reaches the device is this combined with the audio-focus gain, so an
1084/// application may set its volume freely while another app is being heard over
1085/// the top without undoing the duck.
1086pub fn set_media_volume(volume: f32) {
1087    *VOLUME.lock() = volume.clamp(0.0, 1.0);
1088    apply_volume();
1089}
1090
1091fn apply_volume() {
1092    let Some(player) = media_player() else {
1093        return;
1094    };
1095    let gain = match audio_focus() {
1096        AudioFocus::Ducked => DUCKED_GAIN,
1097        _ => 1.0,
1098    };
1099    player.set_volume(media_volume() * gain);
1100}
1101
1102/// Sets the playback rate, `1.0` being as recorded. Returns `false` where the
1103/// backend has none — see [`MediaCapabilities::speed`].
1104pub fn set_media_speed(speed: f32) -> bool {
1105    match media_player() {
1106        Some(player) if player.capabilities().speed => player.set_speed(speed),
1107        _ => false,
1108    }
1109}
1110
1111/// Repeats the open item when it reaches its end.
1112pub fn set_media_looping(looping: bool) {
1113    if let Some(player) = media_player() {
1114        player.set_looping(looping);
1115    }
1116}
1117
1118/// Starts or stops publishing [`MediaSamples`]. Returns `false` where the
1119/// backend cannot produce them — see [`MediaCapabilities::analysis`].
1120///
1121/// Off by default: producing samples costs the platform work on every block,
1122/// and a screen with no visualiser on it should not pay for one.
1123pub fn set_media_analysis_enabled(enabled: bool) -> bool {
1124    match media_player() {
1125        Some(player) if player.capabilities().analysis => {
1126            if !enabled {
1127                *LATEST_SAMPLES.lock() = None;
1128            }
1129            player.set_analysis_enabled(enabled)
1130        }
1131        _ => false,
1132    }
1133}
1134
1135/// Reads how long `item` is without playing it.
1136///
1137/// `None` where no backend is installed or the installed one cannot tell —
1138/// see [`MediaCapabilities::probing`].
1139pub fn probe_media_duration(item: &MediaItem) -> Option<Duration> {
1140    media_player()?.probe_duration(item)
1141}
1142
1143/// The equalizer bands this platform has, in the order gains are given in.
1144///
1145/// Empty where there is no equalizer. A screen reads this to know how many
1146/// controls to draw and what to label them, rather than assuming a layout.
1147pub fn media_equalizer_bands() -> Vec<EqualizerBand> {
1148    match media_player() {
1149        Some(player) if player.capabilities().equalizer => player.equalizer_bands(),
1150        _ => Vec::new(),
1151    }
1152}
1153
1154/// The equalizer setting last applied.
1155pub fn media_equalizer() -> EqualizerSettings {
1156    EQUALIZER.lock().clone()
1157}
1158
1159/// Applies an equalizer setting, clamped to what the platform's bands can do.
1160///
1161/// Returns `false` where there is no equalizer — see
1162/// [`MediaCapabilities::equalizer`]. The setting is remembered either way, so a
1163/// screen that stores a user's curve reads back what the user chose rather than
1164/// what a device happened to support.
1165pub fn set_media_equalizer(settings: EqualizerSettings) -> bool {
1166    *EQUALIZER.lock() = settings.clone();
1167    let Some(player) = media_player() else {
1168        return false;
1169    };
1170    if !player.capabilities().equalizer {
1171        return false;
1172    }
1173    player.set_equalizer(&settings.clamped_to(&player.equalizer_bands()));
1174    true
1175}
1176
1177/// Updates the metadata shown by the platform media session for the open item.
1178///
1179/// Called when tags finish parsing, which is usually after playback started.
1180pub fn set_media_metadata(metadata: MediaMetadata) {
1181    {
1182        let mut item = CURRENT_ITEM.lock();
1183        let Some(item) = item.as_mut() else {
1184            return;
1185        };
1186        item.metadata = metadata.clone();
1187    }
1188    if let Some(player) = media_player() {
1189        if player.capabilities().session {
1190            player.set_session_metadata(&metadata);
1191        }
1192    }
1193}
1194
1195// -- Lifecycle ---------------------------------------------------------------
1196
1197static BACKGROUND_LEASE: Mutex<Option<BackgroundWorkLease>> = Mutex::new(None);
1198
1199fn acquire_background_lease() {
1200    let mut lease = BACKGROUND_LEASE.lock();
1201    if lease.is_none() {
1202        *lease = Some(acquire_background_work());
1203    }
1204}
1205
1206fn release_background_lease() {
1207    BACKGROUND_LEASE.lock().take();
1208}
1209
1210/// Whether playback is what is keeping the runtime turning.
1211///
1212/// The lease count is one number for the whole process — a durable save holds
1213/// leases too — so this asks about the one this service took rather than about
1214/// the total.
1215#[cfg(test)]
1216fn holds_background_work() -> bool {
1217    BACKGROUND_LEASE.lock().is_some()
1218}
1219
1220/// Applies a host lifecycle transition to playback.
1221///
1222/// Backgrounding does **not** stop a media player: that is the difference
1223/// between a media player and every other service, and it is why playback holds
1224/// a background-work lease while it runs. A host being destroyed does stop it,
1225/// because the device it holds outlives the surface that was drawing.
1226pub(crate) fn on_lifecycle(event: LifecycleEvent) {
1227    if event.to == LifecycleState::Destroyed {
1228        stop_media();
1229    }
1230}
1231
1232// -- Local-file URIs ---------------------------------------------------------
1233
1234/// The `file:` URI for a path, which is what [`MediaItem`] takes.
1235///
1236/// Percent-encodes everything a URI reserves, so a track called `Sgt. Pepper's
1237/// #1.mp3` survives the trip. Lives here rather than in a backend because
1238/// every backend that reads local files needs the same answer, and an
1239/// application building an item needs it too.
1240pub fn uri_for_path(path: &Path) -> String {
1241    let text = path.to_string_lossy();
1242    let mut uri = String::with_capacity(text.len() + 8);
1243    uri.push_str("file://");
1244    if !text.starts_with('/') {
1245        // A Windows path (`C:\Music\x.mp3`) has no leading slash of its own,
1246        // and `file://C:/...` would read `C:` as a host.
1247        uri.push('/');
1248    }
1249    for byte in text.bytes() {
1250        match byte {
1251            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1252                uri.push(byte as char);
1253            }
1254            b'\\' => uri.push('/'),
1255            _ => uri.push_str(&format!("%{byte:02X}")),
1256        }
1257    }
1258    uri
1259}
1260
1261/// The path a media URI addresses, or `None` when it addresses something that
1262/// is not a local file — a stream, a content provider, a browser blob.
1263///
1264/// A bare path is accepted as itself: an application that already has a
1265/// `PathBuf` should not have to build a URI to hand it back.
1266pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
1267    let rest = match uri.split_once("://") {
1268        Some(("file", rest)) => rest,
1269        Some(_) => return None,
1270        None => return non_empty_path(uri),
1271    };
1272    // `file:///path` has an empty authority; `file://host/path` names a host
1273    // no local backend can read.
1274    let path = rest.strip_prefix('/')?;
1275    let decoded = crate::content::percent_decode(path)?;
1276    if decoded.starts_with('/') || decoded.is_empty() {
1277        return non_empty_path(&decoded);
1278    }
1279    // A Windows path came through as `C:/Music/x.mp3`; anything else that has
1280    // lost its leading slash is put back where it was.
1281    if decoded.as_bytes().get(1) == Some(&b':') {
1282        non_empty_path(&decoded)
1283    } else {
1284        non_empty_path(&format!("/{decoded}"))
1285    }
1286}
1287
1288fn non_empty_path(text: &str) -> Option<PathBuf> {
1289    if text.is_empty() {
1290        return None;
1291    }
1292    Some(PathBuf::from(text))
1293}
1294
1295// -- Composables -------------------------------------------------------------
1296
1297/// What the player is doing, observed for as long as this call stays in the
1298/// composition.
1299#[allow(non_snake_case)]
1300pub fn rememberPlaybackState() -> State<PlaybackState> {
1301    let updates = rememberEventStream((), |sender| {
1302        observe_playback_state(move |state| sender.send(state))
1303    });
1304    cranpose_core::collectAsState(updates, (), playback_state())
1305}
1306
1307/// Where the open item is, observed for as long as this call stays in the
1308/// composition.
1309///
1310/// This recomposes as the position moves, which is what a seek bar and a time
1311/// label want. A visualiser or a waveform that redraws every frame anyway reads
1312/// [`playback_progress`] during draw instead.
1313#[allow(non_snake_case)]
1314pub fn rememberPlaybackProgress() -> State<PlaybackProgress> {
1315    let updates = rememberEventStream((), |sender| {
1316        observe_playback_progress(move |progress| sender.send(progress))
1317    });
1318    cranpose_core::collectAsState(updates, (), playback_progress())
1319}
1320
1321/// What the rest of the device is doing with the output, observed for as long
1322/// as this call stays in the composition.
1323#[allow(non_snake_case)]
1324pub fn rememberAudioFocus() -> State<AudioFocus> {
1325    let updates = rememberEventStream((), |sender| {
1326        observe_audio_focus(move |focus| sender.send(focus))
1327    });
1328    cranpose_core::collectAsState(updates, (), audio_focus())
1329}
1330
1331/// Buttons pressed outside the application's own UI, as a stream this
1332/// composition collects.
1333///
1334/// The transport commands have already been carried out by the time they arrive
1335/// here; what an application acts on is [`MediaCommand::Next`] and
1336/// [`MediaCommand::Previous`], which need the playlist it owns.
1337#[allow(non_snake_case)]
1338pub fn rememberMediaCommands() -> EventStream<MediaCommand> {
1339    rememberEventStream((), |sender| {
1340        observe_media_commands(move |command| sender.send(command))
1341    })
1342}
1343
1344/// Samples as they are heard, as a stream this composition collects.
1345///
1346/// Enable them with [`set_media_analysis_enabled`] first; a backend that cannot
1347/// produce them says so through [`MediaCapabilities::analysis`].
1348#[allow(non_snake_case)]
1349pub fn rememberMediaSamples() -> EventStream<MediaSamples> {
1350    rememberEventStream((), |sender| {
1351        observe_media_samples(move |samples| sender.send(samples))
1352    })
1353}
1354
1355#[cfg(test)]
1356mod tests {
1357    use super::*;
1358    use crate::registry::test_service_guard;
1359
1360    /// A backend that records what it was asked and publishes what a real one
1361    /// would.
1362    struct FakePlayer {
1363        capabilities: MediaCapabilities,
1364        calls: Mutex<Vec<String>>,
1365        volume: Mutex<f32>,
1366        prepare_fails: bool,
1367    }
1368
1369    impl FakePlayer {
1370        fn new() -> Arc<FakePlayer> {
1371            Arc::new(FakePlayer {
1372                capabilities: MediaCapabilities {
1373                    seeking: true,
1374                    speed: true,
1375                    looping: true,
1376                    analysis: true,
1377                    session: true,
1378                    equalizer: true,
1379                    probing: true,
1380                },
1381                calls: Mutex::new(Vec::new()),
1382                volume: Mutex::new(1.0),
1383                prepare_fails: false,
1384            })
1385        }
1386
1387        fn with(capabilities: MediaCapabilities) -> Arc<FakePlayer> {
1388            Arc::new(FakePlayer {
1389                capabilities,
1390                calls: Mutex::new(Vec::new()),
1391                volume: Mutex::new(1.0),
1392                prepare_fails: false,
1393            })
1394        }
1395
1396        fn failing() -> Arc<FakePlayer> {
1397            Arc::new(FakePlayer {
1398                capabilities: MediaCapabilities::TRANSPORT,
1399                calls: Mutex::new(Vec::new()),
1400                volume: Mutex::new(1.0),
1401                prepare_fails: true,
1402            })
1403        }
1404
1405        fn note(&self, call: impl Into<String>) {
1406            self.calls.lock().push(call.into());
1407        }
1408
1409        fn calls(&self) -> Vec<String> {
1410            self.calls.lock().clone()
1411        }
1412    }
1413
1414    impl MediaPlayer for FakePlayer {
1415        fn capabilities(&self) -> MediaCapabilities {
1416            self.capabilities
1417        }
1418
1419        fn prepare(&self, item: &MediaItem) -> Result<(), MediaError> {
1420            self.note(format!("prepare {}", item.uri));
1421            if self.prepare_fails {
1422                return Err(MediaError::UnsupportedSource(item.uri.clone()));
1423            }
1424            publish_playback_state(PlaybackState::Paused);
1425            Ok(())
1426        }
1427
1428        fn play(&self) -> Result<(), MediaError> {
1429            self.note("play");
1430            publish_playback_state(PlaybackState::Playing);
1431            Ok(())
1432        }
1433
1434        fn pause(&self) {
1435            self.note("pause");
1436            publish_playback_state(PlaybackState::Paused);
1437        }
1438
1439        fn stop(&self) {
1440            self.note("stop");
1441        }
1442
1443        fn seek_to(&self, position: Duration) -> Result<(), MediaError> {
1444            self.note(format!("seek {}", position.as_millis()));
1445            Ok(())
1446        }
1447
1448        fn set_volume(&self, volume: f32) {
1449            *self.volume.lock() = volume;
1450        }
1451
1452        fn set_speed(&self, speed: f32) -> bool {
1453            self.note(format!("speed {speed}"));
1454            true
1455        }
1456
1457        fn set_looping(&self, looping: bool) {
1458            self.note(format!("looping {looping}"));
1459        }
1460
1461        fn set_analysis_enabled(&self, enabled: bool) -> bool {
1462            self.note(format!("analysis {enabled}"));
1463            true
1464        }
1465
1466        fn set_session_metadata(&self, metadata: &MediaMetadata) {
1467            self.note(format!("session {}", metadata.title));
1468        }
1469
1470        fn equalizer_bands(&self) -> Vec<EqualizerBand> {
1471            // Three bands with a narrow range, so a test can tell a clamp from
1472            // a pass-through and a band count from a hard-coded ten.
1473            vec![
1474                EqualizerBand::new(60.0, 6.0),
1475                EqualizerBand::new(1_000.0, 6.0),
1476                EqualizerBand::new(10_000.0, 6.0),
1477            ]
1478        }
1479
1480        fn set_equalizer(&self, settings: &EqualizerSettings) {
1481            self.note(format!(
1482                "equalizer {} preamp {} gains {:?}",
1483                settings.enabled, settings.preamp_db, settings.gains_db
1484            ));
1485        }
1486    }
1487
1488    fn install() -> (crate::registry::TestServiceGuard, Arc<FakePlayer>) {
1489        let guard = test_service_guard();
1490        clear_platform_media_player();
1491        let player = FakePlayer::new();
1492        set_platform_media_player(player.clone());
1493        (guard, player)
1494    }
1495
1496    fn track() -> MediaItem {
1497        MediaItem::new("file:///music/track.flac").with_metadata(
1498            MediaMetadata::titled("Track")
1499                .artist("Artist")
1500                .duration(Duration::from_secs(200)),
1501        )
1502    }
1503
1504    #[test]
1505    fn metadata_carries_everything_a_lock_screen_shows() {
1506        let artwork = MediaArtwork {
1507            bytes: vec![1, 2, 3].into(),
1508            mime: "image/png".to_string(),
1509        };
1510        let metadata = MediaMetadata::titled("Song")
1511            .artist("Band")
1512            .album("Record")
1513            .duration(Duration::from_secs(210))
1514            .artwork(artwork.clone());
1515
1516        assert_eq!(metadata.album, "Record");
1517        assert_eq!(metadata.artwork.as_ref(), Some(&artwork));
1518        assert!(!metadata.is_empty());
1519        // An album on its own is still something to show.
1520        assert!(!MediaMetadata::default().album("Record").is_empty());
1521        assert!(MediaMetadata::default().is_empty());
1522    }
1523
1524    #[test]
1525    fn a_band_reports_what_it_can_actually_do() {
1526        let band = EqualizerBand::new(1_000.0, 6.0);
1527        assert_eq!(band.clamp_gain(0.0), 0.0);
1528        assert_eq!(band.clamp_gain(6.0), 6.0);
1529        assert_eq!(band.clamp_gain(7.5), 6.0);
1530        assert_eq!(band.clamp_gain(-7.5), -6.0);
1531    }
1532
1533    #[test]
1534    fn an_equalizer_setting_is_clamped_to_the_bands_the_backend_has() {
1535        let bands = vec![
1536            EqualizerBand::new(60.0, 6.0),
1537            EqualizerBand::new(1_000.0, 6.0),
1538        ];
1539        let asked = EqualizerSettings {
1540            enabled: true,
1541            preamp_db: -3.0,
1542            // Too loud for these bands, and one entry too many.
1543            gains_db: vec![12.0, -12.0, 4.0],
1544        };
1545        let applied = asked.clamped_to(&bands);
1546        assert_eq!(applied.gains_db, vec![6.0, -6.0]);
1547        assert_eq!(applied.preamp_db, -3.0);
1548        assert!(applied.enabled);
1549    }
1550
1551    #[test]
1552    fn a_setting_shorter_than_the_bands_leaves_the_rest_flat() {
1553        let bands = octave_equalizer_bands(12.0);
1554        let applied = EqualizerSettings {
1555            enabled: true,
1556            preamp_db: 0.0,
1557            gains_db: vec![3.0],
1558        }
1559        .clamped_to(&bands);
1560        assert_eq!(applied.gains_db.len(), bands.len());
1561        assert_eq!(applied.gains_db[0], 3.0);
1562        assert!(applied.gains_db[1..].iter().all(|gain| *gain == 0.0));
1563    }
1564
1565    #[test]
1566    fn a_curve_reaches_the_backend_clamped_to_its_own_bands() {
1567        let (_guard, player) = install();
1568
1569        assert_eq!(media_equalizer_bands().len(), 3);
1570        assert!(set_media_equalizer(EqualizerSettings {
1571            enabled: true,
1572            preamp_db: -2.0,
1573            gains_db: vec![9.0, 0.0, -9.0],
1574        }));
1575
1576        assert!(
1577            player
1578                .calls()
1579                .iter()
1580                .any(|call| call == "equalizer true preamp -2 gains [6.0, 0.0, -6.0]"),
1581            "the backend was not given the clamped curve: {:?}",
1582            player.calls()
1583        );
1584    }
1585
1586    #[test]
1587    fn a_curve_is_remembered_even_where_nothing_can_apply_it() {
1588        let _guard = test_service_guard();
1589        clear_platform_media_player();
1590
1591        let asked = EqualizerSettings {
1592            enabled: true,
1593            preamp_db: -1.0,
1594            gains_db: vec![4.0, -4.0],
1595        };
1596        // No backend: the user's curve is still theirs, and reaches the next
1597        // device that can honour it.
1598        assert!(!set_media_equalizer(asked.clone()));
1599        assert_eq!(media_equalizer(), asked);
1600        assert!(media_equalizer_bands().is_empty());
1601    }
1602
1603    #[test]
1604    fn a_backend_without_an_equalizer_says_so_rather_than_pretending() {
1605        let _guard = test_service_guard();
1606        clear_platform_media_player();
1607        set_platform_media_player(FakePlayer::with(MediaCapabilities {
1608            equalizer: false,
1609            ..MediaCapabilities::TRANSPORT
1610        }));
1611
1612        assert!(media_equalizer_bands().is_empty());
1613        assert!(!set_media_equalizer(EqualizerSettings::flat(10)));
1614    }
1615
1616    #[test]
1617    fn an_item_falls_back_to_its_file_name_for_a_title() {
1618        assert_eq!(
1619            MediaItem::new("file:///music/03 - Song.mp3").display_title(),
1620            "03 - Song.mp3"
1621        );
1622        assert_eq!(
1623            MediaItem::new("https://host/stream?token=1").display_title(),
1624            "stream"
1625        );
1626        assert_eq!(track().display_title(), "Track");
1627    }
1628
1629    #[test]
1630    fn progress_reports_fractions_only_for_items_that_have_a_length() {
1631        let known = PlaybackProgress::new(Duration::from_secs(30), Duration::from_secs(120));
1632        assert_eq!(known.fraction(), Some(0.25));
1633        assert_eq!(known.buffered_fraction(), Some(1.0));
1634
1635        let live = PlaybackProgress {
1636            position: Duration::from_secs(30),
1637            duration: None,
1638            buffered: Duration::from_secs(35),
1639        };
1640        assert_eq!(live.fraction(), None);
1641        assert_eq!(live.buffered_fraction(), None);
1642    }
1643
1644    #[test]
1645    fn progress_never_reads_past_the_end_of_the_item() {
1646        let progress = PlaybackProgress::new(Duration::from_secs(500), Duration::from_secs(120));
1647        assert_eq!(progress.position, Duration::from_secs(120));
1648        assert_eq!(progress.fraction(), Some(1.0));
1649    }
1650
1651    #[test]
1652    fn samples_reject_a_layout_that_does_not_describe_the_data() {
1653        assert!(MediaSamples::new(44_100, 2, 0, vec![0.0; 3]).is_none());
1654        assert!(MediaSamples::new(0, 2, 0, vec![0.0; 4]).is_none());
1655        assert!(MediaSamples::new(44_100, 0, 0, vec![0.0; 4]).is_none());
1656
1657        let block = MediaSamples::new(44_100, 2, 7, vec![0.0; 4410]).expect("well-formed block");
1658        assert_eq!(block.frames(), 2205);
1659        assert_eq!(block.span(), Duration::from_millis(50));
1660        assert_eq!(block.sequence, 7);
1661    }
1662
1663    #[test]
1664    fn without_a_backend_every_call_reports_that_it_is_unsupported() {
1665        let _guard = test_service_guard();
1666        clear_platform_media_player();
1667
1668        assert!(!media_playback_supported());
1669        assert_eq!(media_capabilities(), MediaCapabilities::default());
1670        assert_eq!(open_media(track()), Err(MediaError::Unsupported));
1671        assert_eq!(
1672            playback_state(),
1673            PlaybackState::Failed(MediaError::Unsupported)
1674        );
1675        assert_eq!(play_media(), Err(MediaError::Unsupported));
1676        assert_eq!(seek_media(Duration::ZERO), Err(MediaError::Unsupported));
1677        assert!(!set_media_speed(2.0));
1678        assert!(!set_media_analysis_enabled(true));
1679    }
1680
1681    #[test]
1682    fn opening_an_item_shows_the_wait_before_the_backend_is_asked() {
1683        let (_guard, player) = install();
1684        let seen = Arc::new(Mutex::new(Vec::new()));
1685        let recorder = Arc::clone(&seen);
1686        let _observer = observe_playback_state(move |state| recorder.lock().push(state));
1687
1688        open_media(track()).expect("the fake backend opens anything");
1689
1690        assert_eq!(
1691            *seen.lock(),
1692            vec![
1693                PlaybackState::Idle,
1694                PlaybackState::Loading,
1695                PlaybackState::Paused,
1696            ]
1697        );
1698        assert_eq!(
1699            player.calls(),
1700            vec!["session Track", "prepare file:///music/track.flac"]
1701        );
1702        assert_eq!(current_media_item().map(|item| item.uri), Some(track().uri));
1703    }
1704
1705    #[test]
1706    fn an_item_that_cannot_be_opened_publishes_the_failure() {
1707        let _guard = test_service_guard();
1708        clear_platform_media_player();
1709        set_platform_media_player(FakePlayer::failing());
1710
1711        let error = open_media(track()).expect_err("the failing backend refuses");
1712        assert_eq!(
1713            error,
1714            MediaError::UnsupportedSource("file:///music/track.flac".to_string())
1715        );
1716        assert_eq!(playback_state().failure(), Some(&error));
1717    }
1718
1719    #[test]
1720    fn the_transport_routes_to_the_backend_and_publishes_what_it_did() {
1721        let (_guard, player) = install();
1722
1723        open_media(track()).expect("opens");
1724        play_media().expect("plays");
1725        assert!(playback_state().is_playing());
1726
1727        toggle_media();
1728        assert_eq!(playback_state(), PlaybackState::Paused);
1729
1730        toggle_media();
1731        assert!(playback_state().is_playing());
1732
1733        stop_media();
1734        assert_eq!(playback_state(), PlaybackState::Idle);
1735        assert_eq!(current_media_item(), None);
1736
1737        assert_eq!(
1738            player.calls(),
1739            vec![
1740                "session Track",
1741                "prepare file:///music/track.flac",
1742                "play",
1743                "pause",
1744                "play",
1745                "stop",
1746            ]
1747        );
1748    }
1749
1750    #[test]
1751    fn playing_nothing_reports_that_nothing_is_loaded() {
1752        let (_guard, _player) = install();
1753
1754        assert_eq!(play_media(), Err(MediaError::NothingLoaded));
1755        assert_eq!(
1756            seek_media(Duration::from_secs(1)),
1757            Err(MediaError::NothingLoaded)
1758        );
1759    }
1760
1761    #[test]
1762    fn a_seek_is_clamped_to_the_item_rather_than_to_each_backend() {
1763        let (_guard, player) = install();
1764        open_media(track()).expect("opens");
1765
1766        seek_media(Duration::from_secs(1_000)).expect("seeks");
1767
1768        assert!(player.calls().contains(&"seek 200000".to_string()));
1769    }
1770
1771    #[test]
1772    fn a_seek_bar_fraction_maps_onto_the_item() {
1773        let (_guard, player) = install();
1774        open_media(track()).expect("opens");
1775
1776        seek_media_fraction(0.25).expect("seeks");
1777        seek_media_fraction(3.0).expect("clamps rather than refusing");
1778
1779        let calls = player.calls();
1780        assert!(calls.contains(&"seek 50000".to_string()));
1781        assert!(calls.contains(&"seek 200000".to_string()));
1782    }
1783
1784    #[test]
1785    fn a_stream_with_no_length_has_no_seek_bar_fraction() {
1786        let (_guard, _player) = install();
1787        open_media(MediaItem::new("https://host/live")).expect("opens");
1788
1789        assert_eq!(seek_media_fraction(0.5), Err(MediaError::NotSeekable));
1790    }
1791
1792    #[test]
1793    fn a_backend_that_cannot_seek_says_so_instead_of_moving_nothing() {
1794        let _guard = test_service_guard();
1795        clear_platform_media_player();
1796        set_platform_media_player(FakePlayer::with(MediaCapabilities {
1797            seeking: false,
1798            ..MediaCapabilities::TRANSPORT
1799        }));
1800
1801        open_media(track()).expect("opens");
1802        assert_eq!(
1803            seek_media(Duration::from_secs(1)),
1804            Err(MediaError::NotSeekable)
1805        );
1806    }
1807
1808    #[test]
1809    fn what_reaches_the_device_is_the_volume_combined_with_the_focus_gain() {
1810        let (_guard, player) = install();
1811        open_media(track()).expect("opens");
1812
1813        set_media_volume(0.5);
1814        assert_eq!(*player.volume.lock(), 0.5);
1815        assert_eq!(media_volume(), 0.5);
1816
1817        publish_audio_focus(AudioFocus::Ducked);
1818        assert_eq!(*player.volume.lock(), 0.5 * DUCKED_GAIN);
1819
1820        // The application may still change its own volume while ducked, and
1821        // doing so must not undo the duck.
1822        set_media_volume(1.0);
1823        assert_eq!(*player.volume.lock(), DUCKED_GAIN);
1824
1825        publish_audio_focus(AudioFocus::Gained);
1826        assert_eq!(*player.volume.lock(), 1.0);
1827    }
1828
1829    #[test]
1830    fn a_volume_outside_the_range_is_brought_back_into_it() {
1831        let (_guard, player) = install();
1832
1833        set_media_volume(4.0);
1834        assert_eq!(media_volume(), 1.0);
1835        set_media_volume(-1.0);
1836        assert_eq!(media_volume(), 0.0);
1837        assert_eq!(*player.volume.lock(), 0.0);
1838    }
1839
1840    #[test]
1841    fn a_transient_loss_pauses_and_the_next_gain_resumes() {
1842        let (_guard, _player) = install();
1843        open_media(track()).expect("opens");
1844        play_media().expect("plays");
1845
1846        publish_audio_focus(AudioFocus::LostTransient);
1847        assert_eq!(playback_state(), PlaybackState::Paused);
1848
1849        publish_audio_focus(AudioFocus::Gained);
1850        assert!(playback_state().is_playing());
1851    }
1852
1853    #[test]
1854    fn regaining_focus_does_not_resume_what_the_user_paused() {
1855        let (_guard, _player) = install();
1856        open_media(track()).expect("opens");
1857        play_media().expect("plays");
1858        pause_media();
1859
1860        publish_audio_focus(AudioFocus::LostTransient);
1861        publish_audio_focus(AudioFocus::Gained);
1862
1863        assert_eq!(playback_state(), PlaybackState::Paused);
1864    }
1865
1866    #[test]
1867    fn focus_lost_for_good_stops_and_does_not_come_back() {
1868        let (_guard, _player) = install();
1869        open_media(track()).expect("opens");
1870        play_media().expect("plays");
1871
1872        publish_audio_focus(AudioFocus::Lost);
1873        assert_eq!(playback_state(), PlaybackState::Idle);
1874
1875        publish_audio_focus(AudioFocus::Gained);
1876        assert_eq!(playback_state(), PlaybackState::Idle);
1877    }
1878
1879    #[test]
1880    fn session_commands_drive_the_transport_and_still_reach_the_application() {
1881        let (_guard, player) = install();
1882        open_media(track()).expect("opens");
1883        let seen = Arc::new(Mutex::new(Vec::new()));
1884        let recorder = Arc::clone(&seen);
1885        let _observer = observe_media_commands(move |command| recorder.lock().push(command));
1886
1887        publish_media_command(MediaCommand::Play);
1888        assert!(playback_state().is_playing());
1889        publish_media_command(MediaCommand::TogglePlayPause);
1890        assert_eq!(playback_state(), PlaybackState::Paused);
1891        publish_media_command(MediaCommand::SeekTo(Duration::from_secs(10)));
1892        publish_media_command(MediaCommand::Next);
1893
1894        assert_eq!(
1895            *seen.lock(),
1896            vec![
1897                MediaCommand::Play,
1898                MediaCommand::TogglePlayPause,
1899                MediaCommand::SeekTo(Duration::from_secs(10)),
1900                MediaCommand::Next,
1901            ]
1902        );
1903        // `Next` needs a playlist the framework does not have, so it reached
1904        // the application without touching the transport.
1905        assert!(player.calls().contains(&"seek 10000".to_string()));
1906        assert_eq!(playback_state(), PlaybackState::Paused);
1907    }
1908
1909    #[test]
1910    fn next_and_previous_are_the_commands_the_framework_leaves_alone() {
1911        assert!(MediaCommand::Play.is_transport());
1912        assert!(MediaCommand::SeekTo(Duration::ZERO).is_transport());
1913        assert!(!MediaCommand::Next.is_transport());
1914        assert!(!MediaCommand::Previous.is_transport());
1915    }
1916
1917    #[test]
1918    fn analysis_is_off_until_it_is_asked_for_and_only_where_it_exists() {
1919        let (_guard, player) = install();
1920        assert!(set_media_analysis_enabled(true));
1921        assert!(player.calls().contains(&"analysis true".to_string()));
1922
1923        clear_platform_media_player();
1924        set_platform_media_player(FakePlayer::with(MediaCapabilities::TRANSPORT));
1925        assert!(!set_media_analysis_enabled(true));
1926    }
1927
1928    #[test]
1929    fn the_newest_sample_block_replaces_the_stored_one() {
1930        let (_guard, _player) = install();
1931        let first = MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block");
1932        let second = MediaSamples::new(48_000, 1, 2, vec![0.5; 8]).expect("block");
1933
1934        publish_media_samples(first);
1935        publish_media_samples(second.clone());
1936
1937        assert_eq!(latest_media_samples(), Some(second));
1938        record_dropped_media_samples();
1939        record_dropped_media_samples();
1940        assert_eq!(dropped_media_samples(), 2);
1941    }
1942
1943    #[test]
1944    fn turning_analysis_off_forgets_the_last_block() {
1945        let (_guard, _player) = install();
1946        publish_media_samples(MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block"));
1947
1948        assert!(set_media_analysis_enabled(false));
1949
1950        assert_eq!(latest_media_samples(), None);
1951    }
1952
1953    #[test]
1954    fn observers_stop_being_called_once_they_are_dropped() {
1955        let (_guard, _player) = install();
1956        let seen = Arc::new(Mutex::new(0usize));
1957        let recorder = Arc::clone(&seen);
1958        let observer = observe_playback_progress(move |_| *recorder.lock() += 1);
1959
1960        publish_playback_progress(PlaybackProgress::new(
1961            Duration::from_secs(1),
1962            Duration::from_secs(10),
1963        ));
1964        let delivered = *seen.lock();
1965        drop(observer);
1966        publish_playback_progress(PlaybackProgress::new(
1967            Duration::from_secs(2),
1968            Duration::from_secs(10),
1969        ));
1970
1971        assert_eq!(*seen.lock(), delivered);
1972    }
1973
1974    #[test]
1975    fn published_progress_never_reads_past_the_end() {
1976        let (_guard, _player) = install();
1977        publish_playback_progress(PlaybackProgress {
1978            position: Duration::from_secs(99),
1979            duration: Some(Duration::from_secs(10)),
1980            buffered: Duration::from_secs(99),
1981        });
1982
1983        let progress = playback_progress();
1984        assert_eq!(progress.position, Duration::from_secs(10));
1985        assert_eq!(progress.buffered, Duration::from_secs(10));
1986    }
1987
1988    #[test]
1989    fn playing_holds_the_runtime_awake_and_stopping_lets_it_sleep() {
1990        let (_guard, _player) = install();
1991        assert!(!holds_background_work());
1992
1993        open_media(track()).expect("opens");
1994        assert!(
1995            !holds_background_work(),
1996            "an item that is open but not playing is not work the runtime must keep turning for"
1997        );
1998
1999        play_media().expect("plays");
2000        assert!(holds_background_work());
2001
2002        pause_media();
2003        assert!(!holds_background_work());
2004
2005        play_media().expect("plays");
2006        assert!(holds_background_work());
2007        stop_media();
2008        assert!(!holds_background_work());
2009    }
2010
2011    #[test]
2012    fn a_destroyed_host_stops_playback_but_a_backgrounded_one_does_not() {
2013        let (_guard, _player) = install();
2014        open_media(track()).expect("opens");
2015        play_media().expect("plays");
2016
2017        on_lifecycle(LifecycleEvent {
2018            from: LifecycleState::Resumed,
2019            to: LifecycleState::Stopped,
2020        });
2021        assert!(playback_state().is_playing());
2022
2023        on_lifecycle(LifecycleEvent {
2024            from: LifecycleState::Stopped,
2025            to: LifecycleState::Destroyed,
2026        });
2027        assert_eq!(playback_state(), PlaybackState::Idle);
2028    }
2029
2030    #[test]
2031    fn metadata_learned_after_playback_started_reaches_the_session() {
2032        let (_guard, player) = install();
2033        open_media(MediaItem::new("file:///music/untagged.mp3")).expect("opens");
2034
2035        set_media_metadata(MediaMetadata::titled("Late Tag").artist("Artist"));
2036
2037        assert!(player.calls().contains(&"session Late Tag".to_string()));
2038        assert_eq!(
2039            current_media_item().map(|item| item.metadata.title),
2040            Some("Late Tag".to_string())
2041        );
2042    }
2043
2044    #[test]
2045    fn metadata_with_nothing_in_it_is_metadata_a_lock_screen_can_skip() {
2046        assert!(MediaMetadata::default().is_empty());
2047        assert!(!MediaMetadata::titled("Track").is_empty());
2048    }
2049
2050    #[test]
2051    fn a_path_survives_the_round_trip_through_a_uri() {
2052        let path = PathBuf::from("/music/Sgt. Pepper's #1.mp3");
2053        let uri = uri_for_path(&path);
2054
2055        assert_eq!(uri, "file:///music/Sgt.%20Pepper%27s%20%231.mp3");
2056        assert_eq!(path_from_uri(&uri), Some(path));
2057    }
2058
2059    #[test]
2060    fn a_windows_path_keeps_its_drive_letter() {
2061        let uri = uri_for_path(Path::new("C:\\Music\\track.mp3"));
2062
2063        assert_eq!(uri, "file:///C%3A/Music/track.mp3");
2064        assert_eq!(
2065            path_from_uri(&uri),
2066            Some(PathBuf::from("C:/Music/track.mp3"))
2067        );
2068    }
2069
2070    #[test]
2071    fn a_bare_path_is_accepted_as_itself() {
2072        assert_eq!(
2073            path_from_uri("/music/track.mp3"),
2074            Some(PathBuf::from("/music/track.mp3"))
2075        );
2076    }
2077
2078    #[test]
2079    fn anything_that_is_not_a_local_file_has_no_path() {
2080        assert_eq!(path_from_uri("https://host/stream.mp3"), None);
2081        assert_eq!(path_from_uri("content://media/audio/1"), None);
2082        assert_eq!(path_from_uri("blob:https://host/abc"), None);
2083        assert_eq!(path_from_uri("file://host/share/track.mp3"), None);
2084        assert_eq!(path_from_uri(""), None);
2085    }
2086
2087    #[test]
2088    fn a_truncated_escape_is_not_guessed_at() {
2089        assert_eq!(path_from_uri("file:///music/track%2"), None);
2090        assert_eq!(path_from_uri("file:///music/track%zz.mp3"), None);
2091    }
2092
2093    #[test]
2094    fn speed_and_looping_reach_a_backend_that_has_them() {
2095        let (_guard, player) = install();
2096        assert!(set_media_speed(1.5));
2097        set_media_looping(true);
2098
2099        let calls = player.calls();
2100        assert!(calls.contains(&"speed 1.5".to_string()));
2101        assert!(calls.contains(&"looping true".to_string()));
2102    }
2103}