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 = 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
1295fn percent_decode(text: &str) -> Option<String> {
1296    if !text.contains('%') {
1297        return Some(text.to_string());
1298    }
1299    let bytes = text.as_bytes();
1300    let mut out = Vec::with_capacity(bytes.len());
1301    let mut index = 0;
1302    while index < bytes.len() {
1303        if bytes[index] == b'%' {
1304            let hex = text.get(index + 1..index + 3)?;
1305            out.push(u8::from_str_radix(hex, 16).ok()?);
1306            index += 3;
1307        } else {
1308            out.push(bytes[index]);
1309            index += 1;
1310        }
1311    }
1312    String::from_utf8(out).ok()
1313}
1314
1315// -- Composables -------------------------------------------------------------
1316
1317/// What the player is doing, observed for as long as this call stays in the
1318/// composition.
1319#[allow(non_snake_case)]
1320pub fn rememberPlaybackState() -> State<PlaybackState> {
1321    let updates = rememberEventStream((), |sender| {
1322        observe_playback_state(move |state| sender.send(state))
1323    });
1324    cranpose_core::collectAsState(updates, (), playback_state())
1325}
1326
1327/// Where the open item is, observed for as long as this call stays in the
1328/// composition.
1329///
1330/// This recomposes as the position moves, which is what a seek bar and a time
1331/// label want. A visualiser or a waveform that redraws every frame anyway reads
1332/// [`playback_progress`] during draw instead.
1333#[allow(non_snake_case)]
1334pub fn rememberPlaybackProgress() -> State<PlaybackProgress> {
1335    let updates = rememberEventStream((), |sender| {
1336        observe_playback_progress(move |progress| sender.send(progress))
1337    });
1338    cranpose_core::collectAsState(updates, (), playback_progress())
1339}
1340
1341/// What the rest of the device is doing with the output, observed for as long
1342/// as this call stays in the composition.
1343#[allow(non_snake_case)]
1344pub fn rememberAudioFocus() -> State<AudioFocus> {
1345    let updates = rememberEventStream((), |sender| {
1346        observe_audio_focus(move |focus| sender.send(focus))
1347    });
1348    cranpose_core::collectAsState(updates, (), audio_focus())
1349}
1350
1351/// Buttons pressed outside the application's own UI, as a stream this
1352/// composition collects.
1353///
1354/// The transport commands have already been carried out by the time they arrive
1355/// here; what an application acts on is [`MediaCommand::Next`] and
1356/// [`MediaCommand::Previous`], which need the playlist it owns.
1357#[allow(non_snake_case)]
1358pub fn rememberMediaCommands() -> EventStream<MediaCommand> {
1359    rememberEventStream((), |sender| {
1360        observe_media_commands(move |command| sender.send(command))
1361    })
1362}
1363
1364/// Samples as they are heard, as a stream this composition collects.
1365///
1366/// Enable them with [`set_media_analysis_enabled`] first; a backend that cannot
1367/// produce them says so through [`MediaCapabilities::analysis`].
1368#[allow(non_snake_case)]
1369pub fn rememberMediaSamples() -> EventStream<MediaSamples> {
1370    rememberEventStream((), |sender| {
1371        observe_media_samples(move |samples| sender.send(samples))
1372    })
1373}
1374
1375#[cfg(test)]
1376mod tests {
1377    use super::*;
1378    use crate::registry::test_service_guard;
1379
1380    /// A backend that records what it was asked and publishes what a real one
1381    /// would.
1382    struct FakePlayer {
1383        capabilities: MediaCapabilities,
1384        calls: Mutex<Vec<String>>,
1385        volume: Mutex<f32>,
1386        prepare_fails: bool,
1387    }
1388
1389    impl FakePlayer {
1390        fn new() -> Arc<FakePlayer> {
1391            Arc::new(FakePlayer {
1392                capabilities: MediaCapabilities {
1393                    seeking: true,
1394                    speed: true,
1395                    looping: true,
1396                    analysis: true,
1397                    session: true,
1398                    equalizer: true,
1399                    probing: true,
1400                },
1401                calls: Mutex::new(Vec::new()),
1402                volume: Mutex::new(1.0),
1403                prepare_fails: false,
1404            })
1405        }
1406
1407        fn with(capabilities: MediaCapabilities) -> Arc<FakePlayer> {
1408            Arc::new(FakePlayer {
1409                capabilities,
1410                calls: Mutex::new(Vec::new()),
1411                volume: Mutex::new(1.0),
1412                prepare_fails: false,
1413            })
1414        }
1415
1416        fn failing() -> Arc<FakePlayer> {
1417            Arc::new(FakePlayer {
1418                capabilities: MediaCapabilities::TRANSPORT,
1419                calls: Mutex::new(Vec::new()),
1420                volume: Mutex::new(1.0),
1421                prepare_fails: true,
1422            })
1423        }
1424
1425        fn note(&self, call: impl Into<String>) {
1426            self.calls.lock().push(call.into());
1427        }
1428
1429        fn calls(&self) -> Vec<String> {
1430            self.calls.lock().clone()
1431        }
1432    }
1433
1434    impl MediaPlayer for FakePlayer {
1435        fn capabilities(&self) -> MediaCapabilities {
1436            self.capabilities
1437        }
1438
1439        fn prepare(&self, item: &MediaItem) -> Result<(), MediaError> {
1440            self.note(format!("prepare {}", item.uri));
1441            if self.prepare_fails {
1442                return Err(MediaError::UnsupportedSource(item.uri.clone()));
1443            }
1444            publish_playback_state(PlaybackState::Paused);
1445            Ok(())
1446        }
1447
1448        fn play(&self) -> Result<(), MediaError> {
1449            self.note("play");
1450            publish_playback_state(PlaybackState::Playing);
1451            Ok(())
1452        }
1453
1454        fn pause(&self) {
1455            self.note("pause");
1456            publish_playback_state(PlaybackState::Paused);
1457        }
1458
1459        fn stop(&self) {
1460            self.note("stop");
1461        }
1462
1463        fn seek_to(&self, position: Duration) -> Result<(), MediaError> {
1464            self.note(format!("seek {}", position.as_millis()));
1465            Ok(())
1466        }
1467
1468        fn set_volume(&self, volume: f32) {
1469            *self.volume.lock() = volume;
1470        }
1471
1472        fn set_speed(&self, speed: f32) -> bool {
1473            self.note(format!("speed {speed}"));
1474            true
1475        }
1476
1477        fn set_looping(&self, looping: bool) {
1478            self.note(format!("looping {looping}"));
1479        }
1480
1481        fn set_analysis_enabled(&self, enabled: bool) -> bool {
1482            self.note(format!("analysis {enabled}"));
1483            true
1484        }
1485
1486        fn set_session_metadata(&self, metadata: &MediaMetadata) {
1487            self.note(format!("session {}", metadata.title));
1488        }
1489
1490        fn equalizer_bands(&self) -> Vec<EqualizerBand> {
1491            // Three bands with a narrow range, so a test can tell a clamp from
1492            // a pass-through and a band count from a hard-coded ten.
1493            vec![
1494                EqualizerBand::new(60.0, 6.0),
1495                EqualizerBand::new(1_000.0, 6.0),
1496                EqualizerBand::new(10_000.0, 6.0),
1497            ]
1498        }
1499
1500        fn set_equalizer(&self, settings: &EqualizerSettings) {
1501            self.note(format!(
1502                "equalizer {} preamp {} gains {:?}",
1503                settings.enabled, settings.preamp_db, settings.gains_db
1504            ));
1505        }
1506    }
1507
1508    fn install() -> (crate::registry::TestServiceGuard, Arc<FakePlayer>) {
1509        let guard = test_service_guard();
1510        clear_platform_media_player();
1511        let player = FakePlayer::new();
1512        set_platform_media_player(player.clone());
1513        (guard, player)
1514    }
1515
1516    fn track() -> MediaItem {
1517        MediaItem::new("file:///music/track.flac").with_metadata(
1518            MediaMetadata::titled("Track")
1519                .artist("Artist")
1520                .duration(Duration::from_secs(200)),
1521        )
1522    }
1523
1524    #[test]
1525    fn metadata_carries_everything_a_lock_screen_shows() {
1526        let artwork = MediaArtwork {
1527            bytes: vec![1, 2, 3].into(),
1528            mime: "image/png".to_string(),
1529        };
1530        let metadata = MediaMetadata::titled("Song")
1531            .artist("Band")
1532            .album("Record")
1533            .duration(Duration::from_secs(210))
1534            .artwork(artwork.clone());
1535
1536        assert_eq!(metadata.album, "Record");
1537        assert_eq!(metadata.artwork.as_ref(), Some(&artwork));
1538        assert!(!metadata.is_empty());
1539        // An album on its own is still something to show.
1540        assert!(!MediaMetadata::default().album("Record").is_empty());
1541        assert!(MediaMetadata::default().is_empty());
1542    }
1543
1544    #[test]
1545    fn a_band_reports_what_it_can_actually_do() {
1546        let band = EqualizerBand::new(1_000.0, 6.0);
1547        assert_eq!(band.clamp_gain(0.0), 0.0);
1548        assert_eq!(band.clamp_gain(6.0), 6.0);
1549        assert_eq!(band.clamp_gain(7.5), 6.0);
1550        assert_eq!(band.clamp_gain(-7.5), -6.0);
1551    }
1552
1553    #[test]
1554    fn an_equalizer_setting_is_clamped_to_the_bands_the_backend_has() {
1555        let bands = vec![
1556            EqualizerBand::new(60.0, 6.0),
1557            EqualizerBand::new(1_000.0, 6.0),
1558        ];
1559        let asked = EqualizerSettings {
1560            enabled: true,
1561            preamp_db: -3.0,
1562            // Too loud for these bands, and one entry too many.
1563            gains_db: vec![12.0, -12.0, 4.0],
1564        };
1565        let applied = asked.clamped_to(&bands);
1566        assert_eq!(applied.gains_db, vec![6.0, -6.0]);
1567        assert_eq!(applied.preamp_db, -3.0);
1568        assert!(applied.enabled);
1569    }
1570
1571    #[test]
1572    fn a_setting_shorter_than_the_bands_leaves_the_rest_flat() {
1573        let bands = octave_equalizer_bands(12.0);
1574        let applied = EqualizerSettings {
1575            enabled: true,
1576            preamp_db: 0.0,
1577            gains_db: vec![3.0],
1578        }
1579        .clamped_to(&bands);
1580        assert_eq!(applied.gains_db.len(), bands.len());
1581        assert_eq!(applied.gains_db[0], 3.0);
1582        assert!(applied.gains_db[1..].iter().all(|gain| *gain == 0.0));
1583    }
1584
1585    #[test]
1586    fn a_curve_reaches_the_backend_clamped_to_its_own_bands() {
1587        let (_guard, player) = install();
1588
1589        assert_eq!(media_equalizer_bands().len(), 3);
1590        assert!(set_media_equalizer(EqualizerSettings {
1591            enabled: true,
1592            preamp_db: -2.0,
1593            gains_db: vec![9.0, 0.0, -9.0],
1594        }));
1595
1596        assert!(
1597            player
1598                .calls()
1599                .iter()
1600                .any(|call| call == "equalizer true preamp -2 gains [6.0, 0.0, -6.0]"),
1601            "the backend was not given the clamped curve: {:?}",
1602            player.calls()
1603        );
1604    }
1605
1606    #[test]
1607    fn a_curve_is_remembered_even_where_nothing_can_apply_it() {
1608        let _guard = test_service_guard();
1609        clear_platform_media_player();
1610
1611        let asked = EqualizerSettings {
1612            enabled: true,
1613            preamp_db: -1.0,
1614            gains_db: vec![4.0, -4.0],
1615        };
1616        // No backend: the user's curve is still theirs, and reaches the next
1617        // device that can honour it.
1618        assert!(!set_media_equalizer(asked.clone()));
1619        assert_eq!(media_equalizer(), asked);
1620        assert!(media_equalizer_bands().is_empty());
1621    }
1622
1623    #[test]
1624    fn a_backend_without_an_equalizer_says_so_rather_than_pretending() {
1625        let _guard = test_service_guard();
1626        clear_platform_media_player();
1627        set_platform_media_player(FakePlayer::with(MediaCapabilities {
1628            equalizer: false,
1629            ..MediaCapabilities::TRANSPORT
1630        }));
1631
1632        assert!(media_equalizer_bands().is_empty());
1633        assert!(!set_media_equalizer(EqualizerSettings::flat(10)));
1634    }
1635
1636    #[test]
1637    fn an_item_falls_back_to_its_file_name_for_a_title() {
1638        assert_eq!(
1639            MediaItem::new("file:///music/03 - Song.mp3").display_title(),
1640            "03 - Song.mp3"
1641        );
1642        assert_eq!(
1643            MediaItem::new("https://host/stream?token=1").display_title(),
1644            "stream"
1645        );
1646        assert_eq!(track().display_title(), "Track");
1647    }
1648
1649    #[test]
1650    fn progress_reports_fractions_only_for_items_that_have_a_length() {
1651        let known = PlaybackProgress::new(Duration::from_secs(30), Duration::from_secs(120));
1652        assert_eq!(known.fraction(), Some(0.25));
1653        assert_eq!(known.buffered_fraction(), Some(1.0));
1654
1655        let live = PlaybackProgress {
1656            position: Duration::from_secs(30),
1657            duration: None,
1658            buffered: Duration::from_secs(35),
1659        };
1660        assert_eq!(live.fraction(), None);
1661        assert_eq!(live.buffered_fraction(), None);
1662    }
1663
1664    #[test]
1665    fn progress_never_reads_past_the_end_of_the_item() {
1666        let progress = PlaybackProgress::new(Duration::from_secs(500), Duration::from_secs(120));
1667        assert_eq!(progress.position, Duration::from_secs(120));
1668        assert_eq!(progress.fraction(), Some(1.0));
1669    }
1670
1671    #[test]
1672    fn samples_reject_a_layout_that_does_not_describe_the_data() {
1673        assert!(MediaSamples::new(44_100, 2, 0, vec![0.0; 3]).is_none());
1674        assert!(MediaSamples::new(0, 2, 0, vec![0.0; 4]).is_none());
1675        assert!(MediaSamples::new(44_100, 0, 0, vec![0.0; 4]).is_none());
1676
1677        let block = MediaSamples::new(44_100, 2, 7, vec![0.0; 4410]).expect("well-formed block");
1678        assert_eq!(block.frames(), 2205);
1679        assert_eq!(block.span(), Duration::from_millis(50));
1680        assert_eq!(block.sequence, 7);
1681    }
1682
1683    #[test]
1684    fn without_a_backend_every_call_reports_that_it_is_unsupported() {
1685        let _guard = test_service_guard();
1686        clear_platform_media_player();
1687
1688        assert!(!media_playback_supported());
1689        assert_eq!(media_capabilities(), MediaCapabilities::default());
1690        assert_eq!(open_media(track()), Err(MediaError::Unsupported));
1691        assert_eq!(
1692            playback_state(),
1693            PlaybackState::Failed(MediaError::Unsupported)
1694        );
1695        assert_eq!(play_media(), Err(MediaError::Unsupported));
1696        assert_eq!(seek_media(Duration::ZERO), Err(MediaError::Unsupported));
1697        assert!(!set_media_speed(2.0));
1698        assert!(!set_media_analysis_enabled(true));
1699    }
1700
1701    #[test]
1702    fn opening_an_item_shows_the_wait_before_the_backend_is_asked() {
1703        let (_guard, player) = install();
1704        let seen = Arc::new(Mutex::new(Vec::new()));
1705        let recorder = Arc::clone(&seen);
1706        let _observer = observe_playback_state(move |state| recorder.lock().push(state));
1707
1708        open_media(track()).expect("the fake backend opens anything");
1709
1710        assert_eq!(
1711            *seen.lock(),
1712            vec![
1713                PlaybackState::Idle,
1714                PlaybackState::Loading,
1715                PlaybackState::Paused,
1716            ]
1717        );
1718        assert_eq!(
1719            player.calls(),
1720            vec!["session Track", "prepare file:///music/track.flac"]
1721        );
1722        assert_eq!(current_media_item().map(|item| item.uri), Some(track().uri));
1723    }
1724
1725    #[test]
1726    fn an_item_that_cannot_be_opened_publishes_the_failure() {
1727        let _guard = test_service_guard();
1728        clear_platform_media_player();
1729        set_platform_media_player(FakePlayer::failing());
1730
1731        let error = open_media(track()).expect_err("the failing backend refuses");
1732        assert_eq!(
1733            error,
1734            MediaError::UnsupportedSource("file:///music/track.flac".to_string())
1735        );
1736        assert_eq!(playback_state().failure(), Some(&error));
1737    }
1738
1739    #[test]
1740    fn the_transport_routes_to_the_backend_and_publishes_what_it_did() {
1741        let (_guard, player) = install();
1742
1743        open_media(track()).expect("opens");
1744        play_media().expect("plays");
1745        assert!(playback_state().is_playing());
1746
1747        toggle_media();
1748        assert_eq!(playback_state(), PlaybackState::Paused);
1749
1750        toggle_media();
1751        assert!(playback_state().is_playing());
1752
1753        stop_media();
1754        assert_eq!(playback_state(), PlaybackState::Idle);
1755        assert_eq!(current_media_item(), None);
1756
1757        assert_eq!(
1758            player.calls(),
1759            vec![
1760                "session Track",
1761                "prepare file:///music/track.flac",
1762                "play",
1763                "pause",
1764                "play",
1765                "stop",
1766            ]
1767        );
1768    }
1769
1770    #[test]
1771    fn playing_nothing_reports_that_nothing_is_loaded() {
1772        let (_guard, _player) = install();
1773
1774        assert_eq!(play_media(), Err(MediaError::NothingLoaded));
1775        assert_eq!(
1776            seek_media(Duration::from_secs(1)),
1777            Err(MediaError::NothingLoaded)
1778        );
1779    }
1780
1781    #[test]
1782    fn a_seek_is_clamped_to_the_item_rather_than_to_each_backend() {
1783        let (_guard, player) = install();
1784        open_media(track()).expect("opens");
1785
1786        seek_media(Duration::from_secs(1_000)).expect("seeks");
1787
1788        assert!(player.calls().contains(&"seek 200000".to_string()));
1789    }
1790
1791    #[test]
1792    fn a_seek_bar_fraction_maps_onto_the_item() {
1793        let (_guard, player) = install();
1794        open_media(track()).expect("opens");
1795
1796        seek_media_fraction(0.25).expect("seeks");
1797        seek_media_fraction(3.0).expect("clamps rather than refusing");
1798
1799        let calls = player.calls();
1800        assert!(calls.contains(&"seek 50000".to_string()));
1801        assert!(calls.contains(&"seek 200000".to_string()));
1802    }
1803
1804    #[test]
1805    fn a_stream_with_no_length_has_no_seek_bar_fraction() {
1806        let (_guard, _player) = install();
1807        open_media(MediaItem::new("https://host/live")).expect("opens");
1808
1809        assert_eq!(seek_media_fraction(0.5), Err(MediaError::NotSeekable));
1810    }
1811
1812    #[test]
1813    fn a_backend_that_cannot_seek_says_so_instead_of_moving_nothing() {
1814        let _guard = test_service_guard();
1815        clear_platform_media_player();
1816        set_platform_media_player(FakePlayer::with(MediaCapabilities {
1817            seeking: false,
1818            ..MediaCapabilities::TRANSPORT
1819        }));
1820
1821        open_media(track()).expect("opens");
1822        assert_eq!(
1823            seek_media(Duration::from_secs(1)),
1824            Err(MediaError::NotSeekable)
1825        );
1826    }
1827
1828    #[test]
1829    fn what_reaches_the_device_is_the_volume_combined_with_the_focus_gain() {
1830        let (_guard, player) = install();
1831        open_media(track()).expect("opens");
1832
1833        set_media_volume(0.5);
1834        assert_eq!(*player.volume.lock(), 0.5);
1835        assert_eq!(media_volume(), 0.5);
1836
1837        publish_audio_focus(AudioFocus::Ducked);
1838        assert_eq!(*player.volume.lock(), 0.5 * DUCKED_GAIN);
1839
1840        // The application may still change its own volume while ducked, and
1841        // doing so must not undo the duck.
1842        set_media_volume(1.0);
1843        assert_eq!(*player.volume.lock(), DUCKED_GAIN);
1844
1845        publish_audio_focus(AudioFocus::Gained);
1846        assert_eq!(*player.volume.lock(), 1.0);
1847    }
1848
1849    #[test]
1850    fn a_volume_outside_the_range_is_brought_back_into_it() {
1851        let (_guard, player) = install();
1852
1853        set_media_volume(4.0);
1854        assert_eq!(media_volume(), 1.0);
1855        set_media_volume(-1.0);
1856        assert_eq!(media_volume(), 0.0);
1857        assert_eq!(*player.volume.lock(), 0.0);
1858    }
1859
1860    #[test]
1861    fn a_transient_loss_pauses_and_the_next_gain_resumes() {
1862        let (_guard, _player) = install();
1863        open_media(track()).expect("opens");
1864        play_media().expect("plays");
1865
1866        publish_audio_focus(AudioFocus::LostTransient);
1867        assert_eq!(playback_state(), PlaybackState::Paused);
1868
1869        publish_audio_focus(AudioFocus::Gained);
1870        assert!(playback_state().is_playing());
1871    }
1872
1873    #[test]
1874    fn regaining_focus_does_not_resume_what_the_user_paused() {
1875        let (_guard, _player) = install();
1876        open_media(track()).expect("opens");
1877        play_media().expect("plays");
1878        pause_media();
1879
1880        publish_audio_focus(AudioFocus::LostTransient);
1881        publish_audio_focus(AudioFocus::Gained);
1882
1883        assert_eq!(playback_state(), PlaybackState::Paused);
1884    }
1885
1886    #[test]
1887    fn focus_lost_for_good_stops_and_does_not_come_back() {
1888        let (_guard, _player) = install();
1889        open_media(track()).expect("opens");
1890        play_media().expect("plays");
1891
1892        publish_audio_focus(AudioFocus::Lost);
1893        assert_eq!(playback_state(), PlaybackState::Idle);
1894
1895        publish_audio_focus(AudioFocus::Gained);
1896        assert_eq!(playback_state(), PlaybackState::Idle);
1897    }
1898
1899    #[test]
1900    fn session_commands_drive_the_transport_and_still_reach_the_application() {
1901        let (_guard, player) = install();
1902        open_media(track()).expect("opens");
1903        let seen = Arc::new(Mutex::new(Vec::new()));
1904        let recorder = Arc::clone(&seen);
1905        let _observer = observe_media_commands(move |command| recorder.lock().push(command));
1906
1907        publish_media_command(MediaCommand::Play);
1908        assert!(playback_state().is_playing());
1909        publish_media_command(MediaCommand::TogglePlayPause);
1910        assert_eq!(playback_state(), PlaybackState::Paused);
1911        publish_media_command(MediaCommand::SeekTo(Duration::from_secs(10)));
1912        publish_media_command(MediaCommand::Next);
1913
1914        assert_eq!(
1915            *seen.lock(),
1916            vec![
1917                MediaCommand::Play,
1918                MediaCommand::TogglePlayPause,
1919                MediaCommand::SeekTo(Duration::from_secs(10)),
1920                MediaCommand::Next,
1921            ]
1922        );
1923        // `Next` needs a playlist the framework does not have, so it reached
1924        // the application without touching the transport.
1925        assert!(player.calls().contains(&"seek 10000".to_string()));
1926        assert_eq!(playback_state(), PlaybackState::Paused);
1927    }
1928
1929    #[test]
1930    fn next_and_previous_are_the_commands_the_framework_leaves_alone() {
1931        assert!(MediaCommand::Play.is_transport());
1932        assert!(MediaCommand::SeekTo(Duration::ZERO).is_transport());
1933        assert!(!MediaCommand::Next.is_transport());
1934        assert!(!MediaCommand::Previous.is_transport());
1935    }
1936
1937    #[test]
1938    fn analysis_is_off_until_it_is_asked_for_and_only_where_it_exists() {
1939        let (_guard, player) = install();
1940        assert!(set_media_analysis_enabled(true));
1941        assert!(player.calls().contains(&"analysis true".to_string()));
1942
1943        clear_platform_media_player();
1944        set_platform_media_player(FakePlayer::with(MediaCapabilities::TRANSPORT));
1945        assert!(!set_media_analysis_enabled(true));
1946    }
1947
1948    #[test]
1949    fn the_newest_sample_block_replaces_the_stored_one() {
1950        let (_guard, _player) = install();
1951        let first = MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block");
1952        let second = MediaSamples::new(48_000, 1, 2, vec![0.5; 8]).expect("block");
1953
1954        publish_media_samples(first);
1955        publish_media_samples(second.clone());
1956
1957        assert_eq!(latest_media_samples(), Some(second));
1958        record_dropped_media_samples();
1959        record_dropped_media_samples();
1960        assert_eq!(dropped_media_samples(), 2);
1961    }
1962
1963    #[test]
1964    fn turning_analysis_off_forgets_the_last_block() {
1965        let (_guard, _player) = install();
1966        publish_media_samples(MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block"));
1967
1968        assert!(set_media_analysis_enabled(false));
1969
1970        assert_eq!(latest_media_samples(), None);
1971    }
1972
1973    #[test]
1974    fn observers_stop_being_called_once_they_are_dropped() {
1975        let (_guard, _player) = install();
1976        let seen = Arc::new(Mutex::new(0usize));
1977        let recorder = Arc::clone(&seen);
1978        let observer = observe_playback_progress(move |_| *recorder.lock() += 1);
1979
1980        publish_playback_progress(PlaybackProgress::new(
1981            Duration::from_secs(1),
1982            Duration::from_secs(10),
1983        ));
1984        let delivered = *seen.lock();
1985        drop(observer);
1986        publish_playback_progress(PlaybackProgress::new(
1987            Duration::from_secs(2),
1988            Duration::from_secs(10),
1989        ));
1990
1991        assert_eq!(*seen.lock(), delivered);
1992    }
1993
1994    #[test]
1995    fn published_progress_never_reads_past_the_end() {
1996        let (_guard, _player) = install();
1997        publish_playback_progress(PlaybackProgress {
1998            position: Duration::from_secs(99),
1999            duration: Some(Duration::from_secs(10)),
2000            buffered: Duration::from_secs(99),
2001        });
2002
2003        let progress = playback_progress();
2004        assert_eq!(progress.position, Duration::from_secs(10));
2005        assert_eq!(progress.buffered, Duration::from_secs(10));
2006    }
2007
2008    #[test]
2009    fn playing_holds_the_runtime_awake_and_stopping_lets_it_sleep() {
2010        let (_guard, _player) = install();
2011        assert!(!holds_background_work());
2012
2013        open_media(track()).expect("opens");
2014        assert!(
2015            !holds_background_work(),
2016            "an item that is open but not playing is not work the runtime must keep turning for"
2017        );
2018
2019        play_media().expect("plays");
2020        assert!(holds_background_work());
2021
2022        pause_media();
2023        assert!(!holds_background_work());
2024
2025        play_media().expect("plays");
2026        assert!(holds_background_work());
2027        stop_media();
2028        assert!(!holds_background_work());
2029    }
2030
2031    #[test]
2032    fn a_destroyed_host_stops_playback_but_a_backgrounded_one_does_not() {
2033        let (_guard, _player) = install();
2034        open_media(track()).expect("opens");
2035        play_media().expect("plays");
2036
2037        on_lifecycle(LifecycleEvent {
2038            from: LifecycleState::Resumed,
2039            to: LifecycleState::Stopped,
2040        });
2041        assert!(playback_state().is_playing());
2042
2043        on_lifecycle(LifecycleEvent {
2044            from: LifecycleState::Stopped,
2045            to: LifecycleState::Destroyed,
2046        });
2047        assert_eq!(playback_state(), PlaybackState::Idle);
2048    }
2049
2050    #[test]
2051    fn metadata_learned_after_playback_started_reaches_the_session() {
2052        let (_guard, player) = install();
2053        open_media(MediaItem::new("file:///music/untagged.mp3")).expect("opens");
2054
2055        set_media_metadata(MediaMetadata::titled("Late Tag").artist("Artist"));
2056
2057        assert!(player.calls().contains(&"session Late Tag".to_string()));
2058        assert_eq!(
2059            current_media_item().map(|item| item.metadata.title),
2060            Some("Late Tag".to_string())
2061        );
2062    }
2063
2064    #[test]
2065    fn metadata_with_nothing_in_it_is_metadata_a_lock_screen_can_skip() {
2066        assert!(MediaMetadata::default().is_empty());
2067        assert!(!MediaMetadata::titled("Track").is_empty());
2068    }
2069
2070    #[test]
2071    fn a_path_survives_the_round_trip_through_a_uri() {
2072        let path = PathBuf::from("/music/Sgt. Pepper's #1.mp3");
2073        let uri = uri_for_path(&path);
2074
2075        assert_eq!(uri, "file:///music/Sgt.%20Pepper%27s%20%231.mp3");
2076        assert_eq!(path_from_uri(&uri), Some(path));
2077    }
2078
2079    #[test]
2080    fn a_windows_path_keeps_its_drive_letter() {
2081        let uri = uri_for_path(Path::new("C:\\Music\\track.mp3"));
2082
2083        assert_eq!(uri, "file:///C%3A/Music/track.mp3");
2084        assert_eq!(
2085            path_from_uri(&uri),
2086            Some(PathBuf::from("C:/Music/track.mp3"))
2087        );
2088    }
2089
2090    #[test]
2091    fn a_bare_path_is_accepted_as_itself() {
2092        assert_eq!(
2093            path_from_uri("/music/track.mp3"),
2094            Some(PathBuf::from("/music/track.mp3"))
2095        );
2096    }
2097
2098    #[test]
2099    fn anything_that_is_not_a_local_file_has_no_path() {
2100        assert_eq!(path_from_uri("https://host/stream.mp3"), None);
2101        assert_eq!(path_from_uri("content://media/audio/1"), None);
2102        assert_eq!(path_from_uri("blob:https://host/abc"), None);
2103        assert_eq!(path_from_uri("file://host/share/track.mp3"), None);
2104        assert_eq!(path_from_uri(""), None);
2105    }
2106
2107    #[test]
2108    fn a_truncated_escape_is_not_guessed_at() {
2109        assert_eq!(path_from_uri("file:///music/track%2"), None);
2110        assert_eq!(path_from_uri("file:///music/track%zz.mp3"), None);
2111    }
2112
2113    #[test]
2114    fn speed_and_looping_reach_a_backend_that_has_them() {
2115        let (_guard, player) = install();
2116        assert!(set_media_speed(1.5));
2117        set_media_looping(true);
2118
2119        let calls = player.calls();
2120        assert!(calls.contains(&"speed 1.5".to_string()));
2121        assert!(calls.contains(&"looping true".to_string()));
2122    }
2123}