From 85bd2277142166732b03159d3a473eefa61df91d Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:13:27 +0200 Subject: [PATCH 1/2] fix(home): uniform card heights in the Your Libraries row MediaCard derives its artwork aspect ratio from the item, so a music library rendered aspect-square (144px tall at w-36) next to video libraries at aspect-video (81px), leaving the home row ragged. Add an optional `aspect` prop that overrides the derived ratio, and pass aspect="video" from the home Libraries strip. Unset, behaviour is unchanged, so the /library overview grid and the media carousels keep their per-type ratios. Artwork already uses object-cover, so square music art crops rather than distorts. Co-Authored-By: Claude Opus 5 --- src/lib/components/library/MediaCard.svelte | 16 +++++++++++++++- src/routes/+page.svelte | 5 ++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/components/library/MediaCard.svelte b/src/lib/components/library/MediaCard.svelte index 7e9b488c..c9ce535d 100644 --- a/src/lib/components/library/MediaCard.svelte +++ b/src/lib/components/library/MediaCard.svelte @@ -45,9 +45,16 @@ * TRACES: UR-068 | DR-119 */ 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 // 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") ); + const FIXED_ASPECT = { + square: "aspect-square", + video: "aspect-video", + poster: "aspect-[2/3]", + } as const; + const aspectRatio = $derived(() => { + if (aspect) return FIXED_ASPECT[aspect]; if ("kind" in item) { return isMusicType ? "aspect-square" : "aspect-[2/3]"; } diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 50d9c851..7897e776 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -159,12 +159,15 @@ {#if shortcutLibraries.length > 0}

Your Libraries

-
+
{#each shortcutLibraries as lib (lib.id)}
+ handleLibraryClick(lib)} />
From 9d7cb085e95633bc7045734e6effacc66b786be4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 12 Aug 2026 18:23:26 +0200 Subject: [PATCH 2/2] fix(series): resume after the furthest-watched episode, not the first gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pick_current_episode` rung 3 returned the first unwatched episode in series order. A viewer who skipped the pilot but is three seasons deep was sent back to S1E1: the gap was a deliberate skip, not the place they stopped. This read as flaky rather than consistently wrong because rung 3 only fires when the server's Next Up (rung 2) yields nothing, and `resolve_current_episode` swallows that call's errors with `.unwrap_or_default()`. `HybridRepository::get_next_up_episodes` delegates unconditionally to the online repo, so any unreachable-server moment silently degraded to the empty vec — same series, same watch state, different answer depending on one request's outcome. Rung 3 now scans the ordered list from the end with `rposition(is_played)` and returns the episode after the furthest-watched one, falling back to the previous first-unwatched behaviour when nothing is watched or the series is finished. Season crossing comes free from the already-flat series ordering, and `season_rank` keeps specials last so a watched special cannot mark a show finished. Tests written first and confirmed failing (S1E1 where S3E4 was expected), covering the skipped-pilot case, rolling into the next season past a skipped episode, and the watched-special case. All 17 existing tests still pass. Note: cargo test could not run locally (javascriptcoregtk-4.1 / webkit2gtk-4.1 absent on this host). The pure policy half plus its verbatim test module were extracted into a standalone crate to get real red/green; the full crate suite still needs a run on a complete toolchain. Co-Authored-By: Claude Opus 5 --- src-tauri/src/repository/series_progress.rs | 72 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/repository/series_progress.rs b/src-tauri/src/repository/series_progress.rs index 566aba67..57ec2577 100644 --- a/src-tauri/src/repository/series_progress.rs +++ b/src-tauri/src/repository/series_progress.rs @@ -97,9 +97,14 @@ fn belongs_to_series(item: &MediaItem, series_id: &str) -> bool { /// working through. /// 2. **The server's Next Up** for this series — it accounts for watch history /// we do not cache locally. -/// 3. **The first unwatched episode** in series order. 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. +/// 3. **The episode after the furthest-watched one**, falling back to the first +/// unwatched episode when nothing has been watched or the series is finished. +/// 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 /// rather than on nothing. /// @@ -136,7 +141,18 @@ pub fn pick_current_episode( 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)) { return Some(found.clone()); } @@ -352,6 +368,54 @@ mod tests { 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] fn crosses_a_season_boundary_when_a_season_is_finished() { let mut eps = [season(1, 3), season(2, 3)].concat();