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:
@@ -205,6 +205,15 @@ pub struct PlayItemRequest {
|
|||||||
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
/// zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub duration_seconds: Option<f64>,
|
pub duration_seconds: Option<f64>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue context for remote transfer - what type of queue is this?
|
/// Queue context for remote transfer - what type of queue is this?
|
||||||
@@ -601,7 +610,9 @@ pub async fn player_enter_background_audio(
|
|||||||
artists: None,
|
artists: None,
|
||||||
primary_image_tag: item.primary_image_tag.clone(),
|
primary_image_tag: item.primary_image_tag.clone(),
|
||||||
image_id: 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,
|
playlist_id: None,
|
||||||
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
// Carry the real duration so the lockscreen MediaSession can draw a scrubber.
|
||||||
duration: item.duration_seconds,
|
duration: item.duration_seconds,
|
||||||
@@ -616,7 +627,7 @@ pub async fn player_enter_background_audio(
|
|||||||
video_width: None,
|
video_width: None,
|
||||||
video_height: None,
|
video_height: None,
|
||||||
subtitles: vec![],
|
subtitles: vec![],
|
||||||
series_id: None,
|
series_id: item.series_id.clone(),
|
||||||
server_id: item.server_id.clone(),
|
server_id: item.server_id.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -858,14 +858,42 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start countdown if auto_advance enabled
|
|
||||||
if auto_advance {
|
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
|
controller
|
||||||
.lock()
|
.lock()
|
||||||
.await
|
.await
|
||||||
.start_autoplay_countdown(next_episode, countdown_seconds);
|
.start_autoplay_countdown(next_episode, countdown_seconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::error!("[Autoplay] Decision failed: {}", e);
|
log::error!("[Autoplay] Decision failed: {}", e);
|
||||||
// Emit PlaybackEnded event on error
|
// Emit PlaybackEnded event on error
|
||||||
|
|||||||
+200
-10
@@ -658,6 +658,24 @@ impl PlayerController {
|
|||||||
self.queue.clone()
|
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
|
/// Clear the queue entirely (used when playback genuinely stops, e.g. the
|
||||||
/// sleep timer fires or the queue ends with repeat off). Pair with
|
/// sleep timer fires or the queue ends with repeat off). Pair with
|
||||||
/// `emit_queue_changed` so the frontend hides the mini player.
|
/// `emit_queue_changed` so the frontend hides the mini player.
|
||||||
@@ -963,9 +981,11 @@ impl PlayerController {
|
|||||||
return Ok(AutoplayDecision::Stop);
|
return Ok(AutoplayDecision::Stop);
|
||||||
}
|
}
|
||||||
SleepTimerMode::Episodes { .. } => {
|
SleepTimerMode::Episodes { .. } => {
|
||||||
// Only count TV episodes (not audio tracks or movies)
|
// Only count TV episodes (not audio tracks or movies). Note an
|
||||||
let is_episode =
|
// episode played in background-audio mode is MediaType::Audio, so
|
||||||
current.media_type == MediaType::Video && self.is_episode_item(¤t).await;
|
// 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 {
|
if is_episode {
|
||||||
let should_stop = self.sleep_timer.lock_safe().decrement_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).
|
// 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.
|
// It's here for the Android ExoPlayer path where episode items sit in the
|
||||||
if current.media_type == MediaType::Video && self.is_episode_item(¤t).await {
|
// 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 repo = self.repository.lock_safe().clone();
|
||||||
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
let jellyfin_id = current.jellyfin_id().unwrap_or(¤t.id);
|
||||||
let next_ep_result = if let Some(repo) = &repo {
|
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.
|
/// Handle video playback ended from HTML5 video element.
|
||||||
///
|
///
|
||||||
/// HTML5 video plays independently of the Rust backend, so the backend
|
/// HTML5 video plays independently of the Rust backend, so the backend
|
||||||
@@ -1135,11 +1228,19 @@ impl PlayerController {
|
|||||||
Ok(AutoplayDecision::Stop)
|
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 {
|
async fn is_episode_item(&self, item: &MediaItem) -> bool {
|
||||||
// For now, assume video items are episodes
|
match item.item_type.as_deref() {
|
||||||
// In production, we'd check item metadata or query Jellyfin
|
Some("Episode") => true,
|
||||||
item.media_type == MediaType::Video
|
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
|
/// 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> {
|
async fn get_audio_stream_url(&self, _: &str) -> Result<String, repo_types::RepoError> {
|
||||||
unimplemented!()
|
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(
|
async fn get_live_tv_channels(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<Vec<repo_types::MediaItem>, repo_types::RepoError> {
|
) -> 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
|
/// Without a controller repository the Android episode path must still
|
||||||
/// stop gracefully (previous behavior) rather than error.
|
/// stop gracefully (previous behavior) rather than error.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -641,6 +641,24 @@ impl MediaRepository for HybridRepository {
|
|||||||
self.online.get_audio_stream_url(item_id).await
|
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<f64>,
|
||||||
|
audio_stream_index: Option<i32>,
|
||||||
|
) -> Result<String, RepoError> {
|
||||||
|
// 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<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV requires server communication - delegate to online repository
|
// Live TV requires server communication - delegate to online repository
|
||||||
self.online.get_live_tv_channels().await
|
self.online.get_live_tv_channels().await
|
||||||
@@ -1028,6 +1046,16 @@ mod tests {
|
|||||||
unimplemented!()
|
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, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
@@ -1276,6 +1304,16 @@ mod tests {
|
|||||||
unimplemented!()
|
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, RepoError> {
|
||||||
|
unimplemented!()
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
unimplemented!()
|
unimplemented!()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,22 @@ pub trait MediaRepository: Send + Sync {
|
|||||||
/// @req: JA-007 - Get playback info and stream URL
|
/// @req: JA-007 - Get playback info and stream URL
|
||||||
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError>;
|
||||||
|
|
||||||
|
/// 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<f64>,
|
||||||
|
audio_stream_index: Option<i32>,
|
||||||
|
) -> Result<String, RepoError>;
|
||||||
|
|
||||||
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
/// Get Live TV channels (broadcast / IPTV) for browsing.
|
||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError>;
|
||||||
|
|
||||||
|
|||||||
@@ -1518,6 +1518,17 @@ impl MediaRepository for OfflineRepository {
|
|||||||
Err(RepoError::Offline)
|
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<f64>,
|
||||||
|
_audio_stream_index: Option<i32>,
|
||||||
|
) -> Result<String, RepoError> {
|
||||||
|
// Audio-only transcode requires the server; offline downloads play locally.
|
||||||
|
Err(RepoError::Offline)
|
||||||
|
}
|
||||||
|
|
||||||
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV is inherently online-only.
|
// Live TV is inherently online-only.
|
||||||
Err(RepoError::Offline)
|
Err(RepoError::Offline)
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ impl OnlineRepository {
|
|||||||
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
/// `/universal` endpoint (no `.m3u8` in the path) fails its progressive
|
||||||
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
/// loader with `ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED`. mp3 is universally
|
||||||
/// decodable and supports mid-stream `StartTimeTicks`.
|
/// 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,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
media_source_id: Option<&str>,
|
media_source_id: Option<&str>,
|
||||||
@@ -1355,6 +1355,22 @@ impl MediaRepository for OnlineRepository {
|
|||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, RepoError> {
|
||||||
|
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<Vec<MediaItem>, RepoError> {
|
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
|
||||||
// type "TvChannel" — playable via open_live_stream.
|
// type "TvChannel" — playable via open_live_stream.
|
||||||
|
|||||||
+12
-1
@@ -2055,7 +2055,18 @@ artist?: string | null; primaryImageTag?: string | null; serverId?: string | nul
|
|||||||
* handoff so the lockscreen MediaSession advertises a real duration — a
|
* handoff so the lockscreen MediaSession advertises a real duration — a
|
||||||
* zero-duration session renders no scrubber, even with ACTION_SEEK_TO set.
|
* 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?
|
* Queue context for remote transfer - what type of queue is this?
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1233,6 +1233,10 @@
|
|||||||
serverId: media.serverId ?? null,
|
serverId: media.serverId ?? null,
|
||||||
// Real duration so the lockscreen scrubber has a range to draw.
|
// Real duration so the lockscreen scrubber has a range to draw.
|
||||||
durationSeconds: duration > 0 ? duration : null,
|
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,
|
pos,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user