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