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:
2026-09-24 03:45:59 +02:00
parent f6efa7208a
commit 1fb5f070c8
9 changed files with 240 additions and 24 deletions
+12 -7
View File
@@ -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
+77
View File
@@ -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.