Files
minstrel/web/src/routes/artists/[id]/+page.svelte
T

141 lines
5.3 KiB
Svelte

<script lang="ts">
import { Play } from 'lucide-svelte';
import { page } from '$app/state';
import {
createArtistQuery,
createSimilarArtistsQuery,
createArtistTopTracksQuery
} from '$lib/api/queries';
import { pageTitle } from '$lib/branding';
import AlbumCard from '$lib/components/AlbumCard.svelte';
import ArtistCard from '$lib/components/ArtistCard.svelte';
import CompactTrackCard from '$lib/components/CompactTrackCard.svelte';
import HorizontalScrollRow from '$lib/components/HorizontalScrollRow.svelte';
import LikeButton from '$lib/components/LikeButton.svelte';
import LibrarySkeleton from '$lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '$lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '$lib/utils/useDelayed.svelte';
import { api } from '$lib/api/client';
import { playQueue } from '$lib/player/store.svelte';
import type { ApiError } from '$lib/api/client';
import type { ArtistRef, TrackRef } from '$lib/api/types';
const id = $derived(page.params.id ?? '');
const queryStore = $derived(createArtistQuery(id));
const query = $derived($queryStore);
const showSkeleton = useDelayed(() => query.isPending);
// Secondary sections, lazy-loaded alongside the detail. Each hides itself
// when empty (no similarity matches yet / user hasn't played this artist).
const similarStore = $derived(createSimilarArtistsQuery(id));
const similarQuery = $derived($similarStore);
const topTracksStore = $derived(createArtistTopTracksQuery(id));
const topTracksQuery = $derived($topTracksStore);
const notFound = $derived(
query.isError && (query.error as unknown as ApiError | undefined)?.code === 'not_found'
);
// Mirrors ArtistCard's play overlay: fetch every track for the artist
// (server already honours per-user lidarr_quarantine), shuffle client-side,
// start the queue. Bypassed when the artist has nothing to play.
function shuffle<T>(items: T[]): T[] {
const arr = items.slice();
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
let isStartingPlay = $state(false);
async function onPlay() {
if (isStartingPlay || !id) return;
isStartingPlay = true;
try {
const tracks = await api.get<TrackRef[]>(`/api/artists/${id}/tracks`);
if (tracks.length === 0) return;
playQueue(shuffle(tracks), 0);
} finally {
isStartingPlay = false;
}
}
</script>
<svelte:head>
<title>{pageTitle(query.data?.name ? `Artist · ${query.data.name}` : 'Artist')}</title>
</svelte:head>
<div class="space-y-4">
<a href="/" class="text-sm text-text-secondary hover:text-text-primary">← Library</a>
{#if notFound}
<div role="alert" class="rounded border border-border bg-surface p-4">
<p>Artist not found.</p>
<a href="/" class="mt-2 inline-block text-sm text-accent">Back to Library</a>
</div>
{:else if query.isError}
<ApiErrorBanner error={query.error} onRetry={query.refetch} />
{:else if showSkeleton.value && !query.data}
<LibrarySkeleton variant="grid" count={12} />
{:else if query.data}
{@const detail = query.data}
<header class="flex items-center gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-3">
<h1 class="font-display text-2xl font-medium text-text-primary">{detail.name}</h1>
<LikeButton entityType="artist" entityId={detail.id} size="lg" />
</div>
<p class="text-sm text-text-secondary">
{detail.album_count} {detail.album_count === 1 ? 'album' : 'albums'}
</p>
</div>
<button
type="button"
aria-label={`Play ${detail.name}`}
onclick={onPlay}
disabled={isStartingPlay || detail.album_count === 0}
class="flex h-12 w-12 items-center justify-center rounded-full bg-accent text-text-primary shadow transition-transform hover:scale-105 focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50 disabled:hover:scale-100"
>
<Play size={24} strokeWidth={1.5} fill="currentColor" class="ml-0.5" />
</button>
</header>
{#if detail.albums.length === 0}
<p class="text-text-secondary">This artist has no albums in the library.</p>
{:else}
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{#each detail.albums as album (album.id)}
<AlbumCard {album} />
{/each}
</div>
{/if}
{#if (topTracksQuery.data?.length ?? 0) > 0}
{@const top = topTracksQuery.data ?? []}
<section class="space-y-3">
<HorizontalScrollRow rows={[top]} title="Top tracks" ariaLabel={`Top tracks for ${detail.name}`}>
{#snippet item(track: TrackRef, globalIdx: number)}
<CompactTrackCard {track} sectionTracks={top} index={globalIdx} />
{/snippet}
</HorizontalScrollRow>
</section>
{/if}
{#if (similarQuery.data?.length ?? 0) > 0}
<section class="space-y-3">
<HorizontalScrollRow
rows={[similarQuery.data ?? []]}
title="Similar artists"
ariaLabel="Similar artists"
>
{#snippet item(artist: ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
</HorizontalScrollRow>
</section>
{/if}
{/if}
</div>