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;