From 2946ec42229bcebd59d28234c89b72bedcfb3e86 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 23:52:03 -0400 Subject: [PATCH] feat(web): coupled section scrolling, earlier infinite-scroll, fuller player cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three coordinated polish changes: 1. PlayerBar cover bumps from h-20 (80px) to h-24 (96px) and the bar's vertical padding tightens from py-4 to py-1.5. Bar height stays ~108px but the cover now fills ~89% of it (was ~74%) — reads as the substantial primary content the operator wanted, not a thumbnail. 2. InfiniteScrollSentinel default rootMargin moves from 300px to 800px so the next page fetches well before the user reaches the bottom of the rendered set. Empirically that's ~3-4 rows of cards on a typical library grid — loading feels seamless rather than catching up. 3. HorizontalScrollRow takes rows: T[][] instead of items: T[]. Multiple rows of items now render inside one shared overflow-x-auto container, so the rows scroll together as a single coupled section. Recently added (2 album rows) and Most played (3 track rows) on the home page now scroll as one unit. Rediscover keeps two separate scrollers because its rows are different card types (square albums vs circular artists) — coupling those would interleave shapes awkwardly. The item snippet's second arg is now the global flat index so consumers like CompactTrackCard (which needs sectionTracks + index for play actions) work without per-row re-indexing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/components/HorizontalScrollRow.svelte | 57 ++++++++++++----- .../components/HorizontalScrollRow.test.ts | 10 +-- .../components/InfiniteScrollSentinel.svelte | 6 +- web/src/lib/components/PlayerBar.svelte | 4 +- web/src/routes/+page.svelte | 62 +++++++++---------- 5 files changed, 85 insertions(+), 54 deletions(-) diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index a6c5ec4a..865089af 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -3,14 +3,24 @@ import type { Snippet } from 'svelte'; let { - items, + rows, item, title, ariaLabel }: { - items: T[]; + /** One or more visual rows of items rendered inside a single shared + horizontal scroller. Pass [items] for a single-row section. */ + rows: T[][]; + /** Render snippet for a single item. The second arg is the global + index across the flattened sequence (rows[0] then rows[1] etc.) — + useful when the snippet needs section-wide indexing, e.g. a + compact track card whose play action takes its position in the + whole section's track list. */ item?: Snippet<[T, number]>; + /** Section heading shown to the left of the arrows. Omit for an + unlabeled scroller. */ title?: string; + /** Accessible label for the section as a whole. */ ariaLabel?: string; } = $props(); @@ -22,6 +32,16 @@ const atStart = $derived(scrollLeft <= 1); const atEnd = $derived(scrollLeft + clientWidth >= scrollWidth - 1); + // Per-row offsets so the snippet's second arg can be the global index. + // [0, len(rows[0]), len(rows[0])+len(rows[1]), ...] + const rowOffsets = $derived.by(() => { + const out: number[] = [0]; + for (let i = 0; i < rows.length - 1; i++) { + out.push(out[i] + rows[i].length); + } + return out; + }); + function refreshMetrics() { if (!scroller) return; scrollLeft = scroller.scrollLeft; @@ -39,8 +59,8 @@ $effect(() => { refreshMetrics(); }); -
-
+
+
{#if title}

{title}

{:else} @@ -72,26 +92,32 @@ bind:this={scroller} onscroll={refreshMetrics} data-testid="scroll-container" - class="scroller flex gap-4 overflow-x-auto" + class="scroller overflow-x-auto" > - {#each items as it, i (i)} - {@render item?.(it, i)} - {/each} +
+ {#each rows as row, rowIdx (rowIdx)} +
+ {#each row as it, colIdx (colIdx)} + {@render item?.(it, rowOffsets[rowIdx] + colIdx)} + {/each} +
+ {/each} +