fix(autoplay): advance to the next episode in background audio mode
An episode handed off to the audio-only path for background playback is a MediaType::Audio item, so autoplay's video-only checks stopped recognising it as an episode: playback simply ended at the episode boundary instead of continuing to the next one. - Carry episode identity (item_type, series_id) through the background-audio handoff so the backend queue item still knows it's an episode; is_episode_item now trusts item_type over the media_type heuristic, and the sleep timer's episode counter follows. - The frontend normally performs the advance by navigating to /player/<id>, which is unavailable while the WebView is suspended. advance_to_next_episode_audio_only drives it entirely in the backend: fetch the next episode, build its audio-only stream URL, and load it into the native audio player, preserving episode identity so the following boundary advances too. - Android's autoplay dispatch routes background-audio episodes to that backend advance and keeps the countdown path for the foreground. - get_audio_only_stream_url_for_video joins the MediaRepository trait (online delegates to the existing builder, offline errors) so the controller can reach it without a frontend round-trip. TRACES: UR-040, UR-023 | DR-052 | JA-032
This commit is contained in:
@@ -858,12 +858,40 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
});
|
||||
}
|
||||
|
||||
// Start countdown if auto_advance enabled
|
||||
if auto_advance {
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+200
-10
@@ -658,6 +658,24 @@ impl PlayerController {
|
||||
self.queue.clone()
|
||||
}
|
||||
|
||||
/// True when the current item is a TV episode being played in audio-only
|
||||
/// (background) mode — i.e. an `item_type == "Episode"` item loaded as
|
||||
/// `MediaType::Audio`. Used to decide whether the backend must drive the
|
||||
/// next-episode advance itself (the frontend is suspended in the background).
|
||||
///
|
||||
/// Only *called* from the Android autoplay dispatch (`#[cfg(android)]`), but
|
||||
/// compiled and unit-tested on the host, hence `allow(dead_code)` off-Android.
|
||||
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
|
||||
pub fn current_is_audio_episode(&self) -> bool {
|
||||
self.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.map(|item| {
|
||||
item.media_type == MediaType::Audio && item.item_type.as_deref() == Some("Episode")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
|
||||
/// sleep timer fires or the queue ends with repeat off). Pair with
|
||||
/// `emit_queue_changed` so the frontend hides the mini player.
|
||||
@@ -963,9 +981,11 @@ impl PlayerController {
|
||||
return Ok(AutoplayDecision::Stop);
|
||||
}
|
||||
SleepTimerMode::Episodes { .. } => {
|
||||
// Only count TV episodes (not audio tracks or movies)
|
||||
let is_episode =
|
||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
||||
// Only count TV episodes (not audio tracks or movies). Note an
|
||||
// episode played in background-audio mode is MediaType::Audio, so
|
||||
// rely on is_episode_item (which checks item_type) rather than the
|
||||
// media_type alone.
|
||||
let is_episode = self.is_episode_item(¤t).await;
|
||||
|
||||
if is_episode {
|
||||
let should_stop = self.sleep_timer.lock_safe().decrement_episode();
|
||||
@@ -981,10 +1001,12 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
// For video episodes, fetch next episode and show popup
|
||||
// For episodes, fetch next episode and show popup.
|
||||
// Note: This path is typically not hit for HTML5 video (which uses on_video_playback_ended).
|
||||
// It's here for the Android ExoPlayer path where video items may be in the backend queue.
|
||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
||||
// It's here for the Android ExoPlayer path where episode items sit in the
|
||||
// backend queue — including background-audio mode, where the episode is a
|
||||
// MediaType::Audio item, so gate on is_episode_item (item_type), not media_type.
|
||||
if self.is_episode_item(¤t).await {
|
||||
let repo = self.repository.lock_safe().clone();
|
||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||
let next_ep_result = if let Some(repo) = &repo {
|
||||
@@ -1042,6 +1064,77 @@ impl PlayerController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Advance to the next episode while playing audio-only in the background.
|
||||
///
|
||||
/// The normal autoplay-next path navigates the frontend to `/player/<id>`,
|
||||
/// which is unavailable when the app is backgrounded and the WebView is
|
||||
/// suspended. This drives the advance entirely in the backend: build the next
|
||||
/// episode's *audio-only* stream URL and load it into the native audio player,
|
||||
/// so playback continues without any frontend involvement (UR-040).
|
||||
///
|
||||
/// `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.
|
||||
/// 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,
|
||||
) -> Result<(), String> {
|
||||
let repo = self
|
||||
.repository
|
||||
.lock_safe()
|
||||
.clone()
|
||||
.ok_or_else(|| "No repository for background episode advance".to_string())?;
|
||||
|
||||
// Details for session metadata (title/series/artwork) and the stream URL.
|
||||
let next = repo
|
||||
.get_item(next_episode_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch next episode {}: {}", next_episode_id, e))?;
|
||||
|
||||
// Audio-only transcode from the start of the episode (no resume offset —
|
||||
// a freshly-started next episode always plays from the beginning).
|
||||
let stream_url = repo
|
||||
.get_audio_only_stream_url_for_video(next_episode_id, None, None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to build audio-only URL for next episode: {}", e))?;
|
||||
|
||||
let media_item = MediaItem {
|
||||
id: next.id.clone(),
|
||||
title: next.name.clone(),
|
||||
name: Some(next.name.clone()),
|
||||
artist: next.series_name.clone(),
|
||||
album: None,
|
||||
album_name: None,
|
||||
album_id: None,
|
||||
artist_items: None,
|
||||
artists: None,
|
||||
primary_image_tag: next.primary_image_tag.clone(),
|
||||
image_id: next.image_id.clone().or(next.primary_image_tag.clone()),
|
||||
// Preserve episode identity so the NEXT end-of-track also advances.
|
||||
item_type: Some("Episode".to_string()),
|
||||
playlist_id: None,
|
||||
duration: next.duration_ms.map(|ms| ms as f64 / 1000.0),
|
||||
artwork_url: None,
|
||||
media_type: MediaType::Audio,
|
||||
source: MediaSource::Remote {
|
||||
stream_url,
|
||||
jellyfin_item_id: next.id.clone(),
|
||||
},
|
||||
video_codec: None,
|
||||
needs_transcoding: false,
|
||||
video_width: None,
|
||||
video_height: None,
|
||||
subtitles: vec![],
|
||||
series_id: next.series_id.clone(),
|
||||
server_id: Some(next.server_id.clone()),
|
||||
};
|
||||
|
||||
self.play_item(media_item).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Handle video playback ended from HTML5 video element.
|
||||
///
|
||||
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||
@@ -1135,11 +1228,19 @@ impl PlayerController {
|
||||
Ok(AutoplayDecision::Stop)
|
||||
}
|
||||
|
||||
/// Check if a media item is an episode (has Jellyfin ID to query)
|
||||
/// Check if a media item is an episode (has Jellyfin ID to query).
|
||||
///
|
||||
/// An explicit `item_type == "Episode"` wins so that a TV episode handed off
|
||||
/// to the audio path for background playback (UR-040) is still recognised as
|
||||
/// an episode — otherwise autoplay would fall through to the queue-based
|
||||
/// audio path, find nothing next, and stop at the episode boundary. When the
|
||||
/// type is unknown we fall back to the historical heuristic (video == episode).
|
||||
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||
// For now, assume video items are episodes
|
||||
// In production, we'd check item metadata or query Jellyfin
|
||||
item.media_type == MediaType::Video
|
||||
match item.item_type.as_deref() {
|
||||
Some("Episode") => true,
|
||||
Some(_) => item.media_type == MediaType::Video,
|
||||
None => item.media_type == MediaType::Video,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch next episode for a series by looking up the season's episodes
|
||||
@@ -2311,6 +2412,15 @@ mod tests {
|
||||
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||
unimplemented!()
|
||||
}
|
||||
async fn get_audio_only_stream_url_for_video(
|
||||
&self,
|
||||
item_id: &str,
|
||||
_media_source_id: Option<&str>,
|
||||
_start_time_seconds: Option<f64>,
|
||||
_audio_stream_index: Option<i32>,
|
||||
) -> Result<String, repo_types::RepoError> {
|
||||
Ok(format!("http://example.com/{}-audio.mp3", item_id))
|
||||
}
|
||||
async fn get_live_tv_channels(
|
||||
&self,
|
||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
||||
@@ -2503,6 +2613,86 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background audio-only mode (UR-040): a video episode is handed off to the
|
||||
/// native ExoPlayer *audio* path as a `MediaType::Audio` item so it keeps
|
||||
/// playing while the app is backgrounded. When that audio track ends, autoplay
|
||||
/// must STILL recognise it as an episode and offer the next one — otherwise
|
||||
/// playback just pauses at the episode boundary (the reported bug). The item
|
||||
/// carries its episode identity via `item_type: "Episode"` + `series_id`.
|
||||
#[tokio::test]
|
||||
async fn test_playback_ended_background_audio_episode_advances() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
// Mirrors what player_enter_background_audio builds: the episode as AUDIO.
|
||||
let episode = MediaItem {
|
||||
item_type: Some("Episode".to_string()),
|
||||
media_type: MediaType::Audio, // audio-only handoff, not Video
|
||||
series_id: Some("series1".to_string()),
|
||||
source: MediaSource::Remote {
|
||||
stream_url: "http://example.com/ep2-audio.m3u8".to_string(),
|
||||
jellyfin_item_id: "ep2".to_string(),
|
||||
},
|
||||
..create_test_items(1).remove(0)
|
||||
};
|
||||
controller.play_queue(vec![episode], 0).unwrap();
|
||||
|
||||
// Clear the NewTrackLoaded reason to simulate natural track end.
|
||||
controller.take_end_reason();
|
||||
|
||||
let decision = controller.on_playback_ended().await.unwrap();
|
||||
|
||||
match decision {
|
||||
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||
assert_eq!(next_episode.id, "ep3");
|
||||
}
|
||||
other => panic!(
|
||||
"background-audio episode end must advance to the next episode, got {:?}",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The backend-driven advance (used when backgrounded) must load the next
|
||||
/// episode as an AUDIO item carrying its episode identity, so the *following*
|
||||
/// end-of-track also advances rather than stopping.
|
||||
#[tokio::test]
|
||||
async fn test_advance_to_next_episode_audio_only_loads_audio_episode() {
|
||||
let controller = PlayerController::default();
|
||||
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
|
||||
|
||||
controller
|
||||
.advance_to_next_episode_audio_only("ep2")
|
||||
.await
|
||||
.expect("advance should succeed");
|
||||
|
||||
let current = controller
|
||||
.queue
|
||||
.lock_safe()
|
||||
.current()
|
||||
.cloned()
|
||||
.expect("an item should be loaded");
|
||||
assert_eq!(current.id, "ep2");
|
||||
assert_eq!(current.media_type, MediaType::Audio);
|
||||
assert_eq!(current.item_type.as_deref(), Some("Episode"));
|
||||
assert_eq!(current.series_id.as_deref(), Some("series1"));
|
||||
// Uses the audio-only URL, not a video stream.
|
||||
match ¤t.source {
|
||||
MediaSource::Remote { stream_url, .. } => {
|
||||
assert!(
|
||||
stream_url.contains("audio"),
|
||||
"expected audio-only URL, got {}",
|
||||
stream_url
|
||||
);
|
||||
}
|
||||
other => panic!("expected Remote source, got {:?}", other),
|
||||
}
|
||||
|
||||
// The controller now considers itself mid background-audio episode, so the
|
||||
// next end-of-track will advance again rather than stop.
|
||||
assert!(controller.current_is_audio_episode());
|
||||
}
|
||||
|
||||
/// 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