fix(player): play on past the end of a season
Autoplay listed the episodes of the current season and stopped dead at the last one, so the end of a season returned AutoplayDecision::Stop. On the Android background-audio handoff that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it -- the device log reads "Current episode is the last in the season" and then "Decision: Stop playback", one episode after a mid-season boundary the backend advanced through by itself. The lookup now walks the series' seasons and takes the first episode of the next one that has any. Seasons are sorted client-side by index number because the offline repository ignores sort_by, empty seasons are skipped rather than read as the end of the series, and Specials are never rolled into: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would otherwise land. Every autoplay entry point shares this lookup, so foreground video and the Android native path cross the boundary too. It stays below the sleep-timer gate in on_playback_ended, so a timer set to end-of-episode or a remaining episode count still stops at the boundary instead of being carried past it. TRACES: UR-023, UR-040 | DR-263 | UT-238
This commit is contained in:
@@ -456,6 +456,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
| DR-260 | Subtitle cues are **drawn**. ExoPlayer decodes subtitles and delivers them to a listener; it draws none of them itself, and native video here is a bare `TextureView` the WebView composites over rather than a `PlayerView`, so nothing was holding the cues and a selected track rendered nowhere. The gap was invisible while every subtitle URL 404ed (DR-259) — with no text track to select there was never a cue to drop, so fixing the URL is what exposed it. `media3-ui`'s `SubtitleView` now takes each `CueGroup` from `onCues` and is attached at index 1 of the content view: above the video, still below the WebView, so cues sit over the picture and under the app's own controls. It is fitted to the letterboxed video rect rather than the screen, so cues stay inside the picture and follow it on rotation, and is torn down with the surface it belongs to. Verified on a device | Player | UR-020, UR-003 | Done |
|
||||||
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
| DR-261 | Subtitles are drawn **over the picture, not on a black bar across it**. `SubtitleView.setUserDefaultStyle()` reads Android's captioning preferences and falls back to media3's `DEFAULT` when the viewer has set none — and that default is white on opaque black, so every line arrived in a box as wide as the text. The viewer's own style is kept and only the two colours that paint a box, background and window, are cleared: someone who has configured captions in accessibility settings has said something specific about colour, typeface and edges, and replacing all of it to remove a background would answer a question they did not ask. A style specifying no edge gets a black outline, because without a box the text must supply its own contrast or it is unreadable over a bright scene; a style that already names an edge keeps it | Player | UR-020 | Done (pending device verification) |
|
||||||
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
|
| DR-262 | The A-Z jump strip is bounded by the **scroller it lives in**, not by the viewport minus a guess at the bottom bars. `AlphabetScrollBar` sized itself as `window.innerHeight` minus a hardcoded `bottomGap` — 5rem, 7rem or 11rem, picked by platform and whether the mini player was showing — which dates from when the mini player and bottom nav were `position: fixed` overlays. They have been in-flow flex siblings below the scroller since BottomUi (DR-009), so the scroller's own bottom edge *is* the top of the mini player and can simply be measured. The guess was short on every device with a navigation or gesture bar, because `--safe-bottom` is padded *inside* BottomUi (DR-112) and no guess knew about it: the strip overran the scrollport by ~45px with the nav alone, ~18px with the mini player and ~50px in remote mode, burying one to three letters where they could not be tapped. The ancestor is resolved by computed `overflow-y` rather than `closest("main")`, since the root shell scrolls in a plain `<div>` and a miss silently fell back to the viewport — reinstating the bug on any route outside `/library`. Observing the scroller for resize is also what makes the mini player appearing re-measure, so the component no longer subscribes to player or platform stores at all | UI | UR-007 | Done |
|
||||||
|
| DR-263 | Autoplay crosses the **season boundary**. `fetch_next_episode_for_item` listed the episodes of the current season and stopped dead at the last one, so the end of a season produced `AutoplayDecision::Stop`. On the Android background-audio handoff (UR-040) that is felt as playback simply pausing mid-binge with the screen locked and no UI to un-pause it — the same end that mid-season advances through in the backend. The lookup now walks the series' seasons, sorted client-side by index number because the offline repository ignores `sort_by`, and takes the first episode of the next season that has any, skipping empty ones. Specials are never rolled *into*: Jellyfin numbers them 0 so they sort ahead of season 1, but a server that leaves the index unset sorts them last, exactly where the walk would land. The lookup sits below the sleep-timer gate in `on_playback_ended`, so a timer set to end-of-episode or a remaining-episode count still stops at the boundary rather than being carried past it | Player | UR-023, UR-040 | Done |
|
||||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -488,7 +489,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
|
||||||
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
|
||||||
| UR-022 | IR-017 | DR-025 |
|
| UR-022 | IR-017 | DR-025 |
|
||||||
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
|
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
|
||||||
| UR-024 | IR-010 | DR-027 |
|
| UR-024 | IR-010 | DR-027 |
|
||||||
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
|
||||||
| UR-026 | - | DR-029, DR-048, DR-050 |
|
| UR-026 | - | DR-029, DR-048, DR-050 |
|
||||||
@@ -505,7 +506,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-037 | IR-010 | DR-042 |
|
| UR-037 | IR-010 | DR-042 |
|
||||||
| UR-038 | IR-010 | DR-043 |
|
| UR-038 | IR-010 | DR-043 |
|
||||||
| UR-039 | - | DR-045, DR-046 |
|
| UR-039 | - | DR-045, DR-046 |
|
||||||
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
|
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263 |
|
||||||
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
|
||||||
| UR-042 | IR-009, IR-014 | DR-054 |
|
| UR-042 | IR-009, IR-014 | DR-054 |
|
||||||
| UR-043 | IR-027 | DR-055 |
|
| UR-043 | IR-027 | DR-055 |
|
||||||
@@ -785,6 +786,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
| UT-235 | The A-Z strip's last letter stays above the bottom nav, above the mini player while audio plays, and above the taller remote-mode mini player | DR-262 | Done |
|
||||||
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
| UT-236 | The strip still fills the space it does have rather than stopping a letter short | DR-262 | Done |
|
||||||
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
| UT-237 | The strip falls back to the viewport with no scroll container, and never computes a negative height when scrolled past its floor | DR-262 | Done |
|
||||||
|
| UT-238 | The last episode of a season rolls over into the first of the next, skips an empty season on the way, and still stops there when the sleep timer says so | DR-263 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
+253
-16
@@ -2251,6 +2251,12 @@ impl PlayerController {
|
|||||||
return Ok(Some((current_repo_item, next.clone())));
|
return Ok(Some((current_repo_item, next.clone())));
|
||||||
} else {
|
} else {
|
||||||
log::info!("[PlayerController] Current episode is the last in the season");
|
log::info!("[PlayerController] Current episode is the last in the season");
|
||||||
|
if let Some(next) = self
|
||||||
|
.first_episode_of_next_season(¤t_repo_item, repo)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
return Ok(Some((current_repo_item, next)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -2266,6 +2272,97 @@ impl PlayerController {
|
|||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first episode of the season after this one, if the series has one.
|
||||||
|
///
|
||||||
|
/// A season boundary is not the end of a series, and stopping there is felt
|
||||||
|
/// most sharply on the background-audio path (UR-040): the screen is locked,
|
||||||
|
/// nothing shows a "next" button, and playback simply stops mid-binge. Every
|
||||||
|
/// other autoplay entry point shares this lookup, so foreground video and
|
||||||
|
/// the Android native path cross the boundary too.
|
||||||
|
///
|
||||||
|
/// Lookup failures degrade to `None` rather than an error: the episode has
|
||||||
|
/// already finished, and the caller's only alternative is to stop anyway.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||||
|
async fn first_episode_of_next_season(
|
||||||
|
&self,
|
||||||
|
current: &crate::repository::types::MediaItem,
|
||||||
|
repo: &Arc<dyn crate::repository::MediaRepository>,
|
||||||
|
) -> Option<crate::repository::types::MediaItem> {
|
||||||
|
use crate::repository::types::GetItemsOptions;
|
||||||
|
|
||||||
|
let series_id = current.series_id.as_deref()?;
|
||||||
|
let season_id = current.season_id.as_deref()?;
|
||||||
|
|
||||||
|
let season_options = GetItemsOptions {
|
||||||
|
sort_by: Some("IndexNumber".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(500),
|
||||||
|
include_item_types: Some(vec!["Season".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut seasons = match repo.get_items(series_id, Some(season_options)).await {
|
||||||
|
Ok(result) => result.items,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[PlayerController] Season lookup failed for series {}: {}",
|
||||||
|
series_id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Same client-side sort as the episode list: the offline repository
|
||||||
|
// ignores sort_by and orders by sort_name instead.
|
||||||
|
seasons.sort_by_key(|s| s.index_number.unwrap_or(i32::MAX));
|
||||||
|
|
||||||
|
let current_idx = seasons.iter().position(|s| s.id == season_id)?;
|
||||||
|
|
||||||
|
for season in &seasons[current_idx + 1..] {
|
||||||
|
// Never roll into Specials. Jellyfin numbers them 0, so they sort
|
||||||
|
// ahead of season 1 and are normally unreachable from here -- but a
|
||||||
|
// server that leaves the index unset sorts them last, right where
|
||||||
|
// this walk would otherwise land.
|
||||||
|
if season.index_number == Some(0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let episode_options = GetItemsOptions {
|
||||||
|
sort_by: Some("IndexNumber".to_string()),
|
||||||
|
sort_order: Some("Ascending".to_string()),
|
||||||
|
limit: Some(500),
|
||||||
|
include_item_types: Some(vec!["Episode".to_string()]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut episodes = match repo.get_items(&season.id, Some(episode_options)).await {
|
||||||
|
Ok(result) => result.items,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!(
|
||||||
|
"[PlayerController] Episode lookup failed for season {}: {}",
|
||||||
|
season.id,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
episodes.sort_by_key(|e| e.index_number.unwrap_or(i32::MAX));
|
||||||
|
|
||||||
|
// An empty season is a gap in the series, not the end of it.
|
||||||
|
if let Some(first) = episodes.into_iter().next() {
|
||||||
|
log::info!(
|
||||||
|
"[PlayerController] Rolling over to {} of {}: {}",
|
||||||
|
first.name,
|
||||||
|
season.name,
|
||||||
|
first.id
|
||||||
|
);
|
||||||
|
return Some(first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("[PlayerController] No further season to roll over into");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Start autoplay countdown thread
|
/// Start autoplay countdown thread
|
||||||
pub fn start_autoplay_countdown(
|
pub fn start_autoplay_countdown(
|
||||||
&self,
|
&self,
|
||||||
@@ -3685,23 +3782,67 @@ mod tests {
|
|||||||
/// lookup tests. Only `get_item` and `get_items` are used by
|
/// lookup tests. Only `get_item` and `get_items` are used by
|
||||||
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
/// `fetch_next_episode_for_item`; everything else is unreachable.
|
||||||
struct MockEpisodeRepo {
|
struct MockEpisodeRepo {
|
||||||
episodes: Vec<repo_types::MediaItem>,
|
/// Seasons in the order the series lists them, each with its episodes.
|
||||||
|
seasons: Vec<(repo_types::MediaItem, Vec<repo_types::MediaItem>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockEpisodeRepo {
|
impl MockEpisodeRepo {
|
||||||
|
/// A one-season series, whose episodes keep the historical `ep{n}` ids.
|
||||||
fn season(count: usize) -> Self {
|
fn season(count: usize) -> Self {
|
||||||
let episodes = (1..=count)
|
Self::series(&[count])
|
||||||
.map(|i| {
|
}
|
||||||
let mut item = make_repo_episode(&format!("ep{}", i), i as i32);
|
|
||||||
item.name = format!("Episode {}", i);
|
/// A series whose seasons hold the given episode counts. Season 1 keeps
|
||||||
item
|
/// the `ep{n}` ids the single-season tests use; later seasons get
|
||||||
|
/// `s{season}e{n}` so a rollover assertion names the season it landed in.
|
||||||
|
fn series(counts: &[usize]) -> Self {
|
||||||
|
let seasons = counts
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(s, count)| {
|
||||||
|
let season_number = s as i32 + 1;
|
||||||
|
let episodes = (1..=*count)
|
||||||
|
.map(|i| {
|
||||||
|
let id = if season_number == 1 {
|
||||||
|
format!("ep{}", i)
|
||||||
|
} else {
|
||||||
|
format!("s{}e{}", season_number, i)
|
||||||
|
};
|
||||||
|
make_repo_episode_in(season_number, &id, i as i32)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(make_repo_season(season_number), episodes)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Self { episodes }
|
Self { seasons }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_episodes(&self) -> impl Iterator<Item = &repo_types::MediaItem> {
|
||||||
|
self.seasons.iter().flat_map(|(_, eps)| eps.iter())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_repo_season(index: i32) -> repo_types::MediaItem {
|
||||||
|
repo_types::MediaItem {
|
||||||
|
id: format!("season{}", index),
|
||||||
|
name: format!("Season {}", index),
|
||||||
|
item_type: "Season".to_string(),
|
||||||
|
kind: crate::domain::MediaKind::Season,
|
||||||
|
is_folder: true,
|
||||||
|
parent_id: Some("series1".to_string()),
|
||||||
|
index_number: Some(index),
|
||||||
|
season_id: None,
|
||||||
|
season_name: None,
|
||||||
|
parent_index_number: None,
|
||||||
|
..make_repo_episode(&format!("season{}", index), index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
fn make_repo_episode(id: &str, index: i32) -> repo_types::MediaItem {
|
||||||
|
make_repo_episode_in(1, id, index)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_repo_episode_in(season_number: i32, id: &str, index: i32) -> repo_types::MediaItem {
|
||||||
repo_types::MediaItem {
|
repo_types::MediaItem {
|
||||||
id: id.to_string(),
|
id: id.to_string(),
|
||||||
name: format!("Episode {}", index),
|
name: format!("Episode {}", index),
|
||||||
@@ -3709,7 +3850,7 @@ mod tests {
|
|||||||
kind: crate::domain::MediaKind::Episode,
|
kind: crate::domain::MediaKind::Episode,
|
||||||
is_folder: false,
|
is_folder: false,
|
||||||
server_id: "server".to_string(),
|
server_id: "server".to_string(),
|
||||||
parent_id: Some("season1".to_string()),
|
parent_id: Some(format!("season{}", season_number)),
|
||||||
library_id: None,
|
library_id: None,
|
||||||
overview: None,
|
overview: None,
|
||||||
genres: None,
|
genres: None,
|
||||||
@@ -3731,9 +3872,9 @@ mod tests {
|
|||||||
index_number: Some(index),
|
index_number: Some(index),
|
||||||
series_id: Some("series1".to_string()),
|
series_id: Some("series1".to_string()),
|
||||||
series_name: Some("Test Series".to_string()),
|
series_name: Some("Test Series".to_string()),
|
||||||
season_id: Some("season1".to_string()),
|
season_id: Some(format!("season{}", season_number)),
|
||||||
season_name: Some("Season 1".to_string()),
|
season_name: Some(format!("Season {}", season_number)),
|
||||||
parent_index_number: Some(1),
|
parent_index_number: Some(season_number),
|
||||||
user_data: None,
|
user_data: None,
|
||||||
media_streams: None,
|
media_streams: None,
|
||||||
media_sources: None,
|
media_sources: None,
|
||||||
@@ -3751,18 +3892,29 @@ mod tests {
|
|||||||
parent_id: &str,
|
parent_id: &str,
|
||||||
_options: Option<repo_types::GetItemsOptions>,
|
_options: Option<repo_types::GetItemsOptions>,
|
||||||
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
) -> Result<repo_types::SearchResult, repo_types::RepoError> {
|
||||||
assert_eq!(parent_id, "season1", "episode lookup must query the season");
|
// The series lists its seasons; a season lists its episodes. Both
|
||||||
|
// are real lookups the autoplay path makes -- the second only once
|
||||||
|
// the first has told it which season comes next.
|
||||||
|
let items = if parent_id == "series1" {
|
||||||
|
self.seasons.iter().map(|(s, _)| s.clone()).collect()
|
||||||
|
} else {
|
||||||
|
self.seasons
|
||||||
|
.iter()
|
||||||
|
.find(|(season, _)| season.id == parent_id)
|
||||||
|
.map(|(_, eps)| eps.clone())
|
||||||
|
.unwrap_or_else(|| panic!("unexpected lookup of container {}", parent_id))
|
||||||
|
};
|
||||||
|
let total_record_count = items.len();
|
||||||
Ok(repo_types::SearchResult {
|
Ok(repo_types::SearchResult {
|
||||||
items: self.episodes.clone(),
|
items,
|
||||||
total_record_count: self.episodes.len(),
|
total_record_count,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
async fn get_item(
|
async fn get_item(
|
||||||
&self,
|
&self,
|
||||||
item_id: &str,
|
item_id: &str,
|
||||||
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
) -> Result<repo_types::MediaItem, repo_types::RepoError> {
|
||||||
self.episodes
|
self.all_episodes()
|
||||||
.iter()
|
|
||||||
.find(|e| e.id == item_id)
|
.find(|e| e.id == item_id)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or(repo_types::RepoError::NotFound {
|
.ok_or(repo_types::RepoError::NotFound {
|
||||||
@@ -4004,6 +4156,91 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A season boundary is not the end of the series. The lookup used to stop
|
||||||
|
/// dead at the last episode of a season, which on Android's background-audio
|
||||||
|
/// path is felt as playback simply pausing at the end of an episode with the
|
||||||
|
/// screen locked and nothing to un-pause it.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-040 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_next_episode_rolls_over_to_the_next_season() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[2, 2]));
|
||||||
|
|
||||||
|
let decision = controller
|
||||||
|
.on_video_playback_ended("ep2", repo)
|
||||||
|
.await
|
||||||
|
.expect("decision should succeed");
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||||
|
assert_eq!(
|
||||||
|
next_episode.id, "s2e1",
|
||||||
|
"the first episode of the next season follows the last of this one"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A season with nothing in it is not the end of the series either.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_next_episode_skips_an_empty_season() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
let repo: Arc<dyn MediaRepository> = Arc::new(MockEpisodeRepo::series(&[1, 0, 1]));
|
||||||
|
|
||||||
|
let decision = controller
|
||||||
|
.on_video_playback_ended("ep1", repo)
|
||||||
|
.await
|
||||||
|
.expect("decision should succeed");
|
||||||
|
|
||||||
|
match decision {
|
||||||
|
AutoplayDecision::ShowNextEpisodePopup { next_episode, .. } => {
|
||||||
|
assert_eq!(next_episode.id, "s3e1");
|
||||||
|
}
|
||||||
|
other => panic!("Expected ShowNextEpisodePopup, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rollover must not out-rank the sleep timer: crossing a season
|
||||||
|
/// boundary is still a track boundary, and that is exactly where a timer set
|
||||||
|
/// to "end of episode" is supposed to stop.
|
||||||
|
///
|
||||||
|
/// TRACES: UR-023, UR-026 | DR-263 | UT-238
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_sleep_timer_still_stops_at_a_season_boundary() {
|
||||||
|
let controller = PlayerController::default();
|
||||||
|
controller.set_repository(Arc::new(MockEpisodeRepo::series(&[1, 1])));
|
||||||
|
controller.set_sleep_timer(SleepTimerMode::Episodes { remaining: 1 });
|
||||||
|
|
||||||
|
let episode = MediaItem {
|
||||||
|
transport: None,
|
||||||
|
media_type: MediaType::Audio,
|
||||||
|
item_type: Some("Episode".to_string()),
|
||||||
|
series_id: Some("series1".to_string()),
|
||||||
|
duration: Some(180.0),
|
||||||
|
source: MediaSource::Remote {
|
||||||
|
stream_url: "http://example.com/ep1.mp3".to_string(),
|
||||||
|
jellyfin_item_id: "ep1".to_string(),
|
||||||
|
},
|
||||||
|
..create_test_items(1).remove(0)
|
||||||
|
};
|
||||||
|
controller.play_queue(vec![episode], 0).unwrap();
|
||||||
|
// Played through to the end -- a natural finish, not a stream cut short.
|
||||||
|
controller.seek(180.0).unwrap();
|
||||||
|
controller.take_end_reason();
|
||||||
|
|
||||||
|
let decision = controller.on_playback_ended().await.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(decision, AutoplayDecision::Stop),
|
||||||
|
"the last episode the timer allows must stop, not roll into season 2 (got {:?})",
|
||||||
|
decision
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Last episode of the season: no popup, stop.
|
/// Last episode of the season: no popup, stop.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_video_playback_ended_last_episode_stops() {
|
async fn test_video_playback_ended_last_episode_stops() {
|
||||||
|
|||||||
Reference in New Issue
Block a user