Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@

## 0.1.5 - 2026-08-03

### System media controls and background audio

- Added Now Playing metadata, playback state, timeline, play/pause/seek, and
previous/next navigation controls across iOS, tvOS, macOS, Android, Windows,
and OpenHarmony.
- Added opt-in background audio playback for iOS and Android while suspending
video decoding, with bounded playback-worker barriers so lifecycle callbacks
cannot block an application thread indefinitely.
- Cleared stale metadata when opening media without `ErikaMediaMetadata`, made
pause/play sequencing deterministic, and isolated Android media callbacks
across multiple Flutter engines.
- Cached Android artwork and avoided rebuilding media notifications when native
playback state has not changed.

### Platform support and release artifacts

- Added the tvOS Flutter plugin and native target support for Apple TV devices
Expand Down
170 changes: 165 additions & 5 deletions crates/erika/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -909,6 +909,10 @@ enum PlaybackCommand {
quiesced: bool,
reply: Sender<()>,
},
SetVideoDecodeSuspended {
suspended: bool,
reply: Sender<std::result::Result<(), String>>,
},
Shutdown,
}

Expand Down Expand Up @@ -1253,6 +1257,9 @@ impl Player {
commands
.send(PlaybackCommand::Pause { sequence })
.map_err(|_| PlayerError::Playback("playback worker is not running".to_string()))?;
// Publish the paused intent immediately so a sequential pause/play pair
// cannot race the worker and observe a stale Playing state. The worker
// still replaces the parked clock with its authoritative media time.
let _ = commit_playback_command_intent(
&self.inner,
sequence,
Expand Down Expand Up @@ -1439,6 +1446,58 @@ impl Player {
self.set_frame_output_quiesced_with_timeout(quiesced, FRAME_OUTPUT_BARRIER_TIMEOUT)
}

pub(crate) fn set_video_decode_suspended(&self, suspended: bool) -> Result<bool> {
self.set_video_decode_suspended_with_timeout(suspended, FRAME_OUTPUT_BARRIER_TIMEOUT)
}

fn set_video_decode_suspended_with_timeout(
&self,
suspended: bool,
timeout: Duration,
) -> Result<bool> {
let Some(commands) = self.optional_playback_commands() else {
return Ok(false);
};
let (reply, response) = bounded(1);
let requested_at = Instant::now();
match commands.send_timeout(
PlaybackCommand::SetVideoDecodeSuspended { suspended, reply },
timeout,
) {
Ok(()) => {}
Err(crossbeam_channel::SendTimeoutError::Timeout(_)) => {
return Err(PlayerError::Playback(format!(
"timed out after {} ms while sending video decode mode to the playback worker",
timeout.as_millis(),
)));
}
Err(crossbeam_channel::SendTimeoutError::Disconnected(_)) => {
return Err(PlayerError::Playback(
"playback worker is not running".to_string(),
));
}
}
let remaining = timeout.saturating_sub(requested_at.elapsed());
match response.recv_timeout(remaining) {
Ok(result) => result.map_err(PlayerError::Playback)?,
Err(RecvTimeoutError::Timeout) => {
if suspended {
enqueue_video_decode_resume_after_timeout(&commands);
}
return Err(PlayerError::Playback(format!(
"playback worker did not acknowledge video decode mode within {} ms",
timeout.as_millis(),
)));
}
Err(RecvTimeoutError::Disconnected) => {
return Err(PlayerError::Playback(
"playback worker stopped before acknowledging video decode mode".to_string(),
));
}
}
Ok(true)
}

fn set_frame_output_quiesced_with_timeout(
&self,
quiesced: bool,
Expand Down Expand Up @@ -2278,11 +2337,12 @@ fn handle_playback_command(
return true;
}
engine.pause();
let position = engine.media_time();
let _ = commit_playback_command_intent(
inner,
sequence,
None,
None,
Some(position),
Some(PlayerState::Paused),
);
}
Expand Down Expand Up @@ -2540,6 +2600,20 @@ fn handle_playback_command(
);
let _ = reply.send(());
}
PlaybackCommand::SetVideoDecodeSuspended { suspended, reply } => {
let result = engine
.set_video_decode_suspended(suspended)
.map_err(|error| error.to_string());
trace::diagnostic(
serde_json::json!({
"event": "player_video_decode",
"stage": if suspended { "suspended" } else { "resumed_at_keyframe" },
"generation": *playback_generation,
})
.to_string(),
);
let _ = reply.send(result);
}
PlaybackCommand::Shutdown => return false,
}
true
Expand Down Expand Up @@ -2873,6 +2947,15 @@ fn enqueue_frame_output_resume_after_timeout(commands: &Sender<PlaybackCommand>)
}
}

fn enqueue_video_decode_resume_after_timeout(commands: &Sender<PlaybackCommand>) {
let (reply, response) = bounded(1);
drop(response);
let _ = commands.try_send(PlaybackCommand::SetVideoDecodeSuspended {
suspended: false,
reply,
});
}

fn set_state_from_worker(inner: &Arc<Mutex<PlayerInner>>, next: PlayerState) {
let previous = {
let mut inner = inner.lock().expect("player mutex poisoned");
Expand Down Expand Up @@ -3217,6 +3300,61 @@ mod tests {
assert!(!player.set_frame_output_quiesced(true).unwrap());
}

#[test]
fn video_decode_mode_is_a_noop_without_open_media() {
let player = Player::new(PlayerConfig::default());
assert!(!player.set_video_decode_suspended(true).unwrap());
}

#[test]
fn video_decode_mode_uses_an_acknowledged_worker_command() {
let player = Player::new(PlayerConfig::default());
let receiver = install_test_runtime(&player, 2);
let worker = thread::spawn(move || match receiver.recv().unwrap() {
PlaybackCommand::SetVideoDecodeSuspended {
suspended: true,
reply,
} => reply.send(Ok(())).unwrap(),
_ => panic!("unexpected playback command"),
});

assert!(player.set_video_decode_suspended(true).unwrap());
worker.join().unwrap();
}

#[test]
fn video_decode_mode_reports_an_unresponsive_worker() {
let player = Player::new(PlayerConfig::default());
let receiver = install_test_runtime(&player, 2);
let started = Instant::now();

let error = player
.set_video_decode_suspended_with_timeout(true, Duration::from_millis(25))
.unwrap_err();

assert!(
error
.to_string()
.contains("did not acknowledge video decode mode")
);
assert!(started.elapsed() < Duration::from_secs(1));
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)).unwrap(),
PlaybackCommand::SetVideoDecodeSuspended {
suspended: true,
..
}
));
assert!(matches!(
receiver.recv_timeout(Duration::from_secs(1)).unwrap(),
PlaybackCommand::SetVideoDecodeSuspended {
suspended: false,
..
}
));
drop(receiver);
}

#[test]
fn frame_output_barrier_reports_an_unresponsive_worker() {
let player = Player::new(PlayerConfig::default());
Expand Down Expand Up @@ -3494,8 +3632,9 @@ mod tests {
}

#[test]
fn pause_publishes_a_parked_clock_with_the_paused_state() {
fn pause_publishes_intent_before_worker_position() {
let player = Player::new(PlayerConfig::default());
let events = player.subscribe();
let commands = install_test_runtime(&player, 1);
let anchor = Instant::now();
{
Expand All @@ -3506,10 +3645,31 @@ mod tests {

player.pause().unwrap();

assert!(matches!(
commands.recv_timeout(Duration::from_secs(1)).unwrap(),
PlaybackCommand::Pause { .. }
let sequence = match commands.recv_timeout(Duration::from_secs(1)).unwrap() {
PlaybackCommand::Pause { sequence } => sequence,
_ => panic!("expected pause command"),
};
assert_eq!(player.state(), PlayerState::Paused);
assert_eq!(
events.recv().unwrap(),
PlayerEvent::StateChanged(PlayerState::Paused)
);

let position = Duration::from_millis(9_250);
assert!(commit_playback_command_intent(
&player.inner,
sequence,
None,
Some(position),
Some(PlayerState::Paused),
));
assert_eq!(
events.recv().unwrap(),
PlayerEvent::PositionChanged(position)
);
assert!(events.try_recv().is_err());
assert_eq!(player.current_media_time(), position);
assert_eq!(player.state(), PlayerState::Paused);
let snapshot = player.playback_snapshot();
assert_eq!(snapshot.state, PlayerState::Paused);
assert!(!snapshot.clock.is_running());
Expand Down
Loading
Loading