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
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:
@@ -915,39 +915,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Background audio-only episode: the frontend that normally
|
||||
// performs the advance (goto /player/<id>) is suspended, so
|
||||
// the backend must load the next episode's audio-only stream
|
||||
// itself — otherwise playback just stops at the boundary.
|
||||
let is_bg_audio_episode =
|
||||
controller.lock().await.current_is_audio_episode();
|
||||
if is_bg_audio_episode {
|
||||
log::info!(
|
||||
"[Autoplay] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"[Autoplay] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
} else {
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
} else {
|
||||
// Foreground: frontend drives the advance off the countdown.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+202
-4
@@ -105,7 +105,7 @@ pub fn set_lockscreen_position_offset(_offset_seconds: f64) -> Result<(), String
|
||||
}
|
||||
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::{debug, error, warn};
|
||||
use log::{debug, error, info, warn};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
@@ -153,6 +153,21 @@ pub struct PlayerController {
|
||||
// Auto-play episode counter (session-based, resets on manual play)
|
||||
autoplay_episode_count: Arc<Mutex<u32>>,
|
||||
|
||||
// Base offset (seconds) of the active background-audio handoff.
|
||||
//
|
||||
// The audio-only stream is requested with `StartTimeTicks` = the position the
|
||||
// video was handed off at, so the server makes that point the stream's zero
|
||||
// and the native player reports position RELATIVE to it. Adding this base back
|
||||
// yields the absolute position to resume the video at on the way out.
|
||||
//
|
||||
// Lives on the controller (not beside the command) because the queue and this
|
||||
// offset describe the same stream: whenever the controller loads a different
|
||||
// one — notably the backend-driven advance to the next episode — the base has
|
||||
// to move with it.
|
||||
//
|
||||
// TRACES: UR-040 | DR-052
|
||||
background_audio_base: Arc<Mutex<f64>>,
|
||||
|
||||
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
|
||||
//
|
||||
// Webview-rendered media is played by an element the native backend cannot
|
||||
@@ -185,6 +200,7 @@ impl PlayerController {
|
||||
position_throttler,
|
||||
end_reason: Arc::new(Mutex::new(None)),
|
||||
autoplay_episode_count: Arc::new(Mutex::new(0)),
|
||||
background_audio_base: Arc::new(Mutex::new(0.0)),
|
||||
html5_playing: Arc::new(Mutex::new(None)),
|
||||
};
|
||||
|
||||
@@ -1170,6 +1186,68 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the base offset of a background-audio handoff (the position the
|
||||
/// video was handed off at, which is the audio stream's zero).
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
pub fn set_background_audio_base(&self, seconds: f64) {
|
||||
*self.background_audio_base.lock_safe() = seconds.max(0.0);
|
||||
}
|
||||
|
||||
/// Read and clear the background-audio base offset.
|
||||
///
|
||||
/// TRACES: UR-040 | DR-052
|
||||
pub fn take_background_audio_base(&self) -> f64 {
|
||||
let mut base = self.background_audio_base.lock_safe();
|
||||
std::mem::replace(&mut *base, 0.0)
|
||||
}
|
||||
|
||||
/// Perform the auto-advance for a `ShowNextEpisodePopup` decision.
|
||||
///
|
||||
/// Single place both end-of-playback dispatchers agree on: the Android JNI
|
||||
/// callback (`nativeOnPlaybackEnded`) and the frontend-invoked command
|
||||
/// (`player_on_playback_ended`). They used to each carry their own copy of
|
||||
/// this branch, and the command's copy was missing the background-audio case
|
||||
/// entirely — so an audio-only episode ending while backgrounded only ever
|
||||
/// started a countdown that nothing could act on.
|
||||
///
|
||||
/// TRACES: UR-040, UR-023 | DR-052
|
||||
pub async fn auto_advance_to_next_episode(
|
||||
&self,
|
||||
next_episode: crate::repository::types::MediaItem,
|
||||
countdown_seconds: u32,
|
||||
) {
|
||||
// Background audio-only episode: the countdown only emits ticks — the
|
||||
// advance itself is a `goto('/player/<id>')` in the webview, which cannot
|
||||
// start audio while the app is backgrounded. Load the next episode's
|
||||
// audio-only stream here instead, or playback stalls at the boundary.
|
||||
if self.current_is_audio_episode() {
|
||||
info!(
|
||||
"[PlayerController] Background audio episode — advancing to {} in backend",
|
||||
next_episode.id
|
||||
);
|
||||
match self
|
||||
.advance_to_next_episode_audio_only(&next_episode.id)
|
||||
.await
|
||||
{
|
||||
Ok(()) => self.emit_queue_changed(),
|
||||
Err(e) => {
|
||||
error!(
|
||||
"[PlayerController] Background audio advance failed: {} — stopping",
|
||||
e
|
||||
);
|
||||
if let Some(emitter) = self.event_emitter() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Foreground: the frontend drives the advance off the countdown ticks.
|
||||
self.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
}
|
||||
|
||||
/// Advance to the next episode while playing audio-only in the background.
|
||||
///
|
||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||
@@ -1180,10 +1258,10 @@ impl PlayerController {
|
||||
///
|
||||
/// `next_episode_id` is the Jellyfin item ID of the episode to play next.
|
||||
///
|
||||
/// Called from the Android autoplay dispatch (`#[cfg(android)]`); compiled and
|
||||
/// unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
/// Reached through `auto_advance_to_next_episode`, which gates it on
|
||||
/// `current_is_audio_episode()` — only ever true after a background-audio
|
||||
/// handoff (Android), but compiled and unit-tested on every platform.
|
||||
/// TRACES: UR-040, UR-023 | DR-052
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub async fn advance_to_next_episode_audio_only(
|
||||
&self,
|
||||
next_episode_id: &str,
|
||||
@@ -1238,6 +1316,13 @@ impl PlayerController {
|
||||
server_id: Some(next.server_id.clone()),
|
||||
};
|
||||
|
||||
// The previous episode's handoff base described the stream we are leaving.
|
||||
// This one is built without StartTimeTicks, so its timeline is already
|
||||
// absolute: clear the base (used to resolve the resume position on the way
|
||||
// back to the foreground) and the lockscreen scrubber's matching shift.
|
||||
self.set_background_audio_base(0.0);
|
||||
let _ = set_lockscreen_position_offset(0.0);
|
||||
|
||||
self.play_item(media_item).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -2994,6 +3079,119 @@ mod tests {
|
||||
assert!(controller.current_is_audio_episode());
|
||||
}
|
||||
|
||||
/// The handoff base offset describes ONE stream: the audio-only URL built
|
||||
/// with `StartTimeTicks` = the position the video was handed off at, whose
|
||||
/// timeline therefore starts at that point. The next episode is loaded from
|
||||
/// its own beginning, so its timeline is already absolute and the base must
|
||||
/// be cleared — otherwise returning to the foreground resolves the resume
|
||||
/// position as `old_base + position_in_new_episode` and the video jumps to a
|
||||
/// point that has nothing to do with what was playing.
|
||||
#[tokio::test]
|
||||
async fn test_advance_to_next_episode_audio_only_clears_handoff_base() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
// Handed off 20 minutes into the previous episode.
|
||||
controller.set_background_audio_base(1200.0);
|
||||
|
||||
controller
|
||||
.advance_to_next_episode_audio_only("ep2")
|
||||
.await
|
||||
.expect("advance should succeed");
|
||||
|
||||
assert_eq!(
|
||||
controller.take_background_audio_base(),
|
||||
0.0,
|
||||
"the next episode starts at its own zero, so the previous handoff \
|
||||
base must not survive the advance"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The countdown only emits CountdownTick events; the actual advance is a
|
||||
/// `goto('/player/<id>')` in the webview. While the app is backgrounded that
|
||||
/// navigation cannot start audio, so playback stalls at the episode boundary
|
||||
/// with ExoPlayer parked in STATE_ENDED — and any later play intent
|
||||
/// (lockscreen, headset, Bluetooth reconnect) replays the ended item from the
|
||||
/// start, which is what surfaces to the user as "the episode randomly
|
||||
/// restarted".
|
||||
#[tokio::test]
|
||||
async fn test_auto_advance_background_audio_episode_advances_in_backend() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
// Currently playing: ep2 handed off to audio-only background playback.
|
||||
let episode = MediaItem {
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio,
|
||||
series_id: Some("series1".to_string()),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep2-audio.mp3".to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
|
||||
let next = make_repo_episode("ep3", 3);
|
||||
controller.auto_advance_to_next_episode(next, 10).await;
|
||||
|
||||
let current = controller
|
||||
.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.cloned()
|
||||
.expect("an item should still be loaded");
|
||||
assert_eq!(
|
||||
current.id, "ep3",
|
||||
"background audio-only episode must advance in the backend, not wait \
|
||||
for a frontend navigation that cannot happen while backgrounded"
|
||||
);
|
||||
assert_eq!(current.media_type, MediaType::Audio);
|
||||
assert!(controller.current_is_audio_episode());
|
||||
}
|
||||
|
||||
/// Foreground video playback keeps the countdown-driven advance: the frontend
|
||||
/// owns the navigation there, so the backend must NOT load the next episode
|
||||
/// itself (that would race the page transition and double-start playback).
|
||||
#[tokio::test]
|
||||
async fn test_auto_advance_foreground_video_episode_uses_countdown() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
let episode = MediaItem {
|
||||
id: "ep2".to_string(),
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Video,
|
||||
series_id: Some("series1".to_string()),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep2.m3u8".to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
|
||||
let next = make_repo_episode("ep3", 3);
|
||||
controller.auto_advance_to_next_episode(next, 10).await;
|
||||
|
||||
let current = controller
|
||||
.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.cloned()
|
||||
.expect("an item should still be loaded");
|
||||
assert_eq!(
|
||||
current.id, "ep2",
|
||||
"foreground video advance is frontend-driven; the backend must not \
|
||||
swap the queue item out from under it"
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a controller repository the Android episode path must still
|
||||
/// stop gracefully (previous behavior) rather than error.
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user