diff --git a/src-tauri/src/commands/player/mod.rs b/src-tauri/src/commands/player/mod.rs index a3c39100..24c79093 100644 --- a/src-tauri/src/commands/player/mod.rs +++ b/src-tauri/src/commands/player/mod.rs @@ -205,6 +205,15 @@ pub struct PlayItemRequest { /// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set. #[serde(default)] pub duration_seconds: Option, + /// Item type (e.g. "Episode", "Movie", "Audio"). Carried through the + /// background-audio handoff so an episode played as audio-only is still + /// recognised as an episode by autoplay (UR-040) and advances to the next one. + #[serde(default)] + pub item_type: Option, + /// Series ID for TV episodes. Needed alongside `item_type` so the backend can + /// look up the next episode when a background-audio track ends. + #[serde(default)] + pub series_id: Option, } /// Queue context for remote transfer - what type of queue is this? @@ -601,7 +610,9 @@ pub async fn player_enter_background_audio( artists: None, primary_image_tag: item.primary_image_tag.clone(), image_id: item.primary_image_tag.clone(), - item_type: None, + // Carry episode identity so autoplay can advance to the next episode when + // this audio-only handoff ends while backgrounded (UR-040). + item_type: item.item_type.clone(), playlist_id: None, // Carry the real duration so the lockscreen MediaSession can draw a scrubber. duration: item.duration_seconds, @@ -616,7 +627,7 @@ pub async fn player_enter_background_audio( video_width: None, video_height: None, subtitles: vec![], - series_id: None, + series_id: item.series_id.clone(), server_id: item.server_id.clone(), }; diff --git a/src-tauri/src/player/android/mod.rs b/src-tauri/src/player/android/mod.rs index 754cd020..ff508324 100644 --- a/src-tauri/src/player/android/mod.rs +++ b/src-tauri/src/player/android/mod.rs @@ -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/) 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) => { diff --git a/src-tauri/src/player/mod.rs b/src-tauri/src/player/mod.rs index f9afa0d0..0c4bd814 100644 --- a/src-tauri/src/player/mod.rs +++ b/src-tauri/src/player/mod.rs @@ -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/`, + /// 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 { unimplemented!() } + async fn get_audio_only_stream_url_for_video( + &self, + item_id: &str, + _media_source_id: Option<&str>, + _start_time_seconds: Option, + _audio_stream_index: Option, + ) -> Result { + Ok(format!("http://example.com/{}-audio.mp3", item_id)) + } async fn get_live_tv_channels( &self, ) -> Result, 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] diff --git a/src-tauri/src/repository/hybrid.rs b/src-tauri/src/repository/hybrid.rs index b4ba9a0c..2d15c1b6 100644 --- a/src-tauri/src/repository/hybrid.rs +++ b/src-tauri/src/repository/hybrid.rs @@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository { self.online.get_audio_stream_url(item_id).await } + async fn get_audio_only_stream_url_for_video( + &self, + item_id: &str, + media_source_id: Option<&str>, + start_time_seconds: Option, + audio_stream_index: Option, + ) -> Result { + // Audio-only transcode of a video requires the server - delegate to online. + self.online + .build_audio_only_stream_url_for_video( + item_id, + media_source_id, + start_time_seconds, + audio_stream_index, + ) + .await + } + async fn get_live_tv_channels(&self) -> Result, RepoError> { // Live TV requires server communication - delegate to online repository self.online.get_live_tv_channels().await @@ -1028,6 +1046,16 @@ mod tests { unimplemented!() } + async fn get_audio_only_stream_url_for_video( + &self, + _item_id: &str, + _media_source_id: Option<&str>, + _start_time_seconds: Option, + _audio_stream_index: Option, + ) -> Result { + unimplemented!() + } + async fn get_live_tv_channels(&self) -> Result, RepoError> { unimplemented!() } @@ -1276,6 +1304,16 @@ mod tests { unimplemented!() } + async fn get_audio_only_stream_url_for_video( + &self, + _item_id: &str, + _media_source_id: Option<&str>, + _start_time_seconds: Option, + _audio_stream_index: Option, + ) -> Result { + unimplemented!() + } + async fn get_live_tv_channels(&self) -> Result, RepoError> { unimplemented!() } diff --git a/src-tauri/src/repository/mod.rs b/src-tauri/src/repository/mod.rs index fec92d72..109f3d95 100644 --- a/src-tauri/src/repository/mod.rs +++ b/src-tauri/src/repository/mod.rs @@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync { /// @req: JA-007 - Get playback info and stream URL async fn get_audio_stream_url(&self, item_id: &str) -> Result; + /// Get an audio-only stream URL for a *video* item (background-audio handoff). + /// + /// Used when autoplay advances to the next episode while the app is playing a + /// video in audio-only mode in the background: the backend needs the next + /// episode's audio-only URL without any frontend round-trip. Online-only; + /// offline/cache repositories return an error. + /// + /// TRACES: UR-040 | JA-032 + async fn get_audio_only_stream_url_for_video( + &self, + item_id: &str, + media_source_id: Option<&str>, + start_time_seconds: Option, + audio_stream_index: Option, + ) -> Result; + /// Get Live TV channels (broadcast / IPTV) for browsing. async fn get_live_tv_channels(&self) -> Result, RepoError>; diff --git a/src-tauri/src/repository/offline.rs b/src-tauri/src/repository/offline.rs index 469f0ec4..1d3a7645 100644 --- a/src-tauri/src/repository/offline.rs +++ b/src-tauri/src/repository/offline.rs @@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository { Err(RepoError::Offline) } + async fn get_audio_only_stream_url_for_video( + &self, + _item_id: &str, + _media_source_id: Option<&str>, + _start_time_seconds: Option, + _audio_stream_index: Option, + ) -> Result { + // Audio-only transcode requires the server; offline downloads play locally. + Err(RepoError::Offline) + } + async fn get_live_tv_channels(&self) -> Result, RepoError> { // Live TV is inherently online-only. Err(RepoError::Offline) diff --git a/src-tauri/src/repository/online.rs b/src-tauri/src/repository/online.rs index c2800968..03b82e65 100644 --- a/src-tauri/src/repository/online.rs +++ b/src-tauri/src/repository/online.rs @@ -450,7 +450,7 @@ impl OnlineRepository { /// `/universal` endpoint (no `.m3u8` in the path) fails its progressive /// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally /// decodable and supports mid-stream `StartTimeTicks`. - pub async fn get_audio_only_stream_url_for_video( + pub async fn build_audio_only_stream_url_for_video( &self, item_id: &str, media_source_id: Option<&str>, @@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository { Ok(url) } + async fn get_audio_only_stream_url_for_video( + &self, + item_id: &str, + media_source_id: Option<&str>, + start_time_seconds: Option, + audio_stream_index: Option, + ) -> Result { + self.build_audio_only_stream_url_for_video( + item_id, + media_source_id, + start_time_seconds, + audio_stream_index, + ) + .await + } + async fn get_live_tv_channels(&self) -> Result, RepoError> { // Live TV channels (broadcast tuners / IPTV M3U). Returned as items with // type "TvChannel" — playable via open_live_stream. diff --git a/src/lib/api/bindings.ts b/src/lib/api/bindings.ts index 2223c29e..bb0df990 100644 --- a/src/lib/api/bindings.ts +++ b/src/lib/api/bindings.ts @@ -2055,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul * handoff so the lockscreen MediaSession advertises a real duration — a * zero-duration session renders no scrubber, even with ACTION_SEEK_TO set. */ -durationSeconds?: number | null } +durationSeconds?: number | null; +/** + * Item type (e.g. "Episode", "Movie", "Audio"). Carried through the + * background-audio handoff so an episode played as audio-only is still + * recognised as an episode by autoplay (UR-040) and advances to the next one. + */ +itemType?: string | null; +/** + * Series ID for TV episodes. Needed alongside `item_type` so the backend can + * look up the next episode when a background-audio track ends. + */ +seriesId?: string | null } /** * Queue context for remote transfer - what type of queue is this? */ diff --git a/src/lib/components/player/VideoPlayer.svelte b/src/lib/components/player/VideoPlayer.svelte index 697d49ac..41f72f1e 100644 --- a/src/lib/components/player/VideoPlayer.svelte +++ b/src/lib/components/player/VideoPlayer.svelte @@ -1233,6 +1233,10 @@ serverId: media.serverId ?? null, // Real duration so the lockscreen scrubber has a range to draw. durationSeconds: duration > 0 ? duration : null, + // Episode identity so the backend can auto-advance to the next episode + // when this audio-only stream ends while backgrounded (UR-040). + itemType: media.type ?? null, + seriesId: media.seriesId ?? null, }, pos, );