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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user