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