Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:
remote: uniform card heights; resume after furthest-watched episode
local: Android native-path resume; queued watch-position sync (DR-154)
Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool {
|
|||||||
/// working through.
|
/// working through.
|
||||||
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
/// 2. **The server's Next Up** for this series — it accounts for watch history
|
||||||
/// we do not cache locally.
|
/// we do not cache locally.
|
||||||
/// 3. **The first unwatched episode** in series order. This is the offline path:
|
/// 3. **The episode after the furthest-watched one**, falling back to the first
|
||||||
/// `OfflineRepository::get_next_up_episodes` returns an empty vec, so without
|
/// unwatched episode when nothing has been watched or the series is finished.
|
||||||
/// this rung the whole feature would be online-only.
|
/// This is the offline path: `OfflineRepository::get_next_up_episodes`
|
||||||
|
/// returns an empty vec, so without this rung the whole feature would be
|
||||||
|
/// online-only. It deliberately does *not* return the first unwatched
|
||||||
|
/// episode outright — an unwatched episode behind the viewer's furthest
|
||||||
|
/// point was skipped on purpose, and sending them back to it is the bug
|
||||||
|
/// DR-101 was reopened for.
|
||||||
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
/// 4. **The first episode**, so a never-watched series opens on its premiere
|
||||||
/// rather than on nothing.
|
/// rather than on nothing.
|
||||||
///
|
///
|
||||||
@@ -136,7 +141,18 @@ pub fn pick_current_episode(
|
|||||||
return Some(matched.unwrap_or(found).clone());
|
return Some(matched.unwrap_or(found).clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. First unwatched in series order.
|
// 3. The episode after the furthest-watched one. Not simply the first
|
||||||
|
// unwatched: a viewer who skipped the pilot but is deep into season 3
|
||||||
|
// must not be dragged back to S1E1. An earlier gap is a deliberate skip;
|
||||||
|
// where they stopped is the *last* thing they watched.
|
||||||
|
if let Some(furthest) = episodes.iter().rposition(is_played) {
|
||||||
|
if let Some(found) = episodes.get(furthest + 1) {
|
||||||
|
return Some(found.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nothing watched yet (or the furthest-watched episode is the finale):
|
||||||
|
// the first unwatched episode in series order.
|
||||||
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
if let Some(found) = episodes.iter().find(|e| !is_played(e)) {
|
||||||
return Some(found.clone());
|
return Some(found.clone());
|
||||||
}
|
}
|
||||||
@@ -352,6 +368,54 @@ mod tests {
|
|||||||
assert_eq!(current.id, "s2e2");
|
assert_eq!(current.id, "s2e2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A viewer deep in season 3 who never watched the pilot must not be sent
|
||||||
|
/// back to it: the gap was a skip, not the place they stopped.
|
||||||
|
#[test]
|
||||||
|
fn resumes_after_the_furthest_watched_episode_not_the_first_gap() {
|
||||||
|
let mut eps = [season(1, 4), season(2, 4), season(3, 4)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
// Everything through S3E3 watched, except the never-watched pilot.
|
||||||
|
let watched_through = ep.parent_index_number < Some(3) || ep.index_number <= Some(3);
|
||||||
|
if watched_through && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s3e4");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The furthest-watched episode being a finale must still roll into the
|
||||||
|
/// next season rather than stopping the series.
|
||||||
|
#[test]
|
||||||
|
fn resumes_into_the_next_season_after_a_skipped_earlier_episode() {
|
||||||
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.parent_index_number == Some(1) && ep.id != "s1e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s2e1");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Specials sort last, so watching one must not mark the series finished
|
||||||
|
/// while numbered episodes remain.
|
||||||
|
#[test]
|
||||||
|
fn a_watched_special_does_not_end_the_series() {
|
||||||
|
let mut eps = [season(1, 3), vec![episode("s0e1", 0, 1)]].concat();
|
||||||
|
sort_series_order(&mut eps);
|
||||||
|
for ep in eps.iter_mut() {
|
||||||
|
if ep.id == "s1e1" || ep.id == "s0e1" {
|
||||||
|
*ep = watched(ep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = pick_current_episode(SERIES, &eps, &[], &[]).unwrap();
|
||||||
|
assert_eq!(current.id, "s1e2");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
fn crosses_a_season_boundary_when_a_season_is_finished() {
|
||||||
let mut eps = [season(1, 3), season(2, 3)].concat();
|
let mut eps = [season(1, 3), season(2, 3)].concat();
|
||||||
|
|||||||
@@ -45,9 +45,16 @@
|
|||||||
* TRACES: UR-068 | DR-119
|
* TRACES: UR-068 | DR-119
|
||||||
*/
|
*/
|
||||||
showFavorite?: boolean;
|
showFavorite?: boolean;
|
||||||
|
/**
|
||||||
|
* Force the artwork box to a fixed aspect ratio instead of deriving one from
|
||||||
|
* the item. Use on rows that mix item kinds (e.g. the home "Your Libraries"
|
||||||
|
* strip, where square music art next to 16:9 video art would otherwise give
|
||||||
|
* the cards different heights). Artwork still fills the box via object-cover.
|
||||||
|
*/
|
||||||
|
aspect?: "square" | "video" | "poster";
|
||||||
}
|
}
|
||||||
|
|
||||||
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true }: Props = $props();
|
let { item, size = "medium", showProgress = false, showDownloadStatus = true, sizeLabel, downloadedBadge, onRemove, onclick, onLongPress, showFavorite = true, aspect }: Props = $props();
|
||||||
|
|
||||||
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
// Long-press detection. We arm a timer on pointerdown; if it fires before the
|
||||||
// pointer is released (or moves too far), we treat it as a long press and set a
|
// pointer is released (or moves too far), we treat it as a long press and set a
|
||||||
@@ -179,7 +186,14 @@
|
|||||||
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
"kind" in item && (item.kind === "track" || item.kind === "album" || item.kind === "artist" || item.kind === "playlist")
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const FIXED_ASPECT = {
|
||||||
|
square: "aspect-square",
|
||||||
|
video: "aspect-video",
|
||||||
|
poster: "aspect-[2/3]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
const aspectRatio = $derived(() => {
|
const aspectRatio = $derived(() => {
|
||||||
|
if (aspect) return FIXED_ASPECT[aspect];
|
||||||
if ("kind" in item) {
|
if ("kind" in item) {
|
||||||
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
return isMusicType ? "aspect-square" : "aspect-[2/3]";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,12 +159,15 @@
|
|||||||
{#if shortcutLibraries.length > 0}
|
{#if shortcutLibraries.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
<h2 class="text-xl font-bold text-white mb-4 px-4">Your Libraries</h2>
|
||||||
<div class="flex gap-4 overflow-x-auto px-4 pb-2">
|
<div class="flex gap-4 overflow-x-auto px-4 pb-2 items-start">
|
||||||
{#each shortcutLibraries as lib (lib.id)}
|
{#each shortcutLibraries as lib (lib.id)}
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
|
<!-- Uniform 16:9 artwork so music (square) and video libraries
|
||||||
|
line up at the same height in this mixed row. -->
|
||||||
<MediaCard
|
<MediaCard
|
||||||
item={lib}
|
item={lib}
|
||||||
size="medium"
|
size="medium"
|
||||||
|
aspect="video"
|
||||||
onclick={() => handleLibraryClick(lib)}
|
onclick={() => handleLibraryClick(lib)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user