5698cfe8e4
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
219 lines
8.0 KiB
Svelte
219 lines
8.0 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 songsLikePlaylists = $derived(
|
|
(systemPlaylistsQ.data?.owned ?? [])
|
|
.filter((p) => p.system_variant === 'songs_like_artist')
|
|
.slice(0, 3)
|
|
);
|
|
|
|
const userPlaylists = $derived(userPlaylistsQ.data?.owned ?? []);
|
|
|
|
type PlaceholderVariant = 'building' | 'failed' | 'pending' | 'seed-needed';
|
|
function placeholderVariant(slot: 'for-you' | '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') });
|
|
}
|
|
|
|
// Slots 2-4: 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') });
|
|
}
|
|
}
|
|
|
|
// 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)}
|
|
<div class="w-44">
|
|
{#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}
|
|
<!-- 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-44"><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)}
|
|
<div class="w-44"><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>
|