fix(player): return from background audio onto the episode it advanced to
An episode that ends while backgrounded in audio-only mode advances in the
backend, but player_exit_background_audio returned only a position, so the
video page reloaded the episode it was mounted with -- the previous one, at
the new episode's timestamp.
The command now returns BackgroundAudioResume { itemId, positionSeconds }.
planHandoffReturn yields "other-item" when the id differs from the mounted
one, and the player page navigates to that episode with resumeAt=<seconds>,
marking the outgoing episode watched and suppressing its stale stop report.
TRACES: UR-040, UR-023 | DR-296 | UT-265, UT-266
This commit is contained in:
@@ -939,12 +939,17 @@ pub async fn player_background_action(
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
|
||||
/// Returns the item the native player is on and its absolute position. The
|
||||
/// item matters: an episode that ended while backgrounded has already advanced
|
||||
/// in the backend, so reloading the video the webview was mounted with would
|
||||
/// bring back the previous episode. (DR-296)
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn player_exit_background_audio(
|
||||
player: State<'_, PlayerStateWrapper>,
|
||||
) -> Result<f64, String> {
|
||||
) -> Result<crate::player::BackgroundAudioResume, String> {
|
||||
let controller = player.0.lock().await;
|
||||
|
||||
// Read the position BEFORE clearing either base. The position tick applies the
|
||||
@@ -954,23 +959,23 @@ pub async fn player_exit_background_audio(
|
||||
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
|
||||
// (DR-159)
|
||||
//
|
||||
// `absolute_position` rather than `position`, because a tick that has not
|
||||
// `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
|
||||
// landed *yet* is the same hazard from the other side: returning to the
|
||||
// foreground while the audio-only transcode is still opening read 0.0, and
|
||||
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
|
||||
// beginning. Flooring at the handoff base cannot overshoot: the stream is
|
||||
// physically incapable of being behind its own starting point. (DR-178)
|
||||
let absolute = controller.absolute_position();
|
||||
let resume = controller.background_audio_resume();
|
||||
|
||||
// Now safe to tear the handoff down, native side first.
|
||||
let _ = crate::player::set_lockscreen_position_offset(0.0);
|
||||
controller.exit_background_audio();
|
||||
controller.stop().map_err(|e| e.to_string())?;
|
||||
info!(
|
||||
"player_exit_background_audio: resuming the video at {:.1}s",
|
||||
absolute
|
||||
"player_exit_background_audio: resuming {:?} at {:.1}s",
|
||||
resume.item_id, resume.position_seconds
|
||||
);
|
||||
Ok(absolute)
|
||||
Ok(resume)
|
||||
}
|
||||
|
||||
/// Play a queue of media items
|
||||
|
||||
@@ -177,6 +177,20 @@ fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
|
||||
|
||||
/// Where playback stands when a background-audio handoff returns to the
|
||||
/// foreground. See [`PlayerController::background_audio_resume`].
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-296
|
||||
#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BackgroundAudioResume {
|
||||
/// Item the native audio player is on — `None` if the queue emptied (e.g.
|
||||
/// the sleep timer stopped playback while backgrounded).
|
||||
pub item_id: Option<String>,
|
||||
/// Absolute position in that item, in seconds.
|
||||
pub position_seconds: f64,
|
||||
}
|
||||
|
||||
/// Metadata for the lockscreen / media notification.
|
||||
///
|
||||
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
|
||||
@@ -1728,6 +1742,23 @@ impl PlayerController {
|
||||
/// reports are honoured from here on.
|
||||
///
|
||||
/// TRACES: UR-040, UR-005 | DR-052, DR-097
|
||||
/// Where the foreground should pick up from a background-audio handoff: the
|
||||
/// item the native player is on now, and its absolute position.
|
||||
///
|
||||
/// The item is not necessarily the one the handoff started from — an episode
|
||||
/// that ends while backgrounded advances in the backend
|
||||
/// (`advance_to_next_episode_audio_only`) — so the webview must not assume
|
||||
/// it can reload the video it was mounted with. Read-only: call it before
|
||||
/// `exit_background_audio` clears the base the position depends on.
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-296 | UT-266
|
||||
pub fn background_audio_resume(&self) -> BackgroundAudioResume {
|
||||
BackgroundAudioResume {
|
||||
item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
|
||||
position_seconds: self.absolute_position(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exit_background_audio(&self) -> f64 {
|
||||
*self.background_audio_active.lock_safe() = false;
|
||||
self.take_background_audio_base()
|
||||
@@ -4411,6 +4442,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Returning to the foreground after the backend advanced to the next episode
|
||||
/// must bring back THAT episode, not the one the handoff started from.
|
||||
///
|
||||
/// The return used to carry only a position; the webview reloaded the video it
|
||||
/// was mounted with, so the user came back to the previous episode — at the new
|
||||
/// episode's timestamp. The resume point therefore names the item the native
|
||||
/// player is actually on.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-296 | UT-266
|
||||
#[tokio::test]
|
||||
async fn test_background_audio_resume_names_the_advanced_episode() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
let episode = MediaItem {
|
||||
transport: None,
|
||||
id: "ep1".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
series_id: Some("series1".to_string()),
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
controller.enter_background_audio(1200.0);
|
||||
|
||||
let before = controller.background_audio_resume();
|
||||
assert_eq!(before.item_id.as_deref(), Some("ep1"));
|
||||
assert_eq!(before.position_seconds, 1200.0);
|
||||
|
||||
controller
|
||||
.advance_to_next_episode_audio_only("ep2")
|
||||
.await
|
||||
.expect("advance should succeed");
|
||||
|
||||
let after = controller.background_audio_resume();
|
||||
assert_eq!(
|
||||
after.item_id.as_deref(),
|
||||
Some("ep2"),
|
||||
"the foreground must resume the episode the backend advanced to"
|
||||
);
|
||||
assert!(
|
||||
after.position_seconds < 1200.0,
|
||||
"the previous episode's handoff base must not leak into the new one"
|
||||
);
|
||||
}
|
||||
|
||||
/// A background audio-only episode must advance IN THE BACKEND when the
|
||||
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
|
||||
/// countdown the frontend is supposed to act on.
|
||||
|
||||
Reference in New Issue
Block a user