feat(web): coupled section scrolling, earlier infinite-scroll, fuller player cover

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 23:52:03 -04:00
parent 8d05f623cb
commit 2946ec4222
5 changed files with 85 additions and 54 deletions
@@ -3,14 +3,24 @@
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { let {
items, rows,
item, item,
title, title,
ariaLabel 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]>; item?: Snippet<[T, number]>;
/** Section heading shown to the left of the arrows. Omit for an
unlabeled scroller. */
title?: string; title?: string;
/** Accessible label for the section as a whole. */
ariaLabel?: string; ariaLabel?: string;
} = $props(); } = $props();
@@ -22,6 +32,16 @@
const atStart = $derived(scrollLeft <= 1); const atStart = $derived(scrollLeft <= 1);
const atEnd = $derived(scrollLeft + clientWidth >= scrollWidth - 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() { function refreshMetrics() {
if (!scroller) return; if (!scroller) return;
scrollLeft = scroller.scrollLeft; scrollLeft = scroller.scrollLeft;
@@ -39,8 +59,8 @@
$effect(() => { refreshMetrics(); }); $effect(() => { refreshMetrics(); });
</script> </script>
<div class="row" aria-label={ariaLabel}> <div class="section" aria-label={ariaLabel}>
<header class="row-header"> <header class="section-header">
{#if title} {#if title}
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2> <h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
{:else} {:else}
@@ -72,26 +92,32 @@
bind:this={scroller} bind:this={scroller}
onscroll={refreshMetrics} onscroll={refreshMetrics}
data-testid="scroll-container" data-testid="scroll-container"
class="scroller flex gap-4 overflow-x-auto" class="scroller overflow-x-auto"
> >
{#each items as it, i (i)} <div class="track inline-flex flex-col gap-4">
{@render item?.(it, i)} {#each rows as row, rowIdx (rowIdx)}
{/each} <div class="rack flex gap-4">
{#each row as it, colIdx (colIdx)}
{@render item?.(it, rowOffsets[rowIdx] + colIdx)}
{/each}
</div>
{/each}
</div>
</div> </div>
</div> </div>
<style> <style>
/* Bleed the row past the parent's right padding (16px from Shell's p-4) /* Bleed the section past the parent's right padding (16px from Shell's p-4)
so the scroller's items extend to the viewport's right edge. */ so the scroller's items extend to the viewport's right edge. */
.row { .section {
margin-right: -1rem; margin-right: -1rem;
} }
.row-header { .section-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
/* Re-add the right gutter so the header stays aligned with the rest /* Re-add the right gutter so the header stays aligned with page
of the page content (the scroller below remains full-bleed). */ content; the scroller below remains full-bleed. */
padding-right: 1rem; padding-right: 1rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -103,7 +129,10 @@
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
scrollbar-width: thin; scrollbar-width: thin;
} }
.scroller > :global(*) { /* Cards are two levels deep (.scroller > .track > .rack > card-wrapper),
so target them via the rack. flex-shrink: 0 keeps each card at its
intended width even though the rack tries to fit the scroller width. */
.rack > :global(*) {
flex-shrink: 0; flex-shrink: 0;
scroll-snap-align: start; scroll-snap-align: start;
} }
@@ -6,18 +6,18 @@ afterEach(() => vi.clearAllMocks());
describe('HorizontalScrollRow', () => { describe('HorizontalScrollRow', () => {
test('renders left and right scroll buttons', () => { test('renders left and right scroll buttons', () => {
render(HorizontalScrollRow, { props: { items: ['a', 'b', 'c'] } }); render(HorizontalScrollRow, { props: { rows: [['a', 'b', 'c']] } });
expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /scroll left/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /scroll right/i })).toBeInTheDocument();
}); });
test('left button starts disabled (scrollLeft is 0)', () => { test('left button starts disabled (scrollLeft is 0)', () => {
render(HorizontalScrollRow, { props: { items: ['a'] } }); render(HorizontalScrollRow, { props: { rows: [['a']] } });
expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled(); expect(screen.getByRole('button', { name: /scroll left/i })).toBeDisabled();
}); });
test('clicking right button calls scrollBy on the container', async () => { test('clicking right button calls scrollBy on the container', async () => {
const { container } = render(HorizontalScrollRow, { props: { items: ['a', 'b'] } }); const { container } = render(HorizontalScrollRow, { props: { rows: [['a', 'b']] } });
const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement; const scroller = container.querySelector('[data-testid="scroll-container"]') as HTMLElement;
const spy = vi.fn(); const spy = vi.fn();
scroller.scrollBy = spy as unknown as HTMLElement['scrollBy']; scroller.scrollBy = spy as unknown as HTMLElement['scrollBy'];
@@ -31,12 +31,12 @@ describe('HorizontalScrollRow', () => {
}); });
test('renders title as a heading when prop is provided', () => { test('renders title as a heading when prop is provided', () => {
render(HorizontalScrollRow, { props: { items: ['a'], title: 'My section' } }); render(HorizontalScrollRow, { props: { rows: [['a']], title: 'My section' } });
expect(screen.getByRole('heading', { name: 'My section' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'My section' })).toBeInTheDocument();
}); });
test('omits heading when title prop is absent', () => { test('omits heading when title prop is absent', () => {
render(HorizontalScrollRow, { props: { items: ['a'] } }); render(HorizontalScrollRow, { props: { rows: [['a']] } });
expect(screen.queryByRole('heading')).not.toBeInTheDocument(); expect(screen.queryByRole('heading')).not.toBeInTheDocument();
}); });
}); });
@@ -9,10 +9,14 @@
let { let {
onIntersect, onIntersect,
enabled = true, enabled = true,
rootMargin = '300px' rootMargin = '800px'
}: { }: {
onIntersect: () => void; onIntersect: () => void;
enabled?: boolean; enabled?: boolean;
/** Distance below the viewport at which the sentinel triggers. Larger
values fire earlier so the next page is fetched well before the
user scrolls into empty space. Default 800px ≈ 3-4 rows of
cards on a typical library grid. */
rootMargin?: string; rootMargin?: string;
} = $props(); } = $props();
+2 -2
View File
@@ -46,7 +46,7 @@
</script> </script>
{#if current} {#if current}
<div class="flex min-h-[108px] items-center gap-4 border-t border-border bg-surface px-4 py-4"> <div class="flex min-h-[108px] items-center gap-4 border-t border-border bg-surface px-4 py-1.5">
<!-- Left: cover + title + artist + like + menu --> <!-- Left: cover + title + artist + like + menu -->
<div class="flex w-72 min-w-0 items-center gap-3"> <div class="flex w-72 min-w-0 items-center gap-3">
<a <a
@@ -57,7 +57,7 @@
<img <img
src={`/api/albums/${current.album_id}/cover`} src={`/api/albums/${current.album_id}/cover`}
alt="" alt=""
class="h-20 w-20 rounded-md object-cover" class="h-24 w-24 rounded-md object-cover"
onerror={onCoverError} onerror={onCoverError}
/> />
</a> </a>
+30 -32
View File
@@ -28,7 +28,7 @@
{:else if showSkeleton.value && !data} {:else if showSkeleton.value && !data}
<p class="text-text-secondary">Loading…</p> <p class="text-text-secondary">Loading…</p>
{:else if data} {:else if data}
<!-- Recently added: 50 albums in 2 rows of 25 --> <!-- Recently added: 50 albums in 2 rows of 25, scrolling together -->
<section class="space-y-3"> <section class="space-y-3">
{#if data.recently_added_albums.length === 0} {#if data.recently_added_albums.length === 0}
<header> <header>
@@ -36,21 +36,21 @@
</header> </header>
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p> <p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
{:else} {:else}
{#each chunk(data.recently_added_albums, 25) as row, i (i)} <HorizontalScrollRow
<HorizontalScrollRow rows={chunk(data.recently_added_albums, 25)}
items={row} title="Recently added"
title={i === 0 ? 'Recently added' : undefined} ariaLabel="Recently added"
ariaLabel="Recently added row {i + 1}" >
> {#snippet item(album: import('$lib/api/types').AlbumRef)}
{#snippet item(album: import('$lib/api/types').AlbumRef)} <div class="w-44"><AlbumCard {album} /></div>
<div class="w-44"><AlbumCard {album} /></div> {/snippet}
{/snippet} </HorizontalScrollRow>
</HorizontalScrollRow>
{/each}
{/if} {/if}
</section> </section>
<!-- Rediscover: albums + artists --> <!-- Rediscover: albums + artists. Kept as two scrollers because the
card types differ (square vs circular); coupling them in one
scroller would interleave shapes awkwardly. -->
<section class="space-y-3"> <section class="space-y-3">
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} {#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
<header> <header>
@@ -60,7 +60,7 @@
{:else} {:else}
{#if data.rediscover_albums.length > 0} {#if data.rediscover_albums.length > 0}
<HorizontalScrollRow <HorizontalScrollRow
items={data.rediscover_albums} rows={[data.rediscover_albums]}
title="Rediscover" title="Rediscover"
ariaLabel="Rediscover albums" ariaLabel="Rediscover albums"
> >
@@ -71,7 +71,7 @@
{/if} {/if}
{#if data.rediscover_artists.length > 0} {#if data.rediscover_artists.length > 0}
<HorizontalScrollRow <HorizontalScrollRow
items={data.rediscover_artists} rows={[data.rediscover_artists]}
title={data.rediscover_albums.length === 0 ? 'Rediscover' : undefined} title={data.rediscover_albums.length === 0 ? 'Rediscover' : undefined}
ariaLabel="Rediscover artists" ariaLabel="Rediscover artists"
> >
@@ -83,7 +83,7 @@
{/if} {/if}
</section> </section>
<!-- Most played: 75 tracks in 3 rows of 25 --> <!-- Most played: 75 tracks in 3 rows of 25, scrolling together -->
<section class="space-y-3"> <section class="space-y-3">
{#if data.most_played_tracks.length === 0} {#if data.most_played_tracks.length === 0}
<header> <header>
@@ -91,21 +91,19 @@
</header> </header>
<p class="text-text-secondary">No plays to draw from. Listen to something.</p> <p class="text-text-secondary">No plays to draw from. Listen to something.</p>
{:else} {:else}
{#each chunk(data.most_played_tracks, 25) as row, i (i)} <HorizontalScrollRow
<HorizontalScrollRow rows={chunk(data.most_played_tracks, 25)}
items={row} title="Most played"
title={i === 0 ? 'Most played' : undefined} ariaLabel="Most played"
ariaLabel="Most played row {i + 1}" >
> {#snippet item(track: import('$lib/api/types').TrackRef, globalIdx: number)}
{#snippet item(track: import('$lib/api/types').TrackRef, idx: number)} <CompactTrackCard
<CompactTrackCard {track}
{track} sectionTracks={data.most_played_tracks}
sectionTracks={row} index={globalIdx}
index={idx} />
/> {/snippet}
{/snippet} </HorizontalScrollRow>
</HorizontalScrollRow>
{/each}
{/if} {/if}
</section> </section>
@@ -118,7 +116,7 @@
<p class="text-text-secondary">No recent plays.</p> <p class="text-text-secondary">No recent plays.</p>
{:else} {:else}
<HorizontalScrollRow <HorizontalScrollRow
items={data.last_played_artists} rows={[data.last_played_artists]}
title="Last played" title="Last played"
ariaLabel="Last played artists" ariaLabel="Last played artists"
> >