Files
minstrel/web/src/routes/+page.svelte
T
bvandeusen 3f240bc777
test-web / test (push) Successful in 32s
feat(web): "You might like" Home row (#790 web client slice)
The web UI rendered a fixed set of Home sections and had no code for the
server's you_might_like_albums / you_might_like_artists (shipped in
v2026.06.11), so the row was absent in the web client. Adds it, mirroring
the Rediscover block, positioned directly under the system-playlists row.

- types.ts HomePayload: two new slices (server always emits them; web ships
  in lockstep with the server).
- +page.svelte: a "You might like" section (albums + artists scrollers) as the
  first section under the playlists row, with a "still learning your taste"
  empty state for the cold-start/gated case. Reuses existing AlbumCard /
  ArtistCard / HorizontalScrollRow.
- home.test.ts / page.test.ts: mock payloads gain the two fields.

Completes the You-might-like row across all three clients (server already
emits it; Android in v2026.06.11; web here).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 22:59:58 -04:00

303 lines
12 KiB
Svelte

<script lang="ts">
import { pageTitle } from '$lib/branding';
import { createHomeQuery } from '$lib/api/home';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import PlaylistCard from '$lib/components/PlaylistCard.svelte';
import PlaylistPlaceholderCard from '$lib/components/PlaylistPlaceholderCard.svelte';
import { createPlaylistsQuery } from '$lib/api/playlists';
import { createSystemPlaylistsStatusQuery } from '$lib/api/me';
import type { Playlist } from '$lib/api/types';
const queryStore = createHomeQuery();
const query = $derived($queryStore);
const data = $derived(query.data);
const showSkeleton = useDelayed(() => query.isPending);
// Each "Most played" row gets 25 of the 75 tracks (server returns up
// to 75; rows 0/1/2 slice [0..25), [25..50), [50..75)).
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = [];
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
return out;
}
const systemPlaylistsStore = $derived(createPlaylistsQuery('system'));
const userPlaylistsStore = $derived(createPlaylistsQuery('user'));
const systemStatusStore = $derived(createSystemPlaylistsStatusQuery());
const systemPlaylistsQ = $derived($systemPlaylistsStore);
const userPlaylistsQ = $derived($userPlaylistsStore);
const systemStatusQ = $derived($systemStatusStore);
const forYouPlaylist = $derived(
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'for_you') ?? null
);
const discoverPlaylist = $derived(
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === 'discover') ?? null
);
const songsLikePlaylists = $derived(
(systemPlaylistsQ.data?.owned ?? [])
.filter((p) => p.system_variant === 'songs_like_artist')
.slice(0, 3)
);
// Secondary system kinds the server generates that don't get pinned
// to fixed Home slots: surfaced after the Songs-like slots when they
// exist, in server-registry order (internal/playlists/system.go).
// No placeholders for these — they depend on library shape (Deep
// cuts needs deep albums, On this day needs prior history, etc.) so
// a missing one means "not enough data," not "still building."
// Mirrors Android's SECONDARY_SYSTEM_VARIANTS (HomeScreen.kt). Operator
// request 2026-06-01 for web parity.
const SECONDARY_SYSTEM_VARIANTS = [
'deep_cuts',
'rediscover',
'new_for_you',
'on_this_day',
'first_listens'
] as const;
const secondarySystemPlaylists = $derived(
SECONDARY_SYSTEM_VARIANTS
.map((variant) =>
(systemPlaylistsQ.data?.owned ?? []).find((p) => p.system_variant === variant)
)
.filter((p): p is Playlist => p != null)
);
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
function placeholderVariant(slot: 'for-you' | 'discover' | 'songs-like'): PlaceholderVariant {
const s = systemStatusQ.data;
if (!s) return 'pending';
if (s.in_flight) return 'building';
if (s.last_error) return 'failed';
if (slot === 'songs-like' && s.last_run_at) return 'seed-needed';
return 'pending';
}
type PlaylistRowItem =
| { kind: 'real'; playlist: Playlist }
| { kind: 'placeholder'; label: string; variant: PlaceholderVariant };
const playlistsRow = $derived.by((): PlaylistRowItem[] => {
const out: PlaylistRowItem[] = [];
// Slot 1: For-You (real or placeholder).
if (forYouPlaylist) {
out.push({ kind: 'real', playlist: forYouPlaylist });
} else {
out.push({ kind: 'placeholder', label: 'For You', variant: placeholderVariant('for-you') });
}
// Slot 2: Discover (real or placeholder). Server emits this with
// system_variant='discover' once the recommendation engine has
// eligible tracks.
if (discoverPlaylist) {
out.push({ kind: 'real', playlist: discoverPlaylist });
} else {
out.push({ kind: 'placeholder', label: 'Discover', variant: placeholderVariant('discover') });
}
// Slots 3-5: Songs-like (real first, padded with placeholders).
for (let i = 0; i < 3; i++) {
if (songsLikePlaylists[i]) {
out.push({ kind: 'real', playlist: songsLikePlaylists[i] });
} else {
out.push({ kind: 'placeholder', label: 'Songs like…', variant: placeholderVariant('songs-like') });
}
}
// Secondary system kinds in server-registry order, when generated.
for (const p of secondarySystemPlaylists) {
out.push({ kind: 'real', playlist: p });
}
// User playlists trail (server returns most-recently-updated first).
for (const p of userPlaylists) {
out.push({ kind: 'real', playlist: p });
}
return out;
});
</script>
<svelte:head><title>{pageTitle('Home')}</title></svelte:head>
<div class="space-y-8">
<!-- Playlists: For-You + Songs-like + user playlists, single horizontal row -->
<section class="space-y-3">
<HorizontalScrollRow
rows={[playlistsRow]}
title="Playlists"
ariaLabel="Playlists"
>
{#snippet item(rowItem: PlaylistRowItem)}
<!-- Playlists at the page anchor: largest of the carousels.
Sized so a typical viewport shows ~5-6 across instead
of cramming 8-9. -->
<div class="w-56">
{#if rowItem.kind === 'real'}
<PlaylistCard playlist={rowItem.playlist} />
{:else}
<PlaylistPlaceholderCard label={rowItem.label} variant={rowItem.variant} />
{/if}
</div>
{/snippet}
</HorizontalScrollRow>
</section>
{#if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !data}
<p class="text-text-secondary">Loading…</p>
{:else if data}
<!-- You might like: taste-predicted in-library albums + artists the user
doesn't actively spin (server section, daily-built, cold-start gated).
Sits directly under the system playlists row. Two scrollers like
Rediscover (square album vs circular artist cards). -->
<section class="space-y-3">
{#if data.you_might_like_albums.length === 0 && data.you_might_like_artists.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">You might like</h2>
</header>
<p class="text-text-secondary">We're still learning your taste — keep listening and picks you might like will show up here.</p>
{:else}
{#if data.you_might_like_albums.length > 0}
<HorizontalScrollRow
rows={[data.you_might_like_albums]}
title="You might like"
ariaLabel="You might like albums"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-40"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if data.you_might_like_artists.length > 0}
<HorizontalScrollRow
rows={[data.you_might_like_artists]}
title={data.you_might_like_albums.length === 0 ? 'You might like' : undefined}
ariaLabel="You might like artists"
>
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{/if}
</section>
<!-- Recently added: 50 albums in 2 rows of 25, scrolling together -->
<section class="space-y-3">
{#if data.recently_added_albums.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
</header>
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
{:else}
<HorizontalScrollRow
rows={chunk(data.recently_added_albums, 25)}
title="Recently added"
ariaLabel="Recently added"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-48"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
</section>
<!-- 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">
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
</header>
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
{:else}
{#if data.rediscover_albums.length > 0}
<HorizontalScrollRow
rows={[data.rediscover_albums]}
title="Rediscover"
ariaLabel="Rediscover albums"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<!-- Rediscover sits below Recently Added in the size
hierarchy. w-40 keeps it readable without competing
with the freshest content above. -->
<div class="w-40"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if data.rediscover_artists.length > 0}
<HorizontalScrollRow
rows={[data.rediscover_artists]}
title={data.rediscover_albums.length === 0 ? 'Rediscover' : undefined}
ariaLabel="Rediscover artists"
>
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{/if}
</section>
<!-- Most played: 75 tracks in 3 rows of 25, scrolling together -->
<section class="space-y-3">
{#if data.most_played_tracks.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
</header>
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
{:else}
<HorizontalScrollRow
rows={chunk(data.most_played_tracks, 25)}
title="Most played"
ariaLabel="Most played"
>
{#snippet item(track: import('$lib/api/types').TrackRef, globalIdx: number)}
<CompactTrackCard
{track}
sectionTracks={data.most_played_tracks}
index={globalIdx}
/>
{/snippet}
</HorizontalScrollRow>
{/if}
</section>
<!-- Last played artists -->
<section class="space-y-3">
{#if data.last_played_artists.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
</header>
<p class="text-text-secondary">No recent plays.</p>
{:else}
<HorizontalScrollRow
rows={[data.last_played_artists]}
title="Last played"
ariaLabel="Last played artists"
>
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
</section>
{/if}
</div>