fix(player): make lockscreen transport reach background audio (DR-097)

Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.

The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.

A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.

TRACES: UR-040, UR-005 | DR-052, DR-097
This commit is contained in:
2026-08-05 12:26:25 +02:00
parent 6aaa80ff92
commit 878ac5fa59
5 changed files with 196 additions and 4 deletions
+2 -2
View File
@@ -714,7 +714,7 @@ pub async fn player_enter_background_audio(
// this base to the native player's relative position to get the absolute one.
// The controller owns it so a backend-driven advance to the next episode
// clears it along with the stream it described.
controller.set_background_audio_base(position_seconds);
controller.enter_background_audio(position_seconds);
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
@@ -753,7 +753,7 @@ pub async fn player_exit_background_audio(
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Zero after a backend-driven
// episode advance, whose stream already starts at its own zero.
let base = controller.take_background_audio_base();
let base = controller.exit_background_audio();
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position();
+143
View File
@@ -179,6 +179,17 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-052
background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
//
// TRACES: UR-040 | DR-052, DR-097
background_audio_active: Arc<Mutex<bool>>,
// Budget for re-opening a stream that ended short of the item's runtime.
//
// A resume re-requests the same URL, so a server that is genuinely gone would
@@ -221,6 +232,7 @@ impl PlayerController {
end_reason: Arc::new(Mutex::new(None)),
autoplay_episode_count: Arc::new(Mutex::new(0)),
background_audio_base: Arc::new(Mutex::new(0.0)),
background_audio_active: Arc::new(Mutex::new(false)),
stream_resume: Arc::new(Mutex::new(stream_end::ResumeTracker::default())),
html5_playing: Arc::new(Mutex::new(None)),
};
@@ -1000,6 +1012,14 @@ impl PlayerController {
/// Re-emits a `StateChanged` event identical to what MpvBackend/ExoPlayer
/// would emit, so `playerEvents.ts` needs no HTML5-specific branch.
pub fn report_html5_state(&self, state: String, media_id: Option<String>) {
// A background-audio handoff has already moved playback to the native
// player and torn the element down; anything it still reports describes
// a video that is no longer playing. Dropping it keeps the UI on the
// audio that IS playing and leaves transport with the native backend.
if self.is_background_audio_active() {
debug!("[PlayerController] Ignoring HTML5 state '{state}' during background audio");
return;
}
// Track it: this is the authoritative play/pause state for
// webview-rendered media, and what transport decisions read (DR-097).
// "stopped"/"idle" mean the element is gone, so hand authority back to
@@ -1027,6 +1047,11 @@ impl PlayerController {
/// Re-emits a `PositionUpdate` event mirroring the native backends' periodic
/// position updates (the adapter is expected to throttle to ~250ms like MPV).
pub fn report_html5_position(&self, position: f64, duration: f64) {
// Stale by definition during a handoff — the native player's ticks are
// the real position. See `report_html5_state`.
if self.is_background_audio_active() {
return;
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::PositionUpdate { position, duration });
}
@@ -1035,6 +1060,10 @@ impl PlayerController {
/// Report that the HTML5 <video> element finished loading and knows its
/// duration. Mirrors the native `MediaLoaded` event.
pub fn report_html5_media_loaded(&self, duration: f64) {
// See `report_html5_state` — the element is not the player right now.
if self.is_background_audio_active() {
return;
}
if let Some(emitter) = self.event_emitter.lock_safe().as_ref() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
@@ -1234,6 +1263,42 @@ impl PlayerController {
*self.background_audio_base.lock_safe() = seconds.max(0.0);
}
/// Enter a background-audio handoff at `position` (the video's position, and
/// therefore the audio stream's zero).
///
/// Hands transport authority to the native audio player: the webview
/// `<video>` is about to be torn down, so its last reports — including the
/// `pause` the teardown itself fires — must not keep it looking like the
/// player. Without this the lockscreen pause emitted a ControlCommand at a
/// dead element and the audio played straight through it.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn enter_background_audio(&self, position: f64) {
self.set_background_audio_base(position);
*self.background_audio_active.lock_safe() = true;
*self.html5_playing.lock_safe() = None;
}
/// Leave a background-audio handoff, returning the base offset to add to the
/// native player's relative position.
///
/// The webview `<video>` becomes the player again once it reloads, so its
/// reports are honoured from here on.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
pub fn exit_background_audio(&self) -> f64 {
*self.background_audio_active.lock_safe() = false;
self.take_background_audio_base()
}
/// True while the native audio player owns playback via a background-audio
/// handoff.
///
/// TRACES: UR-040 | DR-052
pub fn is_background_audio_active(&self) -> bool {
*self.background_audio_active.lock_safe()
}
/// Read and clear the background-audio base offset.
///
/// TRACES: UR-040 | DR-052
@@ -2002,6 +2067,84 @@ mod tests {
assert_eq!(controls, vec!["play".to_string(), "pause".to_string()]);
}
#[test]
fn test_background_audio_handoff_moves_transport_to_native_backend() {
// Lockscreen pause while playing a video's audio in the background.
//
// The handoff tears the WebView <video> down AFTER native audio starts,
// and that teardown fires a DOM `pause` the frontend dutifully reports.
// That report used to leave `html5_playing = Some(false)`, so transport
// kept being aimed at an element that no longer exists: the lockscreen
// pause emitted a ControlCommand into the void and the audio played on.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
// Video was playing in the webview.
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
// Hand off to the native audio player, then tear the element down.
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
assert!(
!controller.is_html5_active(),
"native audio owns transport during a background-audio handoff"
);
controller.pause().unwrap();
let controls: Vec<_> = emitter
.events()
.into_iter()
.filter_map(|e| match e {
PlayerStatusEvent::ControlCommand { action, .. } => Some(action),
_ => None,
})
.collect();
assert!(
controls.is_empty(),
"pause must drive the native backend, not a torn-down element: {:?}",
controls
);
}
#[test]
fn test_background_audio_handoff_suppresses_stale_element_events() {
// The dying element's pause/position reports describe the video, not the
// audio now playing — re-emitting them flips the UI to paused and yanks
// the position backwards while native audio keeps going.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
controller.report_html5_state("paused".to_string(), Some("ep-1".to_string()));
controller.report_html5_position(1200.0, 2400.0);
assert!(
emitter.events().is_empty(),
"stale webview reports must not reach the event pipeline: {:?}",
emitter.events()
);
}
#[test]
fn test_exit_background_audio_returns_transport_to_the_webview() {
// Back in the foreground the <video> is the player again, so its reports
// must be honoured — and the base offset still comes back for the resume.
let controller = PlayerController::default();
let emitter = Arc::new(CapturingEmitter::new());
controller.set_event_emitter(emitter.clone());
controller.enter_background_audio(1200.0);
assert_eq!(controller.exit_background_audio(), 1200.0);
controller.report_html5_state("playing".to_string(), Some("ep-1".to_string()));
assert!(controller.is_html5_active());
assert!(controller.html5_is_playing());
}
#[test]
fn test_html5_stopped_report_releases_transport_to_native_backend() {
// When webview video goes away, transport must fall back to the native