fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a play overlay pinned at 0:00, a seek bar at zero, and a play button that did nothing. Nothing crashed — the process stayed up and the frontend kept logging — the transition was simply dropped. The two render paths resume by different means, and exitBackgroundAudioHandoff only ever performed one of them. The webview <video> reloads off its stream URL: an $effect watches it, reinitialises HLS or sets element.src, and canplay drives the seek and play. ExoPlayer owns no element and nothing watches the URL on its behalf — native playback is only ever started by an explicit player_play_item plus adapter load, which the component issues once, from onMount. So reassigning the URL restarted precisely nothing, and since player_exit_background_audio had already stopped the handoff's audio player, the backend came back holding no item at all. That is why the play button was inert: there was nothing loaded to play. The return now re-issues that pair on the native path, in the same order as the initial load, carrying the position the audio reached. Subtitle configurations are reused from the ones resolved at mount — ExoPlayer sideloads them as MediaItem.SubtitleConfigurations and cannot accept one after prepare(). Which path to take is decided by planHandoffReturn, a pure helper in backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the player. It also folds in shouldResumeOnForeground, so a pause taken on the lockscreen during the handoff still wins over the snapshot captured on the way out. Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54, return restored native video playing at 70:18. Previously the same sequence left the player idle and black. The requirements count pin in extract-traces.test.ts moves with the new DR-196.
This commit is contained in:
@@ -863,6 +863,33 @@ fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usi
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a Next Up listing.
|
||||
///
|
||||
/// `EnableResumable=false` is the point of this query: the server default is
|
||||
/// `true`, which makes a partially-watched episode its own series' "next up" —
|
||||
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
|
||||
/// end up showing the same cards. Next Up should only ever offer episodes the
|
||||
/// viewer has not started. Servers predating the parameter ignore it, which is
|
||||
/// why the frontend also drops in-progress entries (DR-196).
|
||||
///
|
||||
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
|
||||
/// HTTP server, matching `build_favorites_endpoint`.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-196, JA-036 | UT-190, UT-191
|
||||
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
user_id,
|
||||
limit.unwrap_or(16)
|
||||
);
|
||||
|
||||
if let Some(sid) = series_id {
|
||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||
}
|
||||
|
||||
endpoint
|
||||
}
|
||||
|
||||
/// Build the Jellyfin endpoint for a favourites listing.
|
||||
///
|
||||
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
|
||||
@@ -1153,15 +1180,7 @@ impl MediaRepository for OnlineRepository {
|
||||
series_id: Option<&str>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let limit_str = limit.unwrap_or(16);
|
||||
let mut endpoint = format!(
|
||||
"/Shows/NextUp?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
|
||||
self.user_id, limit_str
|
||||
);
|
||||
|
||||
if let Some(sid) = series_id {
|
||||
endpoint.push_str(&format!("&SeriesId={}", sid));
|
||||
}
|
||||
let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
|
||||
|
||||
let response: ItemsResponse = self.get_json(&endpoint).await?;
|
||||
Ok(response
|
||||
@@ -3471,6 +3490,44 @@ mod tests {
|
||||
assert!(endpoint.contains("Limit=16"));
|
||||
}
|
||||
|
||||
/// UT-190 — Next Up asks the server to leave resumable episodes out.
|
||||
///
|
||||
/// Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns
|
||||
/// the *in-progress* episode as a series' next up — exactly the episode
|
||||
/// `/Items/Resume` already returns, so Continue Watching and Next Up render
|
||||
/// the same cards.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-196, JA-036 | UT-190
|
||||
#[test]
|
||||
fn test_build_next_up_endpoint_excludes_resumable() {
|
||||
let endpoint = build_next_up_endpoint("u1", None, Some(12));
|
||||
|
||||
assert!(
|
||||
endpoint.contains("EnableResumable=false"),
|
||||
"next up must exclude in-progress episodes, got: {}",
|
||||
endpoint
|
||||
);
|
||||
assert!(endpoint.contains("UserId=u1"));
|
||||
assert!(endpoint.contains("Limit=12"));
|
||||
assert!(
|
||||
!endpoint.contains("SeriesId"),
|
||||
"no series filter when none was requested, got: {}",
|
||||
endpoint
|
||||
);
|
||||
}
|
||||
|
||||
/// UT-191 — a per-series Next Up query keeps the series filter.
|
||||
///
|
||||
/// TRACES: UR-059 | DR-196 | UT-191
|
||||
#[test]
|
||||
fn test_build_next_up_endpoint_scopes_to_series() {
|
||||
let endpoint = build_next_up_endpoint("u1", Some("series-a"), None);
|
||||
|
||||
assert!(endpoint.contains("SeriesId=series-a"));
|
||||
assert!(endpoint.contains("EnableResumable=false"));
|
||||
assert!(endpoint.contains("Limit=16"), "default limit, got: {}", endpoint);
|
||||
}
|
||||
|
||||
/// UT-099 — a Jellyfin item's `UserData` reaches `MediaItem.user_data`.
|
||||
///
|
||||
/// Before DR-113 this mapping was hardcoded to `None`, so nothing outside
|
||||
|
||||
Reference in New Issue
Block a user