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