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