use crate::background::{acquire_background_work, BackgroundWorkLease};
use crate::host::LifecycleEvent;
use crate::host::LifecycleState;
use crate::registry::ServiceRegistry;
use cranpose_core::{rememberEventStream, EventStream, State};
use parking_lot::Mutex;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
pub const DUCKED_GAIN: f32 = 0.2;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MediaArtwork {
pub bytes: Arc<[u8]>,
pub mime: String,
}
impl MediaArtwork {
pub fn new(bytes: impl Into<Arc<[u8]>>, mime: impl Into<String>) -> MediaArtwork {
MediaArtwork {
bytes: bytes.into(),
mime: mime.into(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaMetadata {
pub title: String,
pub artist: String,
pub album: String,
pub duration: Option<Duration>,
pub artwork: Option<MediaArtwork>,
}
impl MediaMetadata {
pub fn titled(title: impl Into<String>) -> MediaMetadata {
MediaMetadata {
title: title.into(),
..MediaMetadata::default()
}
}
pub fn artist(mut self, artist: impl Into<String>) -> MediaMetadata {
self.artist = artist.into();
self
}
pub fn album(mut self, album: impl Into<String>) -> MediaMetadata {
self.album = album.into();
self
}
pub fn duration(mut self, duration: Duration) -> MediaMetadata {
self.duration = Some(duration);
self
}
pub fn artwork(mut self, artwork: MediaArtwork) -> MediaMetadata {
self.artwork = Some(artwork);
self
}
pub fn is_empty(&self) -> bool {
self.title.is_empty() && self.artist.is_empty() && self.album.is_empty()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MediaItem {
pub uri: String,
pub metadata: MediaMetadata,
}
impl MediaItem {
pub fn new(uri: impl Into<String>) -> MediaItem {
MediaItem {
uri: uri.into(),
metadata: MediaMetadata::default(),
}
}
pub fn with_metadata(mut self, metadata: MediaMetadata) -> MediaItem {
self.metadata = metadata;
self
}
pub fn display_title(&self) -> &str {
if !self.metadata.title.is_empty() {
return &self.metadata.title;
}
let path = self.uri.split(['?', '#']).next().unwrap_or(&self.uri);
match path.rsplit(['/', '\\']).next() {
Some(name) if !name.is_empty() => name,
_ => &self.uri,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MediaCapabilities {
pub seeking: bool,
pub speed: bool,
pub looping: bool,
pub analysis: bool,
pub session: bool,
pub equalizer: bool,
pub probing: bool,
}
impl MediaCapabilities {
pub const TRANSPORT: MediaCapabilities = MediaCapabilities {
seeking: true,
speed: false,
looping: true,
analysis: false,
session: false,
equalizer: false,
probing: false,
};
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EqualizerBand {
pub center_hz: f32,
pub min_gain_db: f32,
pub max_gain_db: f32,
}
impl EqualizerBand {
pub fn new(center_hz: f32, range_db: f32) -> EqualizerBand {
let range = range_db.abs();
EqualizerBand {
center_hz,
min_gain_db: -range,
max_gain_db: range,
}
}
pub fn clamp_gain(&self, gain_db: f32) -> f32 {
gain_db.clamp(self.min_gain_db, self.max_gain_db)
}
}
pub const OCTAVE_BAND_CENTERS_HZ: [f32; 10] = [
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,
];
pub fn octave_equalizer_bands(range_db: f32) -> Vec<EqualizerBand> {
OCTAVE_BAND_CENTERS_HZ
.iter()
.map(|center| EqualizerBand::new(*center, range_db))
.collect()
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EqualizerSettings {
pub enabled: bool,
pub preamp_db: f32,
pub gains_db: Vec<f32>,
}
impl EqualizerSettings {
pub fn flat(bands: usize) -> EqualizerSettings {
EqualizerSettings {
enabled: true,
preamp_db: 0.0,
gains_db: vec![0.0; bands],
}
}
pub fn clamped_to(&self, bands: &[EqualizerBand]) -> EqualizerSettings {
EqualizerSettings {
enabled: self.enabled,
preamp_db: self.preamp_db,
gains_db: bands
.iter()
.enumerate()
.map(|(index, band)| {
band.clamp_gain(self.gains_db.get(index).copied().unwrap_or(0.0))
})
.collect(),
}
}
}
#[derive(Clone, Debug, thiserror::Error, PartialEq, Eq)]
pub enum MediaError {
#[error("media playback is not supported here")]
Unsupported,
#[error("cannot play {0}")]
UnsupportedSource(String),
#[error("no media item is loaded")]
NothingLoaded,
#[error("this item cannot be seeked")]
NotSeekable,
#[error("{0}")]
Failed(String),
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum PlaybackState {
#[default]
Idle,
Loading,
Playing,
Paused,
Ended,
Failed(MediaError),
}
impl PlaybackState {
pub fn is_playing(&self) -> bool {
matches!(self, PlaybackState::Playing)
}
pub fn is_active(&self) -> bool {
matches!(
self,
PlaybackState::Loading | PlaybackState::Playing | PlaybackState::Paused
)
}
pub fn failure(&self) -> Option<&MediaError> {
match self {
PlaybackState::Failed(error) => Some(error),
_ => None,
}
}
}
#[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq)]
pub struct PlaybackProgress {
pub position: Duration,
pub duration: Option<Duration>,
pub buffered: Duration,
}
impl PlaybackProgress {
pub fn new(position: Duration, duration: Duration) -> PlaybackProgress {
PlaybackProgress {
position: position.min(duration),
duration: Some(duration),
buffered: duration,
}
}
pub fn fraction(&self) -> Option<f32> {
let duration = self.duration?;
if duration.is_zero() {
return Some(0.0);
}
Some((self.position.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
}
pub fn buffered_fraction(&self) -> Option<f32> {
let duration = self.duration?;
if duration.is_zero() {
return Some(0.0);
}
Some((self.buffered.as_secs_f32() / duration.as_secs_f32()).clamp(0.0, 1.0))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum MediaCommand {
Play,
Pause,
TogglePlayPause,
Stop,
Next,
Previous,
SeekTo(Duration),
}
impl MediaCommand {
pub fn is_transport(self) -> bool {
!matches!(self, MediaCommand::Next | MediaCommand::Previous)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AudioFocus {
#[default]
Gained,
Ducked,
LostTransient,
Lost,
}
#[derive(Clone, Debug, PartialEq)]
pub struct MediaSamples {
pub sample_rate: u32,
pub channels: u16,
pub samples: Arc<[f32]>,
pub sequence: u64,
}
impl MediaSamples {
pub fn new(
sample_rate: u32,
channels: u16,
sequence: u64,
samples: impl Into<Arc<[f32]>>,
) -> Option<MediaSamples> {
let samples = samples.into();
if sample_rate == 0 || channels == 0 || samples.len() % channels as usize != 0 {
return None;
}
Some(MediaSamples {
sample_rate,
channels,
samples,
sequence,
})
}
pub fn frames(&self) -> usize {
self.samples.len() / self.channels.max(1) as usize
}
pub fn span(&self) -> Duration {
if self.sample_rate == 0 {
return Duration::ZERO;
}
Duration::from_secs_f64(self.frames() as f64 / self.sample_rate as f64)
}
}
pub trait MediaPlayer: Send + Sync {
fn capabilities(&self) -> MediaCapabilities;
fn prepare(&self, item: &MediaItem) -> Result<(), MediaError>;
fn play(&self) -> Result<(), MediaError>;
fn pause(&self);
fn stop(&self);
fn seek_to(&self, _position: Duration) -> Result<(), MediaError> {
Err(MediaError::NotSeekable)
}
fn set_volume(&self, volume: f32);
fn set_speed(&self, _speed: f32) -> bool {
false
}
fn set_looping(&self, _looping: bool) {}
fn set_analysis_enabled(&self, _enabled: bool) -> bool {
false
}
fn set_session_metadata(&self, _metadata: &MediaMetadata) {}
fn equalizer_bands(&self) -> Vec<EqualizerBand> {
Vec::new()
}
fn set_equalizer(&self, _settings: &EqualizerSettings) {}
fn probe_duration(&self, _item: &MediaItem) -> Option<Duration> {
None
}
}
pub type MediaPlayerRef = Arc<dyn MediaPlayer>;
static PLATFORM_MEDIA: ServiceRegistry<dyn MediaPlayer> = ServiceRegistry::new();
pub fn set_platform_media_player(player: MediaPlayerRef) {
PLATFORM_MEDIA.set(player);
}
pub fn clear_platform_media_player() {
if let Some(player) = PLATFORM_MEDIA.get() {
player.stop();
}
PLATFORM_MEDIA.clear();
STATE_OBSERVERS.clear();
PROGRESS_OBSERVERS.clear();
COMMAND_OBSERVERS.clear();
FOCUS_OBSERVERS.clear();
SAMPLE_OBSERVERS.clear();
*STATE.lock() = PlaybackState::Idle;
*PROGRESS.lock() = PlaybackProgress::default();
*CURRENT_ITEM.lock() = None;
*LATEST_SAMPLES.lock() = None;
*FOCUS.lock() = AudioFocus::Gained;
*VOLUME.lock() = 1.0;
PAUSED_BY_FOCUS.store(false, Ordering::Release);
DROPPED_SAMPLES.store(0, Ordering::Release);
release_background_lease();
}
pub fn media_player() -> Option<MediaPlayerRef> {
PLATFORM_MEDIA.get()
}
pub fn media_playback_supported() -> bool {
PLATFORM_MEDIA.get().is_some()
}
pub fn media_capabilities() -> MediaCapabilities {
media_player()
.map(|player| player.capabilities())
.unwrap_or_default()
}
static STATE: Mutex<PlaybackState> = Mutex::new(PlaybackState::Idle);
static PROGRESS: Mutex<PlaybackProgress> = Mutex::new(PlaybackProgress {
position: Duration::ZERO,
duration: None,
buffered: Duration::ZERO,
});
static CURRENT_ITEM: Mutex<Option<MediaItem>> = Mutex::new(None);
static LATEST_SAMPLES: Mutex<Option<MediaSamples>> = Mutex::new(None);
static FOCUS: Mutex<AudioFocus> = Mutex::new(AudioFocus::Gained);
static VOLUME: Mutex<f32> = Mutex::new(1.0);
static EQUALIZER: Mutex<EqualizerSettings> = Mutex::new(EqualizerSettings {
enabled: false,
preamp_db: 0.0,
gains_db: Vec::new(),
});
static PAUSED_BY_FOCUS: AtomicBool = AtomicBool::new(false);
static DROPPED_SAMPLES: AtomicU64 = AtomicU64::new(0);
pub fn playback_state() -> PlaybackState {
STATE.lock().clone()
}
pub fn playback_progress() -> PlaybackProgress {
*PROGRESS.lock()
}
pub fn current_media_item() -> Option<MediaItem> {
CURRENT_ITEM.lock().clone()
}
pub fn latest_media_samples() -> Option<MediaSamples> {
LATEST_SAMPLES.lock().clone()
}
pub fn dropped_media_samples() -> u64 {
DROPPED_SAMPLES.load(Ordering::Acquire)
}
pub fn audio_focus() -> AudioFocus {
*FOCUS.lock()
}
pub fn media_volume() -> f32 {
*VOLUME.lock()
}
struct ObserverList<T: ?Sized> {
entries: Mutex<Vec<(u64, Arc<T>)>>,
}
impl<T: ?Sized> ObserverList<T> {
const fn new() -> ObserverList<T> {
ObserverList {
entries: Mutex::new(Vec::new()),
}
}
fn add(&self, observer: Arc<T>) -> u64 {
let id = NEXT_OBSERVER.fetch_add(1, Ordering::Relaxed);
self.entries.lock().push((id, observer));
id
}
fn remove(&self, id: u64) {
self.entries.lock().retain(|(entry, _)| *entry != id);
}
fn snapshot(&self) -> Vec<Arc<T>> {
self.entries
.lock()
.iter()
.map(|(_, observer)| Arc::clone(observer))
.collect()
}
fn clear(&self) {
self.entries.lock().clear();
}
}
static NEXT_OBSERVER: AtomicU64 = AtomicU64::new(1);
type StateObserverFn = dyn Fn(PlaybackState) + Send + Sync;
type ProgressObserverFn = dyn Fn(PlaybackProgress) + Send + Sync;
type CommandObserverFn = dyn Fn(MediaCommand) + Send + Sync;
type FocusObserverFn = dyn Fn(AudioFocus) + Send + Sync;
type SampleObserverFn = dyn Fn(MediaSamples) + Send + Sync;
static STATE_OBSERVERS: ObserverList<StateObserverFn> = ObserverList::new();
static PROGRESS_OBSERVERS: ObserverList<ProgressObserverFn> = ObserverList::new();
static COMMAND_OBSERVERS: ObserverList<CommandObserverFn> = ObserverList::new();
static FOCUS_OBSERVERS: ObserverList<FocusObserverFn> = ObserverList::new();
static SAMPLE_OBSERVERS: ObserverList<SampleObserverFn> = ObserverList::new();
pub struct MediaObserver {
id: u64,
remove: fn(u64),
}
impl Drop for MediaObserver {
fn drop(&mut self) {
(self.remove)(self.id);
}
}
pub fn observe_playback_state(
observer: impl Fn(PlaybackState) + Send + Sync + 'static,
) -> MediaObserver {
let observer: Arc<StateObserverFn> = Arc::new(observer);
let id = STATE_OBSERVERS.add(Arc::clone(&observer));
observer(playback_state());
MediaObserver {
id,
remove: |id| STATE_OBSERVERS.remove(id),
}
}
pub fn observe_playback_progress(
observer: impl Fn(PlaybackProgress) + Send + Sync + 'static,
) -> MediaObserver {
let observer: Arc<ProgressObserverFn> = Arc::new(observer);
let id = PROGRESS_OBSERVERS.add(Arc::clone(&observer));
observer(playback_progress());
MediaObserver {
id,
remove: |id| PROGRESS_OBSERVERS.remove(id),
}
}
pub fn observe_media_commands(
observer: impl Fn(MediaCommand) + Send + Sync + 'static,
) -> MediaObserver {
let id = COMMAND_OBSERVERS.add(Arc::new(observer));
MediaObserver {
id,
remove: |id| COMMAND_OBSERVERS.remove(id),
}
}
pub fn observe_audio_focus(observer: impl Fn(AudioFocus) + Send + Sync + 'static) -> MediaObserver {
let observer: Arc<FocusObserverFn> = Arc::new(observer);
let id = FOCUS_OBSERVERS.add(Arc::clone(&observer));
observer(audio_focus());
MediaObserver {
id,
remove: |id| FOCUS_OBSERVERS.remove(id),
}
}
pub fn observe_media_samples(
observer: impl Fn(MediaSamples) + Send + Sync + 'static,
) -> MediaObserver {
let id = SAMPLE_OBSERVERS.add(Arc::new(observer));
MediaObserver {
id,
remove: |id| SAMPLE_OBSERVERS.remove(id),
}
}
pub fn publish_playback_state(state: PlaybackState) {
{
let mut current = STATE.lock();
if *current == state {
return;
}
*current = state.clone();
}
if state.is_playing() {
acquire_background_lease();
} else {
release_background_lease();
}
if !state.is_active() {
*PROGRESS.lock() = PlaybackProgress::default();
*LATEST_SAMPLES.lock() = None;
}
if matches!(state, PlaybackState::Idle) {
*CURRENT_ITEM.lock() = None;
DROPPED_SAMPLES.store(0, Ordering::Release);
}
for observer in STATE_OBSERVERS.snapshot() {
observer(state.clone());
}
}
pub fn publish_playback_progress(progress: PlaybackProgress) {
let progress = clamp_progress(progress);
{
let mut current = PROGRESS.lock();
if *current == progress {
return;
}
*current = progress;
}
for observer in PROGRESS_OBSERVERS.snapshot() {
observer(progress);
}
}
fn clamp_progress(mut progress: PlaybackProgress) -> PlaybackProgress {
if let Some(duration) = progress.duration {
progress.position = progress.position.min(duration);
progress.buffered = progress.buffered.min(duration);
}
progress
}
pub fn publish_media_command(command: MediaCommand) {
match command {
MediaCommand::Play => {
let _ = play_media();
}
MediaCommand::Pause => pause_media(),
MediaCommand::TogglePlayPause => toggle_media(),
MediaCommand::Stop => stop_media(),
MediaCommand::SeekTo(position) => {
let _ = seek_media(position);
}
MediaCommand::Next | MediaCommand::Previous => {}
}
for observer in COMMAND_OBSERVERS.snapshot() {
observer(command);
}
}
pub fn publish_audio_focus(focus: AudioFocus) {
{
let mut current = FOCUS.lock();
if *current == focus {
return;
}
*current = focus;
}
apply_volume();
match focus {
AudioFocus::Gained => {
if PAUSED_BY_FOCUS.swap(false, Ordering::AcqRel) {
let _ = play_media();
}
}
AudioFocus::Ducked => {}
AudioFocus::LostTransient => {
if playback_state().is_playing() {
PAUSED_BY_FOCUS.store(true, Ordering::Release);
pause_media();
}
}
AudioFocus::Lost => {
PAUSED_BY_FOCUS.store(false, Ordering::Release);
stop_media();
}
}
for observer in FOCUS_OBSERVERS.snapshot() {
observer(focus);
}
}
pub fn publish_media_samples(samples: MediaSamples) {
*LATEST_SAMPLES.lock() = Some(samples.clone());
let observers = SAMPLE_OBSERVERS.snapshot();
if observers.is_empty() {
return;
}
for observer in observers {
observer(samples.clone());
}
}
pub fn record_dropped_media_samples() {
DROPPED_SAMPLES.fetch_add(1, Ordering::AcqRel);
}
pub fn open_media(item: MediaItem) -> Result<(), MediaError> {
let Some(player) = media_player() else {
publish_playback_state(PlaybackState::Failed(MediaError::Unsupported));
return Err(MediaError::Unsupported);
};
PAUSED_BY_FOCUS.store(false, Ordering::Release);
DROPPED_SAMPLES.store(0, Ordering::Release);
*CURRENT_ITEM.lock() = Some(item.clone());
publish_playback_progress(PlaybackProgress {
position: Duration::ZERO,
duration: item.metadata.duration,
buffered: Duration::ZERO,
});
publish_playback_state(PlaybackState::Loading);
if player.capabilities().session {
player.set_session_metadata(&item.metadata);
}
player.prepare(&item).inspect_err(|error| {
publish_playback_state(PlaybackState::Failed(error.clone()));
})
}
pub fn play_media() -> Result<(), MediaError> {
let Some(player) = media_player() else {
return Err(MediaError::Unsupported);
};
if CURRENT_ITEM.lock().is_none() {
return Err(MediaError::NothingLoaded);
}
player.play().inspect_err(|error| {
publish_playback_state(PlaybackState::Failed(error.clone()));
})
}
pub fn pause_media() {
if let Some(player) = media_player() {
player.pause();
}
}
pub fn stop_media() {
PAUSED_BY_FOCUS.store(false, Ordering::Release);
if let Some(player) = media_player() {
player.stop();
}
publish_playback_state(PlaybackState::Idle);
}
pub fn toggle_media() {
if playback_state().is_playing() {
pause_media();
} else {
let _ = play_media();
}
}
pub fn seek_media(position: Duration) -> Result<(), MediaError> {
let Some(player) = media_player() else {
return Err(MediaError::Unsupported);
};
if CURRENT_ITEM.lock().is_none() {
return Err(MediaError::NothingLoaded);
}
if !player.capabilities().seeking {
return Err(MediaError::NotSeekable);
}
let position = match playback_progress().duration {
Some(duration) => position.min(duration),
None => position,
};
player.seek_to(position)
}
pub fn seek_media_fraction(fraction: f32) -> Result<(), MediaError> {
let Some(duration) = playback_progress().duration else {
return Err(MediaError::NotSeekable);
};
let fraction = fraction.clamp(0.0, 1.0) as f64;
seek_media(Duration::from_secs_f64(duration.as_secs_f64() * fraction))
}
pub fn set_media_volume(volume: f32) {
*VOLUME.lock() = volume.clamp(0.0, 1.0);
apply_volume();
}
fn apply_volume() {
let Some(player) = media_player() else {
return;
};
let gain = match audio_focus() {
AudioFocus::Ducked => DUCKED_GAIN,
_ => 1.0,
};
player.set_volume(media_volume() * gain);
}
pub fn set_media_speed(speed: f32) -> bool {
match media_player() {
Some(player) if player.capabilities().speed => player.set_speed(speed),
_ => false,
}
}
pub fn set_media_looping(looping: bool) {
if let Some(player) = media_player() {
player.set_looping(looping);
}
}
pub fn set_media_analysis_enabled(enabled: bool) -> bool {
match media_player() {
Some(player) if player.capabilities().analysis => {
if !enabled {
*LATEST_SAMPLES.lock() = None;
}
player.set_analysis_enabled(enabled)
}
_ => false,
}
}
pub fn probe_media_duration(item: &MediaItem) -> Option<Duration> {
media_player()?.probe_duration(item)
}
pub fn media_equalizer_bands() -> Vec<EqualizerBand> {
match media_player() {
Some(player) if player.capabilities().equalizer => player.equalizer_bands(),
_ => Vec::new(),
}
}
pub fn media_equalizer() -> EqualizerSettings {
EQUALIZER.lock().clone()
}
pub fn set_media_equalizer(settings: EqualizerSettings) -> bool {
*EQUALIZER.lock() = settings.clone();
let Some(player) = media_player() else {
return false;
};
if !player.capabilities().equalizer {
return false;
}
player.set_equalizer(&settings.clamped_to(&player.equalizer_bands()));
true
}
pub fn set_media_metadata(metadata: MediaMetadata) {
{
let mut item = CURRENT_ITEM.lock();
let Some(item) = item.as_mut() else {
return;
};
item.metadata = metadata.clone();
}
if let Some(player) = media_player() {
if player.capabilities().session {
player.set_session_metadata(&metadata);
}
}
}
static BACKGROUND_LEASE: Mutex<Option<BackgroundWorkLease>> = Mutex::new(None);
fn acquire_background_lease() {
let mut lease = BACKGROUND_LEASE.lock();
if lease.is_none() {
*lease = Some(acquire_background_work());
}
}
fn release_background_lease() {
BACKGROUND_LEASE.lock().take();
}
#[cfg(test)]
fn holds_background_work() -> bool {
BACKGROUND_LEASE.lock().is_some()
}
pub(crate) fn on_lifecycle(event: LifecycleEvent) {
if event.to == LifecycleState::Destroyed {
stop_media();
}
}
pub fn uri_for_path(path: &Path) -> String {
let text = path.to_string_lossy();
let mut uri = String::with_capacity(text.len() + 8);
uri.push_str("file://");
if !text.starts_with('/') {
uri.push('/');
}
for byte in text.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => {
uri.push(byte as char);
}
b'\\' => uri.push('/'),
_ => uri.push_str(&format!("%{byte:02X}")),
}
}
uri
}
pub fn path_from_uri(uri: &str) -> Option<PathBuf> {
let rest = match uri.split_once("://") {
Some(("file", rest)) => rest,
Some(_) => return None,
None => return non_empty_path(uri),
};
let path = rest.strip_prefix('/')?;
let decoded = percent_decode(path)?;
if decoded.starts_with('/') || decoded.is_empty() {
return non_empty_path(&decoded);
}
if decoded.as_bytes().get(1) == Some(&b':') {
non_empty_path(&decoded)
} else {
non_empty_path(&format!("/{decoded}"))
}
}
fn non_empty_path(text: &str) -> Option<PathBuf> {
if text.is_empty() {
return None;
}
Some(PathBuf::from(text))
}
fn percent_decode(text: &str) -> Option<String> {
if !text.contains('%') {
return Some(text.to_string());
}
let bytes = text.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' {
let hex = text.get(index + 1..index + 3)?;
out.push(u8::from_str_radix(hex, 16).ok()?);
index += 3;
} else {
out.push(bytes[index]);
index += 1;
}
}
String::from_utf8(out).ok()
}
#[allow(non_snake_case)]
pub fn rememberPlaybackState() -> State<PlaybackState> {
let updates = rememberEventStream((), |sender| {
observe_playback_state(move |state| sender.send(state))
});
cranpose_core::collectAsState(updates, (), playback_state())
}
#[allow(non_snake_case)]
pub fn rememberPlaybackProgress() -> State<PlaybackProgress> {
let updates = rememberEventStream((), |sender| {
observe_playback_progress(move |progress| sender.send(progress))
});
cranpose_core::collectAsState(updates, (), playback_progress())
}
#[allow(non_snake_case)]
pub fn rememberAudioFocus() -> State<AudioFocus> {
let updates = rememberEventStream((), |sender| {
observe_audio_focus(move |focus| sender.send(focus))
});
cranpose_core::collectAsState(updates, (), audio_focus())
}
#[allow(non_snake_case)]
pub fn rememberMediaCommands() -> EventStream<MediaCommand> {
rememberEventStream((), |sender| {
observe_media_commands(move |command| sender.send(command))
})
}
#[allow(non_snake_case)]
pub fn rememberMediaSamples() -> EventStream<MediaSamples> {
rememberEventStream((), |sender| {
observe_media_samples(move |samples| sender.send(samples))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::test_service_guard;
struct FakePlayer {
capabilities: MediaCapabilities,
calls: Mutex<Vec<String>>,
volume: Mutex<f32>,
prepare_fails: bool,
}
impl FakePlayer {
fn new() -> Arc<FakePlayer> {
Arc::new(FakePlayer {
capabilities: MediaCapabilities {
seeking: true,
speed: true,
looping: true,
analysis: true,
session: true,
equalizer: true,
probing: true,
},
calls: Mutex::new(Vec::new()),
volume: Mutex::new(1.0),
prepare_fails: false,
})
}
fn with(capabilities: MediaCapabilities) -> Arc<FakePlayer> {
Arc::new(FakePlayer {
capabilities,
calls: Mutex::new(Vec::new()),
volume: Mutex::new(1.0),
prepare_fails: false,
})
}
fn failing() -> Arc<FakePlayer> {
Arc::new(FakePlayer {
capabilities: MediaCapabilities::TRANSPORT,
calls: Mutex::new(Vec::new()),
volume: Mutex::new(1.0),
prepare_fails: true,
})
}
fn note(&self, call: impl Into<String>) {
self.calls.lock().push(call.into());
}
fn calls(&self) -> Vec<String> {
self.calls.lock().clone()
}
}
impl MediaPlayer for FakePlayer {
fn capabilities(&self) -> MediaCapabilities {
self.capabilities
}
fn prepare(&self, item: &MediaItem) -> Result<(), MediaError> {
self.note(format!("prepare {}", item.uri));
if self.prepare_fails {
return Err(MediaError::UnsupportedSource(item.uri.clone()));
}
publish_playback_state(PlaybackState::Paused);
Ok(())
}
fn play(&self) -> Result<(), MediaError> {
self.note("play");
publish_playback_state(PlaybackState::Playing);
Ok(())
}
fn pause(&self) {
self.note("pause");
publish_playback_state(PlaybackState::Paused);
}
fn stop(&self) {
self.note("stop");
}
fn seek_to(&self, position: Duration) -> Result<(), MediaError> {
self.note(format!("seek {}", position.as_millis()));
Ok(())
}
fn set_volume(&self, volume: f32) {
*self.volume.lock() = volume;
}
fn set_speed(&self, speed: f32) -> bool {
self.note(format!("speed {speed}"));
true
}
fn set_looping(&self, looping: bool) {
self.note(format!("looping {looping}"));
}
fn set_analysis_enabled(&self, enabled: bool) -> bool {
self.note(format!("analysis {enabled}"));
true
}
fn set_session_metadata(&self, metadata: &MediaMetadata) {
self.note(format!("session {}", metadata.title));
}
fn equalizer_bands(&self) -> Vec<EqualizerBand> {
vec![
EqualizerBand::new(60.0, 6.0),
EqualizerBand::new(1_000.0, 6.0),
EqualizerBand::new(10_000.0, 6.0),
]
}
fn set_equalizer(&self, settings: &EqualizerSettings) {
self.note(format!(
"equalizer {} preamp {} gains {:?}",
settings.enabled, settings.preamp_db, settings.gains_db
));
}
}
fn install() -> (crate::registry::TestServiceGuard, Arc<FakePlayer>) {
let guard = test_service_guard();
clear_platform_media_player();
let player = FakePlayer::new();
set_platform_media_player(player.clone());
(guard, player)
}
fn track() -> MediaItem {
MediaItem::new("file:///music/track.flac").with_metadata(
MediaMetadata::titled("Track")
.artist("Artist")
.duration(Duration::from_secs(200)),
)
}
#[test]
fn metadata_carries_everything_a_lock_screen_shows() {
let artwork = MediaArtwork {
bytes: vec![1, 2, 3].into(),
mime: "image/png".to_string(),
};
let metadata = MediaMetadata::titled("Song")
.artist("Band")
.album("Record")
.duration(Duration::from_secs(210))
.artwork(artwork.clone());
assert_eq!(metadata.album, "Record");
assert_eq!(metadata.artwork.as_ref(), Some(&artwork));
assert!(!metadata.is_empty());
assert!(!MediaMetadata::default().album("Record").is_empty());
assert!(MediaMetadata::default().is_empty());
}
#[test]
fn a_band_reports_what_it_can_actually_do() {
let band = EqualizerBand::new(1_000.0, 6.0);
assert_eq!(band.clamp_gain(0.0), 0.0);
assert_eq!(band.clamp_gain(6.0), 6.0);
assert_eq!(band.clamp_gain(7.5), 6.0);
assert_eq!(band.clamp_gain(-7.5), -6.0);
}
#[test]
fn an_equalizer_setting_is_clamped_to_the_bands_the_backend_has() {
let bands = vec![
EqualizerBand::new(60.0, 6.0),
EqualizerBand::new(1_000.0, 6.0),
];
let asked = EqualizerSettings {
enabled: true,
preamp_db: -3.0,
gains_db: vec![12.0, -12.0, 4.0],
};
let applied = asked.clamped_to(&bands);
assert_eq!(applied.gains_db, vec![6.0, -6.0]);
assert_eq!(applied.preamp_db, -3.0);
assert!(applied.enabled);
}
#[test]
fn a_setting_shorter_than_the_bands_leaves_the_rest_flat() {
let bands = octave_equalizer_bands(12.0);
let applied = EqualizerSettings {
enabled: true,
preamp_db: 0.0,
gains_db: vec![3.0],
}
.clamped_to(&bands);
assert_eq!(applied.gains_db.len(), bands.len());
assert_eq!(applied.gains_db[0], 3.0);
assert!(applied.gains_db[1..].iter().all(|gain| *gain == 0.0));
}
#[test]
fn a_curve_reaches_the_backend_clamped_to_its_own_bands() {
let (_guard, player) = install();
assert_eq!(media_equalizer_bands().len(), 3);
assert!(set_media_equalizer(EqualizerSettings {
enabled: true,
preamp_db: -2.0,
gains_db: vec![9.0, 0.0, -9.0],
}));
assert!(
player
.calls()
.iter()
.any(|call| call == "equalizer true preamp -2 gains [6.0, 0.0, -6.0]"),
"the backend was not given the clamped curve: {:?}",
player.calls()
);
}
#[test]
fn a_curve_is_remembered_even_where_nothing_can_apply_it() {
let _guard = test_service_guard();
clear_platform_media_player();
let asked = EqualizerSettings {
enabled: true,
preamp_db: -1.0,
gains_db: vec![4.0, -4.0],
};
assert!(!set_media_equalizer(asked.clone()));
assert_eq!(media_equalizer(), asked);
assert!(media_equalizer_bands().is_empty());
}
#[test]
fn a_backend_without_an_equalizer_says_so_rather_than_pretending() {
let _guard = test_service_guard();
clear_platform_media_player();
set_platform_media_player(FakePlayer::with(MediaCapabilities {
equalizer: false,
..MediaCapabilities::TRANSPORT
}));
assert!(media_equalizer_bands().is_empty());
assert!(!set_media_equalizer(EqualizerSettings::flat(10)));
}
#[test]
fn an_item_falls_back_to_its_file_name_for_a_title() {
assert_eq!(
MediaItem::new("file:///music/03 - Song.mp3").display_title(),
"03 - Song.mp3"
);
assert_eq!(
MediaItem::new("https://host/stream?token=1").display_title(),
"stream"
);
assert_eq!(track().display_title(), "Track");
}
#[test]
fn progress_reports_fractions_only_for_items_that_have_a_length() {
let known = PlaybackProgress::new(Duration::from_secs(30), Duration::from_secs(120));
assert_eq!(known.fraction(), Some(0.25));
assert_eq!(known.buffered_fraction(), Some(1.0));
let live = PlaybackProgress {
position: Duration::from_secs(30),
duration: None,
buffered: Duration::from_secs(35),
};
assert_eq!(live.fraction(), None);
assert_eq!(live.buffered_fraction(), None);
}
#[test]
fn progress_never_reads_past_the_end_of_the_item() {
let progress = PlaybackProgress::new(Duration::from_secs(500), Duration::from_secs(120));
assert_eq!(progress.position, Duration::from_secs(120));
assert_eq!(progress.fraction(), Some(1.0));
}
#[test]
fn samples_reject_a_layout_that_does_not_describe_the_data() {
assert!(MediaSamples::new(44_100, 2, 0, vec![0.0; 3]).is_none());
assert!(MediaSamples::new(0, 2, 0, vec![0.0; 4]).is_none());
assert!(MediaSamples::new(44_100, 0, 0, vec![0.0; 4]).is_none());
let block = MediaSamples::new(44_100, 2, 7, vec![0.0; 4410]).expect("well-formed block");
assert_eq!(block.frames(), 2205);
assert_eq!(block.span(), Duration::from_millis(50));
assert_eq!(block.sequence, 7);
}
#[test]
fn without_a_backend_every_call_reports_that_it_is_unsupported() {
let _guard = test_service_guard();
clear_platform_media_player();
assert!(!media_playback_supported());
assert_eq!(media_capabilities(), MediaCapabilities::default());
assert_eq!(open_media(track()), Err(MediaError::Unsupported));
assert_eq!(
playback_state(),
PlaybackState::Failed(MediaError::Unsupported)
);
assert_eq!(play_media(), Err(MediaError::Unsupported));
assert_eq!(seek_media(Duration::ZERO), Err(MediaError::Unsupported));
assert!(!set_media_speed(2.0));
assert!(!set_media_analysis_enabled(true));
}
#[test]
fn opening_an_item_shows_the_wait_before_the_backend_is_asked() {
let (_guard, player) = install();
let seen = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let _observer = observe_playback_state(move |state| recorder.lock().push(state));
open_media(track()).expect("the fake backend opens anything");
assert_eq!(
*seen.lock(),
vec![
PlaybackState::Idle,
PlaybackState::Loading,
PlaybackState::Paused,
]
);
assert_eq!(
player.calls(),
vec!["session Track", "prepare file:///music/track.flac"]
);
assert_eq!(current_media_item().map(|item| item.uri), Some(track().uri));
}
#[test]
fn an_item_that_cannot_be_opened_publishes_the_failure() {
let _guard = test_service_guard();
clear_platform_media_player();
set_platform_media_player(FakePlayer::failing());
let error = open_media(track()).expect_err("the failing backend refuses");
assert_eq!(
error,
MediaError::UnsupportedSource("file:///music/track.flac".to_string())
);
assert_eq!(playback_state().failure(), Some(&error));
}
#[test]
fn the_transport_routes_to_the_backend_and_publishes_what_it_did() {
let (_guard, player) = install();
open_media(track()).expect("opens");
play_media().expect("plays");
assert!(playback_state().is_playing());
toggle_media();
assert_eq!(playback_state(), PlaybackState::Paused);
toggle_media();
assert!(playback_state().is_playing());
stop_media();
assert_eq!(playback_state(), PlaybackState::Idle);
assert_eq!(current_media_item(), None);
assert_eq!(
player.calls(),
vec![
"session Track",
"prepare file:///music/track.flac",
"play",
"pause",
"play",
"stop",
]
);
}
#[test]
fn playing_nothing_reports_that_nothing_is_loaded() {
let (_guard, _player) = install();
assert_eq!(play_media(), Err(MediaError::NothingLoaded));
assert_eq!(
seek_media(Duration::from_secs(1)),
Err(MediaError::NothingLoaded)
);
}
#[test]
fn a_seek_is_clamped_to_the_item_rather_than_to_each_backend() {
let (_guard, player) = install();
open_media(track()).expect("opens");
seek_media(Duration::from_secs(1_000)).expect("seeks");
assert!(player.calls().contains(&"seek 200000".to_string()));
}
#[test]
fn a_seek_bar_fraction_maps_onto_the_item() {
let (_guard, player) = install();
open_media(track()).expect("opens");
seek_media_fraction(0.25).expect("seeks");
seek_media_fraction(3.0).expect("clamps rather than refusing");
let calls = player.calls();
assert!(calls.contains(&"seek 50000".to_string()));
assert!(calls.contains(&"seek 200000".to_string()));
}
#[test]
fn a_stream_with_no_length_has_no_seek_bar_fraction() {
let (_guard, _player) = install();
open_media(MediaItem::new("https://host/live")).expect("opens");
assert_eq!(seek_media_fraction(0.5), Err(MediaError::NotSeekable));
}
#[test]
fn a_backend_that_cannot_seek_says_so_instead_of_moving_nothing() {
let _guard = test_service_guard();
clear_platform_media_player();
set_platform_media_player(FakePlayer::with(MediaCapabilities {
seeking: false,
..MediaCapabilities::TRANSPORT
}));
open_media(track()).expect("opens");
assert_eq!(
seek_media(Duration::from_secs(1)),
Err(MediaError::NotSeekable)
);
}
#[test]
fn what_reaches_the_device_is_the_volume_combined_with_the_focus_gain() {
let (_guard, player) = install();
open_media(track()).expect("opens");
set_media_volume(0.5);
assert_eq!(*player.volume.lock(), 0.5);
assert_eq!(media_volume(), 0.5);
publish_audio_focus(AudioFocus::Ducked);
assert_eq!(*player.volume.lock(), 0.5 * DUCKED_GAIN);
set_media_volume(1.0);
assert_eq!(*player.volume.lock(), DUCKED_GAIN);
publish_audio_focus(AudioFocus::Gained);
assert_eq!(*player.volume.lock(), 1.0);
}
#[test]
fn a_volume_outside_the_range_is_brought_back_into_it() {
let (_guard, player) = install();
set_media_volume(4.0);
assert_eq!(media_volume(), 1.0);
set_media_volume(-1.0);
assert_eq!(media_volume(), 0.0);
assert_eq!(*player.volume.lock(), 0.0);
}
#[test]
fn a_transient_loss_pauses_and_the_next_gain_resumes() {
let (_guard, _player) = install();
open_media(track()).expect("opens");
play_media().expect("plays");
publish_audio_focus(AudioFocus::LostTransient);
assert_eq!(playback_state(), PlaybackState::Paused);
publish_audio_focus(AudioFocus::Gained);
assert!(playback_state().is_playing());
}
#[test]
fn regaining_focus_does_not_resume_what_the_user_paused() {
let (_guard, _player) = install();
open_media(track()).expect("opens");
play_media().expect("plays");
pause_media();
publish_audio_focus(AudioFocus::LostTransient);
publish_audio_focus(AudioFocus::Gained);
assert_eq!(playback_state(), PlaybackState::Paused);
}
#[test]
fn focus_lost_for_good_stops_and_does_not_come_back() {
let (_guard, _player) = install();
open_media(track()).expect("opens");
play_media().expect("plays");
publish_audio_focus(AudioFocus::Lost);
assert_eq!(playback_state(), PlaybackState::Idle);
publish_audio_focus(AudioFocus::Gained);
assert_eq!(playback_state(), PlaybackState::Idle);
}
#[test]
fn session_commands_drive_the_transport_and_still_reach_the_application() {
let (_guard, player) = install();
open_media(track()).expect("opens");
let seen = Arc::new(Mutex::new(Vec::new()));
let recorder = Arc::clone(&seen);
let _observer = observe_media_commands(move |command| recorder.lock().push(command));
publish_media_command(MediaCommand::Play);
assert!(playback_state().is_playing());
publish_media_command(MediaCommand::TogglePlayPause);
assert_eq!(playback_state(), PlaybackState::Paused);
publish_media_command(MediaCommand::SeekTo(Duration::from_secs(10)));
publish_media_command(MediaCommand::Next);
assert_eq!(
*seen.lock(),
vec![
MediaCommand::Play,
MediaCommand::TogglePlayPause,
MediaCommand::SeekTo(Duration::from_secs(10)),
MediaCommand::Next,
]
);
assert!(player.calls().contains(&"seek 10000".to_string()));
assert_eq!(playback_state(), PlaybackState::Paused);
}
#[test]
fn next_and_previous_are_the_commands_the_framework_leaves_alone() {
assert!(MediaCommand::Play.is_transport());
assert!(MediaCommand::SeekTo(Duration::ZERO).is_transport());
assert!(!MediaCommand::Next.is_transport());
assert!(!MediaCommand::Previous.is_transport());
}
#[test]
fn analysis_is_off_until_it_is_asked_for_and_only_where_it_exists() {
let (_guard, player) = install();
assert!(set_media_analysis_enabled(true));
assert!(player.calls().contains(&"analysis true".to_string()));
clear_platform_media_player();
set_platform_media_player(FakePlayer::with(MediaCapabilities::TRANSPORT));
assert!(!set_media_analysis_enabled(true));
}
#[test]
fn the_newest_sample_block_replaces_the_stored_one() {
let (_guard, _player) = install();
let first = MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block");
let second = MediaSamples::new(48_000, 1, 2, vec![0.5; 8]).expect("block");
publish_media_samples(first);
publish_media_samples(second.clone());
assert_eq!(latest_media_samples(), Some(second));
record_dropped_media_samples();
record_dropped_media_samples();
assert_eq!(dropped_media_samples(), 2);
}
#[test]
fn turning_analysis_off_forgets_the_last_block() {
let (_guard, _player) = install();
publish_media_samples(MediaSamples::new(48_000, 1, 1, vec![0.25; 8]).expect("block"));
assert!(set_media_analysis_enabled(false));
assert_eq!(latest_media_samples(), None);
}
#[test]
fn observers_stop_being_called_once_they_are_dropped() {
let (_guard, _player) = install();
let seen = Arc::new(Mutex::new(0usize));
let recorder = Arc::clone(&seen);
let observer = observe_playback_progress(move |_| *recorder.lock() += 1);
publish_playback_progress(PlaybackProgress::new(
Duration::from_secs(1),
Duration::from_secs(10),
));
let delivered = *seen.lock();
drop(observer);
publish_playback_progress(PlaybackProgress::new(
Duration::from_secs(2),
Duration::from_secs(10),
));
assert_eq!(*seen.lock(), delivered);
}
#[test]
fn published_progress_never_reads_past_the_end() {
let (_guard, _player) = install();
publish_playback_progress(PlaybackProgress {
position: Duration::from_secs(99),
duration: Some(Duration::from_secs(10)),
buffered: Duration::from_secs(99),
});
let progress = playback_progress();
assert_eq!(progress.position, Duration::from_secs(10));
assert_eq!(progress.buffered, Duration::from_secs(10));
}
#[test]
fn playing_holds_the_runtime_awake_and_stopping_lets_it_sleep() {
let (_guard, _player) = install();
assert!(!holds_background_work());
open_media(track()).expect("opens");
assert!(
!holds_background_work(),
"an item that is open but not playing is not work the runtime must keep turning for"
);
play_media().expect("plays");
assert!(holds_background_work());
pause_media();
assert!(!holds_background_work());
play_media().expect("plays");
assert!(holds_background_work());
stop_media();
assert!(!holds_background_work());
}
#[test]
fn a_destroyed_host_stops_playback_but_a_backgrounded_one_does_not() {
let (_guard, _player) = install();
open_media(track()).expect("opens");
play_media().expect("plays");
on_lifecycle(LifecycleEvent {
from: LifecycleState::Resumed,
to: LifecycleState::Stopped,
});
assert!(playback_state().is_playing());
on_lifecycle(LifecycleEvent {
from: LifecycleState::Stopped,
to: LifecycleState::Destroyed,
});
assert_eq!(playback_state(), PlaybackState::Idle);
}
#[test]
fn metadata_learned_after_playback_started_reaches_the_session() {
let (_guard, player) = install();
open_media(MediaItem::new("file:///music/untagged.mp3")).expect("opens");
set_media_metadata(MediaMetadata::titled("Late Tag").artist("Artist"));
assert!(player.calls().contains(&"session Late Tag".to_string()));
assert_eq!(
current_media_item().map(|item| item.metadata.title),
Some("Late Tag".to_string())
);
}
#[test]
fn metadata_with_nothing_in_it_is_metadata_a_lock_screen_can_skip() {
assert!(MediaMetadata::default().is_empty());
assert!(!MediaMetadata::titled("Track").is_empty());
}
#[test]
fn a_path_survives_the_round_trip_through_a_uri() {
let path = PathBuf::from("/music/Sgt. Pepper's #1.mp3");
let uri = uri_for_path(&path);
assert_eq!(uri, "file:///music/Sgt.%20Pepper%27s%20%231.mp3");
assert_eq!(path_from_uri(&uri), Some(path));
}
#[test]
fn a_windows_path_keeps_its_drive_letter() {
let uri = uri_for_path(Path::new("C:\\Music\\track.mp3"));
assert_eq!(uri, "file:///C%3A/Music/track.mp3");
assert_eq!(
path_from_uri(&uri),
Some(PathBuf::from("C:/Music/track.mp3"))
);
}
#[test]
fn a_bare_path_is_accepted_as_itself() {
assert_eq!(
path_from_uri("/music/track.mp3"),
Some(PathBuf::from("/music/track.mp3"))
);
}
#[test]
fn anything_that_is_not_a_local_file_has_no_path() {
assert_eq!(path_from_uri("https://host/stream.mp3"), None);
assert_eq!(path_from_uri("content://media/audio/1"), None);
assert_eq!(path_from_uri("blob:https://host/abc"), None);
assert_eq!(path_from_uri("file://host/share/track.mp3"), None);
assert_eq!(path_from_uri(""), None);
}
#[test]
fn a_truncated_escape_is_not_guessed_at() {
assert_eq!(path_from_uri("file:///music/track%2"), None);
assert_eq!(path_from_uri("file:///music/track%zz.mp3"), None);
}
#[test]
fn speed_and_looping_reach_a_backend_that_has_them() {
let (_guard, player) = install();
assert!(set_media_speed(1.5));
set_media_looping(true);
let calls = player.calls();
assert!(calls.contains(&"speed 1.5".to_string()));
assert!(calls.contains(&"looping true".to_string()));
}
}