fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s

An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, and ExoPlayer parked in STATE_ENDED —
where any later play intent (lockscreen, headset, Bluetooth reconnect) replays
the ended item, surfacing as the episode randomly restarting.

End-of-playback is dispatched from two places and they disagreed. The Android
JNI callback carried the background-audio branch but can never reach it:
load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears
it, so the first real end consumes it and the decision is always Stop. The call
that actually decides is the frontend's echo of the resulting PlaybackEnded into
player_on_playback_ended — and that path had no background-audio case at all, so
it started a countdown whose advance is a webview goto() that cannot start audio
while backgrounded.

Both dispatchers now share PlayerController::auto_advance_to_next_episode, so
they cannot drift apart again.

The handoff base offset moves from the BackgroundAudioOffset Tauri state onto
the controller, and the advance clears it: the next episode's stream is built
without StartTimeTicks, so its timeline is already absolute and a stale base
made player_exit_background_audio return old_base + position_in_new_episode.
Unreachable until the advance actually worked.

Tests (red before the fix):
- test_auto_advance_background_audio_episode_advances_in_backend
- test_auto_advance_foreground_video_episode_uses_countdown
- test_advance_to_next_episode_audio_only_clears_handoff_base

Bump to 0.2.9.
This commit is contained in:
2026-08-02 18:10:18 +02:00
parent 9d099268b9
commit a26a853f01
11 changed files with 401 additions and 211 deletions
+10 -28
View File
@@ -57,18 +57,6 @@ pub struct MediaSessionManagerWrapper(pub Mutex<MediaSessionManager>);
/// @req: DR-048 - Video settings (auto-play toggle, countdown duration)
pub struct VideoSettingsWrapper(pub Mutex<VideoSettings>);
/// Base offset (seconds) for the active background-audio handoff.
///
/// The audio-only stream is requested with `StartTimeTicks` = the handoff
/// position, so the server makes that point the stream's zero. ExoPlayer then
/// reports position RELATIVE to that zero. To convert back to an absolute
/// position on exit (so the video resumes where the audio actually reached), we
/// add this stored base to the native player's reported position.
///
/// TRACES: UR-040 | DR-052
#[derive(Default)]
pub struct BackgroundAudioOffset(pub Mutex<f64>);
/// Response for player state queries
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -586,7 +574,6 @@ pub async fn player_play_item(
pub async fn player_enter_background_audio(
player: State<'_, PlayerStateWrapper>,
session: State<'_, MediaSessionManagerWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
item: PlayItemRequest,
position_seconds: f64,
) -> Result<PlayerStatus, String> {
@@ -636,17 +623,18 @@ pub async fn player_enter_background_audio(
session_mgr.start_audio_session(media_item.clone());
}
// Remember where the video was: the audio stream's zero == this position
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
// this base to the native player's relative position to get the absolute one.
*bg_offset.0.lock().map_err(|e| e.to_string())? = position_seconds.max(0.0);
// Same base offset drives the lockscreen scrubber: ExoPlayer reports position
// relative to the stream's StartTimeTicks zero, but the metadata duration is
// absolute, so shift the reported position back to absolute for the scrubber.
let _ = crate::player::set_lockscreen_position_offset(position_seconds.max(0.0));
let controller = player.0.lock().await;
// Remember where the video was: the audio stream's zero == this position
// (the URL was built with StartTimeTicks=position_seconds), so on exit we add
// 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
.play_item(media_item)
.map_err(|e| e.to_string())?;
@@ -677,21 +665,15 @@ pub async fn player_enter_background_audio(
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
bg_offset: State<'_, BackgroundAudioOffset>,
) -> Result<f64, String> {
// The base offset (handoff position) + native player's relative position =
// the absolute position to resume the video at. Read/reset the base first.
let base = {
let mut off = bg_offset.0.lock().map_err(|e| e.to_string())?;
let b = *off;
*off = 0.0;
b
};
// Back to foreground playback: the lockscreen scrubber is absolute again.
let _ = crate::player::set_lockscreen_position_offset(0.0);
let controller = player.0.lock().await;
// 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();
// Capture position into a `let` BEFORE stop() — never hold work across a lock
// re-entrant call (deadlock discipline, CLAUDE.md).
let relative = controller.position();
+9 -2
View File
@@ -141,6 +141,8 @@ pub async fn player_play_next_episode(
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly
///
/// TRACES: UR-023, UR-026, UR-040 | DR-047, DR-052
#[tauri::command]
#[specta::specta]
pub async fn player_on_playback_ended(
@@ -242,12 +244,17 @@ pub async fn player_on_playback_ended(
});
}
// Start countdown if auto_advance enabled
// Advance if auto_advance is enabled. This is the path that actually
// runs on Android: the JNI callback's own decision is swallowed by the
// NewTrackLoaded end reason set at load, so it returns Stop, emits
// PlaybackEnded, and the frontend echoes it back into this command —
// which is where the real decision lands.
if auto_advance {
controller_arc
.lock()
.await
.start_autoplay_countdown(next_episode, countdown_seconds);
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
}