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:
Generated
+1
-1
@@ -1994,7 +1994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jellytau"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"async-trait",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "jellytau"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1196,9 +1196,6 @@ pub fn run() {
|
||||
let video_settings = VideoSettingsWrapper(Mutex::new(VideoSettings::default()));
|
||||
app.manage(video_settings);
|
||||
|
||||
// Background-audio handoff base offset (UR-040).
|
||||
app.manage(commands::player::BackgroundAudioOffset::default());
|
||||
|
||||
// Initialize thumbnail cache
|
||||
info!("[INIT] Initializing thumbnail cache...");
|
||||
let app_data_dir = if let Ok(test_data_dir) = std::env::var("JELLYTAU_DATA_DIR") {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "jellytau",
|
||||
"version": "0.2.8",
|
||||
"version": "0.2.9",
|
||||
"identifier": "com.dtourolle.jellytau",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
|
||||
Reference in New Issue
Block a user