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 std::{
34    fs::File,
35    path::{Path, PathBuf},
36    sync::{
37        Arc,
38        atomic::{AtomicBool, Ordering},
39    },
40    time::Duration,
41};
42
43use cranpose_core::{EventStream, State, rememberEventStream};
44use parking_lot::Mutex;
45use portable_atomic::AtomicU64;
46
47use crate::{
48    background::{BackgroundWorkLease, acquire_background_work},
49    host::{LifecycleEvent, LifecycleState},
50    registry::ServiceRegistry,
51};
52
53/// The gain applied while another app is being heard over this one.
54///
55/// Ducking rather than pausing is what the platforms ask for on a transient
56/// interruption that can share the output — a navigation prompt over music.
57pub const DUCKED_GAIN: f32 = 0.2;
58
59/// Artwork for the platform media session.
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct MediaArtwork {
62    /// The encoded image, in whatever the tag carried.
63    pub bytes: Arc<[u8]>,
64    /// The image's media type, `image/jpeg` and `image/png` being what tags
65    /// actually contain.
66    pub mime: String,
67}
68
69impl MediaArtwork {
70    /// Artwork from encoded bytes.
71    pub fn new(bytes: impl Into<Arc<[u8]>>, mime: impl Into<String>) -> MediaArtwork {
72        MediaArtwork {
73            bytes: bytes.into(),
74            mime: mime.into(),
75        }
76    }
77}
78
79/// What the platform media session shows: the lock screen, the notification,
80/// the car head unit.
81#[derive(Clone, Debug, Default, PartialEq, Eq)]
82pub struct MediaMetadata {
83    /// Title of the item, or empty for none.
84    pub title: String,
85    /// Artist or performer name, or empty for none.
86    pub artist: String,
87    /// Album name, or empty for none.
88    pub album: String,
89    /// The item's length when it is known before playback starts — from a tag,
90    /// or from a previous play. `None` means "ask the backend once it has
91    /// opened the item", which is what [`PlaybackProgress::duration`] reports.
92    pub duration: Option<Duration>,
93    /// Cover image of the item, or `None` for none.
94    pub artwork: Option<MediaArtwork>,
95}
96
97impl MediaMetadata {
98    /// Metadata carrying only a title, which is what a bare file name gives.
99    pub fn titled(title: impl Into<String>) -> MediaMetadata {
100        MediaMetadata {
101            title: title.into(),
102            ..MediaMetadata::default()
103        }
104    }
105
106    /// Sets the performer.
107    pub fn artist(mut self, artist: impl Into<String>) -> MediaMetadata {
108        self.artist = artist.into();
109        self
110    }
111
112    /// Sets the album.
113    pub fn album(mut self, album: impl Into<String>) -> MediaMetadata {
114        self.album = album.into();
115        self
116    }
117
118    /// Sets the length known ahead of playback.
119    pub fn duration(mut self, duration: Duration) -> MediaMetadata {
120        self.duration = Some(duration);
121        self
122    }
123
124    /// Sets the artwork.
125    pub fn artwork(mut self, artwork: MediaArtwork) -> MediaMetadata {
126        self.artwork = Some(artwork);
127        self
128    }
129
130    /// Whether there is anything worth showing on a lock screen.
131    pub fn is_empty(&self) -> bool {
132        self.title.is_empty() && self.artist.is_empty() && self.album.is_empty()
133    }
134}
135
136/// One playable item.
137///
138/// The source is a URI because that is the one form every platform media stack
139/// takes: `file:` and `content:` on Android, `file:` on desktop and iOS,
140/// `blob:` for a file the browser handed over, `http:` and `https:` everywhere.
141/// Handing the platform a URI is also what keeps a streamed item streaming
142/// instead of being read into memory first.
143#[derive(Clone, Debug, Default, PartialEq, Eq)]
144pub struct MediaItem {
145    /// The source of the item, such as `file:`, `content:`, `blob:`, `http:` or `https:`.
146    pub uri: String,
147    /// The title, artist and artwork that the platform media session shows for the item.
148    pub metadata: MediaMetadata,
149}
150
151impl MediaItem {
152    /// An item at `uri`, with no metadata yet.
153    pub fn new(uri: impl Into<String>) -> MediaItem {
154        MediaItem {
155            uri: uri.into(),
156            metadata: MediaMetadata::default(),
157        }
158    }
159
160    /// The same item with metadata attached.
161    pub fn with_metadata(mut self, metadata: MediaMetadata) -> MediaItem {
162        self.metadata = metadata;
163        self
164    }
165
166    /// The title, falling back to the last path segment of the URI so a screen
167    /// always has something to show.
168    pub fn display_title(&self) -> &str {
169        if !self.metadata.title.is_empty() {
170            return &self.metadata.title;
171        }
172        let path = self.uri.split(['?', '#']).next().unwrap_or(&self.uri);
173        match path.rsplit(['/', '\\']).next() {
174            Some(name) if !name.is_empty() => name,
175            _ => &self.uri,
176        }
177    }
178}
179
180/// What a platform media stack can actually do.
181///
182/// Reported rather than assumed: a screen greys out a speed control the device
183/// will not honour instead of offering one that silently does nothing.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
185pub struct MediaCapabilities {
186    /// Whether [`seek_media`] moves the position.
187    pub seeking: bool,
188    /// Whether [`set_media_speed`] changes the rate.
189    pub speed: bool,
190    /// Whether [`set_media_looping`] repeats the item when it ends.
191    pub looping: bool,
192    /// Whether the backend can publish [`MediaSamples`] while it plays.
193    pub analysis: bool,
194    /// Whether metadata reaches a platform media session — the lock screen,
195    /// the notification, the headset buttons.
196    pub session: bool,
197    /// Whether the backend has an equalizer. The bands it has are reported by
198    /// [`media_equalizer_bands`], because a platform effect has the bands its
199    /// implementation has rather than the ones a screen would like.
200    pub equalizer: bool,
201    /// Whether [`probe_media_duration`] can read an item's length without
202    /// playing it. A playlist that shows durations for entries nobody has
203    /// opened needs this; one that does not, does not.
204    pub probing: bool,
205}
206
207impl MediaCapabilities {
208    /// A backend that plays, pauses and seeks and does nothing else, which is
209    /// the floor for anything worth calling a media player.
210    pub const TRANSPORT: MediaCapabilities = MediaCapabilities {
211        seeking: true,
212        speed: false,
213        looping: true,
214        analysis: false,
215        session: false,
216        equalizer: false,
217        probing: false,
218    };
219}
220
221/// One frequency band of a backend's equalizer.
222#[derive(Clone, Copy, Debug, PartialEq)]
223pub struct EqualizerBand {
224    /// The frequency the band is centred on, in hertz.
225    pub center_hz: f32,
226    /// The most this band can cut, in decibels — a negative number.
227    pub min_gain_db: f32,
228    /// The most this band can lift, in decibels.
229    pub max_gain_db: f32,
230}
231
232impl EqualizerBand {
233    /// A band centred on `center_hz` with a symmetric range.
234    pub fn new(center_hz: f32, range_db: f32) -> EqualizerBand {
235        let range = range_db.abs();
236        EqualizerBand {
237            center_hz,
238            min_gain_db: -range,
239            max_gain_db: range,
240        }
241    }
242
243    /// Brings `gain_db` inside what this band can actually do.
244    pub fn clamp_gain(&self, gain_db: f32) -> f32 {
245        gain_db.clamp(self.min_gain_db, self.max_gain_db)
246    }
247}
248
249/// The octave centres a graphic equalizer is built on, in hertz.
250///
251/// The set a hardware graphic equalizer has had since long before software
252/// ones. A backend that builds its own filters — the desktop one, the browser
253/// one — reports these, so the same curve means the same thing on both. A
254/// platform effect reports whatever bands its implementation has instead.
255pub const OCTAVE_BAND_CENTERS_HZ: [f32; 10] = [
256    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,
257];
258
259/// [`OCTAVE_BAND_CENTERS_HZ`] as bands, each able to lift or cut by `range_db`.
260pub fn octave_equalizer_bands(range_db: f32) -> Vec<EqualizerBand> {
261    OCTAVE_BAND_CENTERS_HZ
262        .iter()
263        .map(|center| EqualizerBand::new(*center, range_db))
264        .collect()
265}
266
267/// What an equalizer is set to.
268///
269/// `gains_db` is read alongside the bands [`media_equalizer_bands`] reported:
270/// entry `n` is band `n`. A shorter list leaves the remaining bands flat, and a
271/// longer one is truncated, so a screen built for one band layout still says
272/// something sensible on a device with another.
273#[derive(Clone, Debug, Default, PartialEq)]
274pub struct EqualizerSettings {
275    /// Whether the equalizer is in circuit at all. A flat, disabled equalizer
276    /// is not the same as a flat, enabled one: the disabled one costs nothing.
277    pub enabled: bool,
278    /// Gain applied ahead of the bands, in decibels.
279    pub preamp_db: f32,
280    /// Per-band gain in decibels, in the order the bands were reported.
281    pub gains_db: Vec<f32>,
282}
283
284impl EqualizerSettings {
285    /// An enabled equalizer with every band flat.
286    pub fn flat(bands: usize) -> EqualizerSettings {
287        EqualizerSettings {
288            enabled: true,
289            preamp_db: 0.0,
290            gains_db: vec![0.0; bands],
291        }
292    }
293
294    /// This setting with every gain brought inside what `bands` can do, and
295    /// its length matched to theirs.
296    pub fn clamped_to(&self, bands: &[EqualizerBand]) -> EqualizerSettings {
297        EqualizerSettings {
298            enabled: self.enabled,
299            preamp_db: self.preamp_db,
300            gains_db: bands
301                .iter()
302                .enumerate()
303                .map(|(index, band)| {
304                    band.clamp_gain(self.gains_db.get(index).copied().unwrap_or(0.0))
305                })
306                .collect(),
307        }
308    }
309}
310
311/// Why a media call failed.
312#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
313pub enum MediaError {
314    /// No media backend on this platform.
315    #[error("media playback is not supported here")]
316    Unsupported,
317    /// The backend cannot open this URI — an unknown scheme, a codec it has
318    /// no decoder for, a file that is not there.
319    #[error("cannot play {0}")]
320    UnsupportedSource(String),
321    /// A transport call arrived before anything was opened.
322    #[error("no media item is loaded")]
323    NothingLoaded,
324    /// The backend has no seek for this item — a live stream, or a container
325    /// without an index.
326    #[error("this item cannot be seeked")]
327    NotSeekable,
328    /// Any other failure the platform reported.
329    #[error("{0}")]
330    Failed(String),
331}
332
333/// What the player is doing.
334#[derive(Clone, Debug, Default, PartialEq, Eq)]
335pub enum PlaybackState {
336    /// Nothing is open.
337    #[default]
338    Idle,
339    /// An item is opening or refilling its buffer. A separate state rather
340    /// than a gap, because opening a network item takes long enough that a
341    /// screen has to say so.
342    Loading,
343    /// Sound is coming out.
344    Playing,
345    /// An item is open and positioned, and stopped.
346    Paused,
347    /// The item played to its end. Distinct from [`Paused`](Self::Paused):
348    /// this is what advances a playlist.
349    Ended,
350    /// The item could not be played, or playback ended in a failure.
351    Failed(MediaError),
352}
353
354impl PlaybackState {
355    /// Whether sound is coming out now.
356    pub fn is_playing(&self) -> bool {
357        matches!(self, PlaybackState::Playing)
358    }
359
360    /// Whether an item is open — playing, paused, or still opening.
361    pub fn is_active(&self) -> bool {
362        matches!(
363            self,
364            PlaybackState::Loading | PlaybackState::Playing | PlaybackState::Paused
365        )
366    }
367
368    /// The failure, if playback ended in one.
369    pub fn failure(&self) -> Option<&MediaError> {
370        match self {
371            PlaybackState::Failed(error) => Some(error),
372            _ => None,
373        }
374    }
375}
376
377/// Where the open item is.
378#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
379pub struct PlaybackProgress {
380    /// How far in the item playback has reached.
381    pub position: Duration,
382    /// The item's length, or `None` for a stream that has none.
383    pub duration: Option<Duration>,
384    /// How far ahead of the position the buffer reaches. Equal to `duration`
385    /// for a local file, which is what makes a local file's buffer bar full.
386    pub buffered: Duration,
387}
388
389impl PlaybackProgress {
390    /// Progress through an item of known length.
391    pub fn new(position: Duration, duration: Duration) -> PlaybackProgress {
392        PlaybackProgress {
393            position: position.min(duration),
394            duration: Some(duration),
395            buffered: duration,
396        }
397    }
398
399    /// How far through the item this is, or `None` when it has no length.
400    pub fn fraction(&self) -> Option<f32> {
401        let duration = self.duration?;
402        if duration.is_zero() {
403            return Some(0.0);
404        }
405        Some((self.position.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
406    }
407
408    /// How much of the item is buffered, or `None` when it has no length.
409    pub fn buffered_fraction(&self) -> Option<f32> {
410        let duration = self.duration?;
411        if duration.is_zero() {
412            return Some(0.0);
413        }
414        Some((self.buffered.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
415    }
416}
417
418/// A button pressed somewhere the application does not draw: a lock screen, a
419/// notification, a headset, a car.
420#[derive(Clone, Copy, Debug, PartialEq, Eq)]
421pub enum MediaCommand {
422    /// Starts or resumes playback.
423    Play,
424    /// Pauses playback.
425    Pause,
426    /// The one button a headset has.
427    TogglePlayPause,
428    /// Stops playback and closes the item.
429    Stop,
430    /// Needs a playlist, so it is reported and not carried out.
431    Next,
432    /// Needs a playlist, so it is reported and not carried out.
433    Previous,
434    /// Moves playback to the given position from the start of the item.
435    SeekTo(Duration),
436}
437
438impl MediaCommand {
439    /// Whether this command is one the framework carries out itself.
440    ///
441    /// The transport is player state and lives here. [`Next`](Self::Next) and
442    /// [`Previous`](Self::Previous) need an order the framework does not have,
443    /// so they are only reported.
444    pub fn is_transport(self) -> bool {
445        !matches!(self, MediaCommand::Next | MediaCommand::Previous)
446    }
447}
448
449/// What the rest of the device is doing with the output.
450#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
451pub enum AudioFocus {
452    /// This app may be heard at its own volume.
453    #[default]
454    Gained,
455    /// Something short is being said over the top — a navigation prompt.
456    /// Playback continues at [`DUCKED_GAIN`].
457    Ducked,
458    /// Something else has the output for a moment — a call, another player.
459    /// Playback pauses and resumes on the next [`Gained`](Self::Gained).
460    LostTransient,
461    /// Something else has the output for good. Playback stops and does not
462    /// come back on its own.
463    Lost,
464}
465
466/// Samples as they are being heard, for a visualiser.
467#[derive(Clone, Debug, PartialEq)]
468pub struct MediaSamples {
469    /// Samples per second per channel.
470    pub sample_rate: u32,
471    /// How many channels are interleaved in [`samples`](Self::samples).
472    pub channels: u16,
473    /// Interleaved samples, nominally in `[-1, 1]`.
474    pub samples: Arc<[f32]>,
475    /// Which block this is, so a visualiser can tell a repeat from a new one
476    /// and count what it missed.
477    pub sequence: u64,
478}
479
480impl MediaSamples {
481    /// A block of samples, or `None` when the layout does not describe the
482    /// data — which reads as a broken visualiser rather than as an error if it
483    /// is let through.
484    pub fn new(
485        sample_rate: u32,
486        channels: u16,
487        sequence: u64,
488        samples: impl Into<Arc<[f32]>>,
489    ) -> Option<MediaSamples> {
490        let samples = samples.into();
491        if sample_rate == 0 || channels == 0 || samples.len() % channels as usize != 0 {
492            return None;
493        }
494        Some(MediaSamples {
495            sample_rate,
496            channels,
497            samples,
498            sequence,
499        })
500    }
501
502    /// How many samples there are per channel.
503    pub fn frames(&self) -> usize {
504        self.samples.len() / self.channels.max(1) as usize
505    }
506
507    /// How long this block lasts.
508    pub fn span(&self) -> Duration {
509        if self.sample_rate == 0 {
510            return Duration::ZERO;
511        }
512        Duration::from_secs_f64(self.frames() as f64 / self.sample_rate as f64)
513    }
514}
515
516/// A platform media stack.
517///
518/// A backend opens items, drives the transport, and publishes what happens
519/// through [`publish_playback_state`], [`publish_playback_progress`],
520/// [`publish_audio_focus`], [`publish_media_command`] and
521/// [`publish_media_samples`]. Nothing here is polled, and no method blocks for
522/// the length of an item.
523///
524/// Applications call the free functions — [`open_media`], [`play_media`],
525/// [`seek_media`] — rather than this trait: the free functions are where volume
526/// is combined with the focus gain, where the background-work lease is held,
527/// and where a seek is clamped to the item.
528pub trait MediaPlayer: Send + Sync {
529    /// What this backend can do. Read by screens to decide which controls
530    /// exist at all.
531    fn capabilities(&self) -> MediaCapabilities;
532
533    /// Opens `item` and gets it ready to play, without playing it.
534    ///
535    /// Returns as soon as the request is accepted; the item's progress arrives
536    /// as [`PlaybackState`], because opening a network item takes as long as
537    /// the network does.
538    fn prepare(&self, item: &MediaItem) -> Result<(), MediaError>;
539
540    /// Starts, or resumes, the open item.
541    fn play(&self) -> Result<(), MediaError>;
542
543    /// Stops without giving up the position.
544    fn pause(&self);
545
546    /// Stops, closes the item and releases the output device.
547    fn stop(&self);
548
549    /// Moves the position within the open item.
550    fn seek_to(&self, _position: Duration) -> Result<(), MediaError> {
551        Err(MediaError::NotSeekable)
552    }
553
554    /// Sets the output gain, already combined with the audio-focus gain by
555    /// [`set_media_volume`]. `0.0` is silent, `1.0` is the item as recorded.
556    fn set_volume(&self, volume: f32);
557
558    /// Sets stereo balance: -1 is left, 0 is centered, and 1 is right.
559    /// The quieter channel is attenuated without mixing channels. Returns
560    /// false when the active backend cannot apply balance.
561    fn set_balance(&self, _balance: f32) -> bool {
562        false
563    }
564
565    /// Sets the playback rate, `1.0` being as recorded. Returns `false` where
566    /// the backend does not have one.
567    fn set_speed(&self, _speed: f32) -> bool {
568        false
569    }
570
571    /// Repeats the open item when it reaches its end.
572    fn set_looping(&self, _looping: bool) {}
573
574    /// Starts or stops publishing [`MediaSamples`]. Returns `false` where the
575    /// backend cannot produce them, which is also what
576    /// [`MediaCapabilities::analysis`] reports.
577    fn set_analysis_enabled(&self, _enabled: bool) -> bool {
578        false
579    }
580
581    /// Hands metadata to the platform media session. Called again whenever the
582    /// application learns more about the open item, because tags are often
583    /// parsed after playback has already started.
584    fn set_session_metadata(&self, _metadata: &MediaMetadata) {}
585
586    /// The equalizer bands this backend has, centre frequency and range.
587    ///
588    /// Empty where there is no equalizer, which is also what
589    /// [`MediaCapabilities::equalizer`] reports. A backend states its real
590    /// bands: a platform effect has the ones its implementation has, and a
591    /// screen that wants a different layout maps onto these rather than being
592    /// told a layout that is not there.
593    fn equalizer_bands(&self) -> Vec<EqualizerBand> {
594        Vec::new()
595    }
596
597    /// Applies an equalizer setting, already clamped to this backend's bands.
598    fn set_equalizer(&self, _settings: &EqualizerSettings) {}
599
600    /// The audio file extensions this backend can decode, lower case and
601    /// without the dot.
602    ///
603    /// An application that picks tracks off a disk decides what to offer from
604    /// this rather than from a list of its own. Which formats play is a
605    /// property of the stack underneath — the platform's decoders on a phone,
606    /// the ones compiled in on a desktop — and a list written next to the
607    /// picker is a claim about a backend it never asks. It goes stale the
608    /// moment the backend changes, and the failure is quiet: the tracks import
609    /// and then refuse to play.
610    ///
611    /// Empty where the backend cannot say, which a caller should read as "no
612    /// opinion, offer what you like" rather than as "nothing plays".
613    fn audio_extensions(&self) -> Vec<&'static str> {
614        Vec::new()
615    }
616
617    /// Reads how long `item` is without opening it for playback.
618    ///
619    /// A playlist shows the length of entries nobody has played yet, and the
620    /// only thing that can answer is the stack that reads the container.
621    /// `None` where this backend cannot tell, which is also what
622    /// [`MediaCapabilities::probing`] reports; a screen leaves the duration
623    /// blank rather than treating it as an error.
624    fn probe_duration(&self, _item: &MediaItem) -> Option<Duration> {
625        None
626    }
627}
628
629/// Shared handle to the platform media player.
630pub type MediaPlayerRef = Arc<dyn MediaPlayer>;
631
632static PLATFORM_MEDIA: ServiceRegistry<dyn MediaPlayer> = ServiceRegistry::new();
633
634/// Installs the platform media player, replacing any previous one.
635pub fn set_platform_media_player(player: MediaPlayerRef) {
636    PLATFORM_MEDIA.set(player);
637}
638
639/// Removes the platform media player and forgets everything it published.
640pub fn clear_platform_media_player() {
641    if let Some(player) = PLATFORM_MEDIA.get() {
642        player.stop();
643    }
644    PLATFORM_MEDIA.clear();
645    STATE_OBSERVERS.clear();
646    PROGRESS_OBSERVERS.clear();
647    COMMAND_OBSERVERS.clear();
648    FOCUS_OBSERVERS.clear();
649    SAMPLE_OBSERVERS.clear();
650    *STATE.lock() = PlaybackState::Idle;
651    *PROGRESS.lock() = PlaybackProgress::default();
652    *CURRENT_ITEM.lock() = None;
653    *LATEST_SAMPLES.lock() = None;
654    *FOCUS.lock() = AudioFocus::Gained;
655    *VOLUME.lock() = 1.0;
656    PAUSED_BY_FOCUS.store(false, Ordering::Release);
657    DROPPED_SAMPLES.store(0, Ordering::Release);
658    release_background_lease();
659}
660
661/// The installed media player, or `None` where this platform has none.
662pub fn media_player() -> Option<MediaPlayerRef> {
663    PLATFORM_MEDIA.get()
664}
665
666/// Whether this platform can play media at all.
667pub fn media_playback_supported() -> bool {
668    PLATFORM_MEDIA.get().is_some()
669}
670
671/// What the installed backend can do, or [`MediaCapabilities::default`] — every
672/// capability absent — when there is none.
673pub fn media_capabilities() -> MediaCapabilities {
674    media_player()
675        .map(|player| player.capabilities())
676        .unwrap_or_default()
677}
678
679static STATE: Mutex<PlaybackState> = Mutex::new(PlaybackState::Idle);
680static PROGRESS: Mutex<PlaybackProgress> = Mutex::new(PlaybackProgress {
681    position: Duration::ZERO,
682    duration: None,
683    buffered: Duration::ZERO,
684});
685static CURRENT_ITEM: Mutex<Option<MediaItem>> = Mutex::new(None);
686static LATEST_SAMPLES: Mutex<Option<MediaSamples>> = Mutex::new(None);
687static FOCUS: Mutex<AudioFocus> = Mutex::new(AudioFocus::Gained);
688static VOLUME: Mutex<f32> = Mutex::new(1.0);
689static EQUALIZER: Mutex<EqualizerSettings> = Mutex::new(EqualizerSettings {
690    enabled: false,
691    preamp_db: 0.0,
692    gains_db: Vec::new(),
693});
694static PAUSED_BY_FOCUS: AtomicBool = AtomicBool::new(false);
695
696static DROPPED_SAMPLES: AtomicU64 = AtomicU64::new(0);
697
698/// What the player is doing.
699pub fn playback_state() -> PlaybackState {
700    STATE.lock().clone()
701}
702
703/// Where the open item is.
704///
705/// Read outside composition — while a seek bar is being dragged, or during
706/// draw — so a moving position costs no recomposition.
707pub fn playback_progress() -> PlaybackProgress {
708    *PROGRESS.lock()
709}
710
711/// The open item, or `None` when nothing is.
712pub fn current_media_item() -> Option<MediaItem> {
713    CURRENT_ITEM.lock().clone()
714}
715
716/// The last block of samples, or `None` when analysis is off or nothing has
717/// played yet. Read during draw, so a visualiser never draws a stale block.
718pub fn latest_media_samples() -> Option<MediaSamples> {
719    LATEST_SAMPLES.lock().clone()
720}
721
722/// How many sample blocks were produced while every observer was still busy.
723pub fn dropped_media_samples() -> u64 {
724    DROPPED_SAMPLES.load(Ordering::Acquire)
725}
726
727/// What the rest of the device is doing with the output.
728pub fn audio_focus() -> AudioFocus {
729    *FOCUS.lock()
730}
731
732/// The volume the application asked for, before the audio-focus gain.
733pub fn media_volume() -> f32 {
734    *VOLUME.lock()
735}
736
737struct ObserverList<T: ?Sized> {
738    entries: Mutex<Vec<(u64, Arc<T>)>>,
739}
740
741impl<T: ?Sized> ObserverList<T> {
742    const fn new() -> ObserverList<T> {
743        ObserverList {
744            entries: Mutex::new(Vec::new()),
745        }
746    }
747
748    fn add(&self, observer: Arc<T>) -> u64 {
749        let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed);
750        self.entries.lock().push((id, observer));
751        id
752    }
753
754    fn remove(&self, id: u64) {
755        self.entries.lock().retain(|(entry, _)| *entry != id);
756    }
757
758    fn snapshot(&self) -> Vec<Arc<T>> {
759        self.entries
760            .lock()
761            .iter()
762            .map(|(_, observer)| Arc::clone(observer))
763            .collect()
764    }
765
766    fn clear(&self) {
767        self.entries.lock().clear();
768    }
769}
770
771static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1);
772
773type StateObserverFn = dyn Fn(PlaybackState) + Send + Sync;
774type ProgressObserverFn = dyn Fn(PlaybackProgress) + Send + Sync;
775type CommandObserverFn = dyn Fn(MediaCommand) + Send + Sync;
776type FocusObserverFn = dyn Fn(AudioFocus) + Send + Sync;
777type SampleObserverFn = dyn Fn(MediaSamples) + Send + Sync;
778
779static STATE_OBSERVERS: ObserverList<StateObserverFn> = ObserverList::new();
780static PROGRESS_OBSERVERS: ObserverList<ProgressObserverFn> = ObserverList::new();
781static COMMAND_OBSERVERS: ObserverList<CommandObserverFn> = ObserverList::new();
782static FOCUS_OBSERVERS: ObserverList<FocusObserverFn> = ObserverList::new();
783static SAMPLE_OBSERVERS: ObserverList<SampleObserverFn> = ObserverList::new();
784
785/// Keeps a media observer registered until it is dropped.
786pub struct MediaObserver {
787    id: u64,
788    remove: fn(u64),
789}
790
791impl Drop for MediaObserver {
792    fn drop(&mut self) {
793        (self.remove)(self.id);
794    }
795}
796
797/// Registers `observer` for playback state. The current state is delivered at
798/// once, so a screen composed mid-item shows what is happening rather than
799/// waiting for the next change.
800pub fn observe_playback_state(
801    observer: impl Fn(PlaybackState) + Send + Sync + 'static,
802) -> MediaObserver {
803    let observer: Arc<StateObserverFn> = Arc::new(observer);
804    let id = STATE_OBSERVERS.add(Arc::clone(&observer));
805    observer(playback_state());
806    MediaObserver {
807        id,
808        remove: |id| STATE_OBSERVERS.remove(id),
809    }
810}
811
812/// Registers `observer` for position updates. The current position is
813/// delivered at once.
814pub fn observe_playback_progress(
815    observer: impl Fn(PlaybackProgress) + Send + Sync + 'static,
816) -> MediaObserver {
817    let observer: Arc<ProgressObserverFn> = Arc::new(observer);
818    let id = PROGRESS_OBSERVERS.add(Arc::clone(&observer));
819    observer(playback_progress());
820    MediaObserver {
821        id,
822        remove: |id| PROGRESS_OBSERVERS.remove(id),
823    }
824}
825
826/// Registers `observer` for media-session commands.
827pub fn observe_media_commands(
828    observer: impl Fn(MediaCommand) + Send + Sync + 'static,
829) -> MediaObserver {
830    let id = COMMAND_OBSERVERS.add(Arc::new(observer));
831    MediaObserver {
832        id,
833        remove: |id| COMMAND_OBSERVERS.remove(id),
834    }
835}
836
837/// Registers `observer` for audio-focus changes. The current focus is
838/// delivered at once.
839pub fn observe_audio_focus(observer: impl Fn(AudioFocus) + Send + Sync + 'static) -> MediaObserver {
840    let observer: Arc<FocusObserverFn> = Arc::new(observer);
841    let id = FOCUS_OBSERVERS.add(Arc::clone(&observer));
842    observer(audio_focus());
843    MediaObserver {
844        id,
845        remove: |id| FOCUS_OBSERVERS.remove(id),
846    }
847}
848
849/// Registers `observer` for analysis samples.
850pub fn observe_media_samples(
851    observer: impl Fn(MediaSamples) + Send + Sync + 'static,
852) -> MediaObserver {
853    let id = SAMPLE_OBSERVERS.add(Arc::new(observer));
854    MediaObserver {
855        id,
856        remove: |id| SAMPLE_OBSERVERS.remove(id),
857    }
858}
859
860/// Publishes what the player is doing.
861///
862/// This is also where the background-work lease is taken and given up: an app
863/// that is playing has work the runtime must keep turning for even with its
864/// surface gone, and an app that has stopped does not.
865pub fn publish_playback_state(state: PlaybackState) {
866    {
867        let mut current = STATE.lock();
868        if *current == state {
869            return;
870        }
871        *current = state.clone();
872    }
873    if state.is_playing() {
874        acquire_background_lease();
875    } else {
876        release_background_lease();
877    }
878    if !state.is_active() {
879        *PROGRESS.lock() = PlaybackProgress::default();
880        *LATEST_SAMPLES.lock() = None;
881    }
882    if matches!(state, PlaybackState::Idle) {
883        *CURRENT_ITEM.lock() = None;
884        DROPPED_SAMPLES.store(0, Ordering::Release);
885    }
886    for observer in STATE_OBSERVERS.snapshot() {
887        observer(state.clone());
888    }
889}
890
891/// Publishes where the open item is. Backends call this as the position moves,
892/// which for a local file is a handful of times a second.
893pub fn publish_playback_progress(progress: PlaybackProgress) {
894    let progress = clamp_progress(progress);
895    {
896        let mut current = PROGRESS.lock();
897        if *current == progress {
898            return;
899        }
900        *current = progress;
901    }
902    for observer in PROGRESS_OBSERVERS.snapshot() {
903        observer(progress);
904    }
905}
906
907fn clamp_progress(mut progress: PlaybackProgress) -> PlaybackProgress {
908    if let Some(duration) = progress.duration {
909        progress.position = progress.position.min(duration);
910        progress.buffered = progress.buffered.min(duration);
911    }
912    progress
913}
914
915/// Publishes a button pressed outside the application's own UI.
916///
917/// The transport commands are carried out here before observers are told, so an
918/// application that only wants to advance its playlist has nothing to wire up:
919/// it collects [`rememberMediaCommands`] and reacts to
920/// [`MediaCommand::Next`] and [`MediaCommand::Previous`].
921pub fn publish_media_command(command: MediaCommand) {
922    match command {
923        MediaCommand::Play => {
924            let _ = play_media();
925        }
926        MediaCommand::Pause => pause_media(),
927        MediaCommand::TogglePlayPause => toggle_media(),
928        MediaCommand::Stop => stop_media(),
929        MediaCommand::SeekTo(position) => {
930            let _ = seek_media(position);
931        }
932        MediaCommand::Next | MediaCommand::Previous => {}
933    }
934    for observer in COMMAND_OBSERVERS.snapshot() {
935        observer(command);
936    }
937}
938
939/// Publishes what the rest of the device is doing with the output, and applies
940/// the policy that goes with it.
941///
942/// The policy is the whole point of this living in the framework:
943///
944/// * [`Ducked`](AudioFocus::Ducked) lowers the gain to [`DUCKED_GAIN`] and
945///   keeps playing; regaining focus puts the application's own volume back,
946///   whatever it changed to in the meantime.
947/// * [`LostTransient`](AudioFocus::LostTransient) pauses **and remembers that
948///   it did**, so the next [`Gained`](AudioFocus::Gained) resumes — and a
949///   [`Gained`](AudioFocus::Gained) that follows a user's own pause does not.
950/// * [`Lost`](AudioFocus::Lost) stops and forgets, because focus lost for good
951///   does not come back.
952pub fn publish_audio_focus(focus: AudioFocus) {
953    {
954        let mut current = FOCUS.lock();
955        if *current == focus {
956            return;
957        }
958        *current = focus;
959    }
960    apply_volume();
961    match focus {
962        AudioFocus::Gained => {
963            if PAUSED_BY_FOCUS.swap(false, Ordering::AcqRel) {
964                let _ = play_media();
965            }
966        }
967        AudioFocus::Ducked => {}
968        AudioFocus::LostTransient => {
969            if playback_state().is_playing() {
970                PAUSED_BY_FOCUS.store(true, Ordering::Release);
971                pause_media();
972            }
973        }
974        AudioFocus::Lost => {
975            PAUSED_BY_FOCUS.store(false, Ordering::Release);
976            stop_media();
977        }
978    }
979    for observer in FOCUS_OBSERVERS.snapshot() {
980        observer(focus);
981    }
982}
983
984/// Publishes a block of samples as it is heard.
985///
986/// The newest block always replaces the stored one, so a visualiser drawing
987/// [`latest_media_samples`] never draws a stale one; observers that keep up see
988/// every block, and blocks nobody could take are counted in
989/// [`dropped_media_samples`] rather than queued behind.
990pub fn publish_media_samples(samples: MediaSamples) {
991    *LATEST_SAMPLES.lock() = Some(samples.clone());
992    let observers = SAMPLE_OBSERVERS.snapshot();
993    if observers.is_empty() {
994        return;
995    }
996    for observer in observers {
997        observer(samples.clone());
998    }
999}
1000
1001/// Records that the backend produced a block nobody could take.
1002pub fn record_dropped_media_samples() {
1003    DROPPED_SAMPLES.fetch_add(1, Ordering::AcqRel);
1004}
1005
1006/// Opens `item`, publishing [`PlaybackState::Loading`] before the backend is
1007/// asked so a screen shows the wait rather than a gap.
1008///
1009/// The item is not played: an application that wants it to start calls
1010/// [`play_media`] when the backend publishes [`PlaybackState::Paused`], or
1011/// simply calls it straight away — a backend queues the request against the
1012/// item it is opening.
1013pub fn open_media(item: MediaItem) -> Result<(), MediaError> {
1014    *LATEST_SAMPLES.lock() = None;
1015    let Some(player) = media_player() else {
1016        publish_playback_state(PlaybackState::Failed(MediaError::Unsupported));
1017        return Err(MediaError::Unsupported);
1018    };
1019    PAUSED_BY_FOCUS.store(false, Ordering::Release);
1020    DROPPED_SAMPLES.store(0, Ordering::Release);
1021    *CURRENT_ITEM.lock() = Some(item.clone());
1022    publish_playback_progress(PlaybackProgress {
1023        position: Duration::ZERO,
1024        duration: item.metadata.duration,
1025        buffered: Duration::ZERO,
1026    });
1027    publish_playback_state(PlaybackState::Loading);
1028    if player.capabilities().session {
1029        player.set_session_metadata(&item.metadata);
1030    }
1031    player.prepare(&item).inspect_err(|error| {
1032        publish_playback_state(PlaybackState::Failed(error.clone()));
1033    })
1034}
1035
1036/// Starts, or resumes, the open item.
1037pub fn play_media() -> Result<(), MediaError> {
1038    let Some(player) = media_player() else {
1039        return Err(MediaError::Unsupported);
1040    };
1041    if CURRENT_ITEM.lock().is_none() {
1042        return Err(MediaError::NothingLoaded);
1043    }
1044    player.play().inspect_err(|error| {
1045        publish_playback_state(PlaybackState::Failed(error.clone()));
1046    })
1047}
1048
1049/// Stops without giving up the position.
1050pub fn pause_media() {
1051    if let Some(player) = media_player() {
1052        player.pause();
1053    }
1054}
1055
1056/// Stops, closes the item and releases the output device.
1057pub fn stop_media() {
1058    PAUSED_BY_FOCUS.store(false, Ordering::Release);
1059    if let Some(player) = media_player() {
1060        player.stop();
1061    }
1062    publish_playback_state(PlaybackState::Idle);
1063}
1064
1065/// Pauses what is playing and plays what is paused — the one button a headset
1066/// has, and the space bar.
1067pub fn toggle_media() {
1068    if playback_state().is_playing() {
1069        pause_media();
1070    } else {
1071        let _ = play_media();
1072    }
1073}
1074
1075/// Moves the position within the open item.
1076///
1077/// Clamped to the item's length here rather than in every backend, because a
1078/// seek past the end means different things to different platform stacks and
1079/// none of them mean what the seek bar meant.
1080pub fn seek_media(position: Duration) -> Result<(), MediaError> {
1081    let Some(player) = media_player() else {
1082        return Err(MediaError::Unsupported);
1083    };
1084    if CURRENT_ITEM.lock().is_none() {
1085        return Err(MediaError::NothingLoaded);
1086    }
1087    if !player.capabilities().seeking {
1088        return Err(MediaError::NotSeekable);
1089    }
1090    let position = match playback_progress().duration {
1091        Some(duration) => position.min(duration),
1092        None => position,
1093    };
1094    player.seek_to(position)
1095}
1096
1097/// Moves the position to a fraction of the item, which is what a seek bar has.
1098///
1099/// Reports [`MediaError::NotSeekable`] for an item with no length, because a
1100/// fraction of an unknown length is not a position.
1101pub fn seek_media_fraction(fraction: f32) -> Result<(), MediaError> {
1102    let Some(duration) = playback_progress().duration else {
1103        return Err(MediaError::NotSeekable);
1104    };
1105    let fraction = fraction.clamp(0.0, 1.0) as f64;
1106    seek_media(Duration::from_secs_f64(duration.as_secs_f64() * fraction))
1107}
1108
1109/// Sets the volume the application asks for, `1.0` being the item as recorded.
1110///
1111/// What reaches the device is this combined with the audio-focus gain, so an
1112/// application may set its volume freely while another app is being heard over
1113/// the top without undoing the duck.
1114pub fn set_media_volume(volume: f32) {
1115    *VOLUME.lock() = volume.clamp(0.0, 1.0);
1116    apply_volume();
1117}
1118
1119/// Applies stereo balance to the installed player. Values outside -1..=1
1120/// are clamped; non-finite values are rejected without changing playback.
1121/// Returns false if the player cannot apply balance.
1122pub fn set_media_balance(balance: f32) -> bool {
1123    balance.is_finite()
1124        && media_player().is_some_and(|player| player.set_balance(balance.clamp(-1.0, 1.0)))
1125}
1126
1127fn apply_volume() {
1128    let Some(player) = media_player() else {
1129        return;
1130    };
1131    let gain = match audio_focus() {
1132        AudioFocus::Ducked => DUCKED_GAIN,
1133        _ => 1.0,
1134    };
1135    player.set_volume(media_volume() * gain);
1136}
1137
1138/// Sets the playback rate, `1.0` being as recorded. Returns `false` where the
1139/// backend has none — see [`MediaCapabilities::speed`].
1140pub fn set_media_speed(speed: f32) -> bool {
1141    match media_player() {
1142        Some(player) if player.capabilities().speed => player.set_speed(speed),
1143        _ => false,
1144    }
1145}
1146
1147/// Repeats the open item when it reaches its end.
1148pub fn set_media_looping(looping: bool) {
1149    if let Some(player) = media_player() {
1150        player.set_looping(looping);
1151    }
1152}
1153
1154/// Starts or stops publishing [`MediaSamples`]. Returns `false` where the
1155/// backend cannot produce them — see [`MediaCapabilities::analysis`].
1156///
1157/// Off by default: producing samples costs the platform work on every block,
1158/// and a screen with no visualiser on it should not pay for one.
1159pub fn set_media_analysis_enabled(enabled: bool) -> bool {
1160    match media_player() {
1161        Some(player) if player.capabilities().analysis => {
1162            if !enabled {
1163                *LATEST_SAMPLES.lock() = None;
1164            }
1165            player.set_analysis_enabled(enabled)
1166        }
1167        _ => {
1168            *LATEST_SAMPLES.lock() = None;
1169            false
1170        }
1171    }
1172}
1173
1174/// Reads how long `item` is without playing it.
1175///
1176/// `None` where no backend is installed or the installed one cannot tell —
1177/// see [`MediaCapabilities::probing`].
1178pub fn probe_media_duration(item: &MediaItem) -> Option<Duration> {
1179    media_player()?.probe_duration(item)
1180}
1181
1182/// The equalizer bands this platform has, in the order gains are given in.
1183///
1184/// Empty where there is no equalizer. A screen reads this to know how many
1185/// controls to draw and what to label them, rather than assuming a layout.
1186pub fn media_equalizer_bands() -> Vec<EqualizerBand> {
1187    match media_player() {
1188        Some(player) if player.capabilities().equalizer => player.equalizer_bands(),
1189        _ => Vec::new(),
1190    }
1191}
1192
1193/// The audio file extensions the platform backend can decode, lower case and
1194/// without the dot.
1195///
1196/// Empty where there is no backend, or where the backend has no opinion. See
1197/// [`MediaPlayer::audio_extensions`].
1198pub fn media_audio_extensions() -> Vec<&'static str> {
1199    media_player()
1200        .map(|player| player.audio_extensions())
1201        .unwrap_or_default()
1202}
1203
1204/// The equalizer setting last applied.
1205pub fn media_equalizer() -> EqualizerSettings {
1206    EQUALIZER.lock().clone()
1207}
1208
1209/// Applies an equalizer setting, clamped to what the platform's bands can do.
1210///
1211/// Returns `false` where there is no equalizer — see
1212/// [`MediaCapabilities::equalizer`]. The setting is remembered either way, so a
1213/// screen that stores a user's curve reads back what the user chose rather than
1214/// what a device happened to support.
1215pub fn set_media_equalizer(settings: EqualizerSettings) -> bool {
1216    *EQUALIZER.lock() = settings.clone();
1217    let Some(player) = media_player() else {
1218        return false;
1219    };
1220    if !player.capabilities().equalizer {
1221        return false;
1222    }
1223    player.set_equalizer(&settings.clamped_to(&player.equalizer_bands()));
1224    true
1225}
1226
1227/// Updates the metadata shown by the platform media session for the open item.
1228///
1229/// Called when tags finish parsing, which is usually after playback started.
1230pub fn set_media_metadata(metadata: MediaMetadata) {
1231    {
1232        let mut item = CURRENT_ITEM.lock();
1233        let Some(item) = item.as_mut() else {
1234            return;
1235        };
1236        item.metadata = metadata.clone();
1237    }
1238    if let Some(player) = media_player()
1239        && player.capabilities().session
1240    {
1241        player.set_session_metadata(&metadata);
1242    }
1243}
1244
1245static BACKGROUND_LEASE: Mutex<Option<BackgroundWorkLease>> = Mutex::new(None);
1246
1247fn acquire_background_lease() {
1248    let mut lease = BACKGROUND_LEASE.lock();
1249    if lease.is_none() {
1250        *lease = Some(acquire_background_work("Playing"));
1251    }
1252}
1253
1254fn release_background_lease() {
1255    BACKGROUND_LEASE.lock().take();
1256}
1257
1258#[cfg(test)]
1259fn holds_background_work() -> bool {
1260    BACKGROUND_LEASE.lock().is_some()
1261}
1262
1263pub(crate) fn on_lifecycle(event: LifecycleEvent) {
1264    if event.to == LifecycleState::Destroyed {
1265        stop_media();
1266    }
1267}
1268
1269/// The `file:` URI for a path, which is what [`MediaItem`] takes.
1270///
1271/// Percent-encodes everything a URI reserves, so a track called `Sgt. Pepper's
1272/// #1.mp3` survives the trip. Lives here rather than in a backend because
1273/// every backend that reads local files needs the same answer, and an
1274/// application building an item needs it too.
1275pub fn uri_for_path(path: &Path) -> String {
1276    let text = path.to_string_lossy();
1277    let mut uri = String::with_capacity(text.len() + 8);
1278    uri.push_str("file://");
1279    if !text.starts_with('/') {
1280        uri.push('/');
1281    }
1282    for byte in text.bytes() {
1283        match byte {
1284            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
1285                uri.push(byte as char);
1286            }
1287            b'\\' => uri.push('/'),
1288            _ => uri.push_str(&format!("%{byte:02X}")),
1289        }
1290    }
1291    uri
1292}
1293
1294/// The path a media URI addresses, or `None` when it addresses something that
1295/// is not a local file — a stream, a content provider, a browser blob.
1296///
1297/// A bare path is accepted as itself: an application that already has a
1298/// `PathBuf` should not have to build a URI to hand it back.
1299pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
1300    let rest = match uri.split_once("://") {
1301        Some(("file", rest)) => rest,
1302        Some(_) => return None,
1303        None => return non_empty_path(uri),
1304    };
1305    let path = rest.strip_prefix('/')?;
1306    let decoded = crate::content::percent_decode(path)?;
1307    if decoded.starts_with('/') || decoded.is_empty() {
1308        return non_empty_path(&decoded);
1309    }
1310    if decoded.as_bytes().get(1) == Some(&b':') {
1311        non_empty_path(&decoded)
1312    } else {
1313        non_empty_path(&format!("/{decoded}"))
1314    }
1315}
1316
1317fn non_empty_path(text: &str) -> Option<PathBuf> {
1318    if text.is_empty() {
1319        return None;
1320    }
1321    Some(PathBuf::from(text))
1322}
1323
1324/// A media stream the platform opened, and what it knows about it.
1325#[derive(Debug)]
1326pub struct MediaSourceHandle {
1327    /// The descriptor to read. Seekable for a real file; a pipe for a provider
1328    /// that streams.
1329    pub stream: File,
1330    /// How long the whole thing is, when the platform knows.
1331    ///
1332    /// A provider that streams cannot answer `stat` — that is what makes its
1333    /// descriptor a pipe — but it listed a size for the document all the same,
1334    /// and a decoder that knows the length can seek by it instead of waiting
1335    /// for the stream to end to find out where the end is.
1336    pub len: Option<u64>,
1337}
1338
1339/// Opens a media URI the decoder cannot open for itself.
1340///
1341/// A `file:` URI is a path and needs nobody, which is why
1342/// [`open_media_source`] answers those without asking. A `content://` document
1343/// belongs to an Android provider and only the platform layer can ask that
1344/// provider for a descriptor, so the platform layer registers this and the
1345/// decode thread calls it.
1346///
1347/// A descriptor rather than a reader because that is what both sides really
1348/// have: a real file is seekable, a provider that streams hands back a pipe,
1349/// and the decoder tells them apart by trying to seek.
1350pub trait MediaSourceOpener: Send + Sync {
1351    /// Opens `uri` for reading.
1352    fn open(&self, uri: &str) -> std::io::Result<MediaSourceHandle>;
1353}
1354
1355/// Shared handle to the platform media source opener.
1356pub type MediaSourceOpenerRef = Arc<dyn MediaSourceOpener>;
1357
1358static PLATFORM_MEDIA_SOURCE: ServiceRegistry<dyn MediaSourceOpener> = ServiceRegistry::new();
1359
1360/// Installs the platform media source opener, replacing any previous one.
1361pub fn set_platform_media_source_opener(opener: MediaSourceOpenerRef) {
1362    PLATFORM_MEDIA_SOURCE.set(opener);
1363}
1364
1365/// Removes the platform media source opener.
1366pub fn clear_platform_media_source_opener() {
1367    PLATFORM_MEDIA_SOURCE.clear();
1368}
1369
1370/// Opens `uri` for decoding.
1371///
1372/// `file:` URIs and bare paths are opened here. Anything else is the platform's
1373/// to answer, and a platform that registered no opener gets
1374/// [`ErrorKind::Unsupported`](std::io::ErrorKind::Unsupported) rather than a
1375/// guess.
1376pub fn open_media_source(uri: &str) -> std::io::Result<MediaSourceHandle> {
1377    if let Some(path) = path_from_uri(uri) {
1378        let stream = File::open(path)?;
1379        let len = stream.metadata().ok().map(|metadata| metadata.len());
1380        return Ok(MediaSourceHandle { stream, len });
1381    }
1382    match PLATFORM_MEDIA_SOURCE.get() {
1383        Some(opener) => opener.open(uri),
1384        None => Err(std::io::Error::new(
1385            std::io::ErrorKind::Unsupported,
1386            format!("no platform opener for {uri}"),
1387        )),
1388    }
1389}
1390
1391/// What the player is doing, observed for as long as this call stays in the
1392/// composition.
1393#[expect(non_snake_case)]
1394#[track_caller]
1395pub fn rememberPlaybackState() -> State<PlaybackState> {
1396    let updates = rememberEventStream((), |sender| {
1397        observe_playback_state(move |state| sender.send(state))
1398    });
1399    cranpose_core::collectAsState(updates, (), playback_state())
1400}
1401
1402/// Where the open item is, observed for as long as this call stays in the
1403/// composition.
1404///
1405/// This recomposes as the position moves, which is what a seek bar and a time
1406/// label want. A visualiser or a waveform that redraws every frame anyway reads
1407/// [`playback_progress`] during draw instead.
1408#[expect(non_snake_case)]
1409#[track_caller]
1410pub fn rememberPlaybackProgress() -> State<PlaybackProgress> {
1411    let updates = rememberEventStream((), |sender| {
1412        observe_playback_progress(move |progress| sender.send(progress))
1413    });
1414    cranpose_core::collectAsState(updates, (), playback_progress())
1415}
1416
1417/// What the rest of the device is doing with the output, observed for as long
1418/// as this call stays in the composition.
1419#[expect(non_snake_case)]
1420#[track_caller]
1421pub fn rememberAudioFocus() -> State<AudioFocus> {
1422    let updates = rememberEventStream((), |sender| {
1423        observe_audio_focus(move |focus| sender.send(focus))
1424    });
1425    cranpose_core::collectAsState(updates, (), audio_focus())
1426}
1427
1428/// Buttons pressed outside the application's own UI, as a stream this
1429/// composition collects.
1430///
1431/// The transport commands have already been carried out by the time they arrive
1432/// here; what an application acts on is [`MediaCommand::Next`] and
1433/// [`MediaCommand::Previous`], which need the playlist it owns.
1434#[expect(non_snake_case)]
1435#[track_caller]
1436pub fn rememberMediaCommands() -> EventStream<MediaCommand> {
1437    rememberEventStream((), |sender| {
1438        observe_media_commands(move |command| sender.send(command))
1439    })
1440}
1441
1442/// Samples as they are heard, as a stream this composition collects.
1443///
1444/// Enable them with [`set_media_analysis_enabled`] first; a backend that cannot
1445/// produce them says so through [`MediaCapabilities::analysis`].
1446#[expect(non_snake_case)]
1447#[track_caller]
1448pub fn rememberMediaSamples() -> EventStream<MediaSamples> {
1449    rememberEventStream((), |sender| {
1450        observe_media_samples(move |samples| sender.send(samples))
1451    })
1452}
1453
1454#[cfg(test)]
1455#[path = "tests/media_tests.rs"]
1456mod tests;