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