web-player: cards and grids (MediaCard)

MediaCard now builds its own art (src/trackIds via Artwork
size="fill", or a custom art snippet) and its own play button
(onPlay, with a scrim and a glass PlayButton on hover) instead of
relying on each wrapper passing it a snippet. Replaces AlbumCard,
PlaylistCard and MixTile, which are removed along with
MediaGrid.svelte (their grids move to the
grid-cards/grid-cards-lg/grid-tiles/grid-wide @utility recipes).

format.ts gains albumMeta() and playlistMeta() (previously ad-hoc
calculations inside each card). Explore's genre tile moves from an
inline style= gradient to the genre-tile @utility recipe +
--tile-hue. Along the way, fixes the "follower/s" pluralization on
the user profile that had been missed.

Delta documented: the art's hover-zoom and the scrim+PlayButton
(previously exclusive to MixTile) become MediaCard behavior gated on
the onPlay prop — they'll apply to any future card that uses it, for
consistency.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-21 00:30:30 +02:00
parent 8cea298b2a
commit 964dfc809f
13 changed files with 126 additions and 221 deletions
@@ -1,56 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import Artwork from './Artwork.svelte';
import { plural } from '$lib/format';
interface Props {
id: string;
title: string;
href?: string;
subtitle?: string;
typeLabel?: string;
releaseYear?: number;
trackCount?: number;
trackIds?: (string | number)[];
coverSrc?: string | null;
index?: number;
onContextMenu?: (event: MouseEvent) => void;
}
let {
id,
title,
href,
subtitle,
typeLabel,
releaseYear,
trackCount,
trackIds = [],
coverSrc,
index = 0,
onContextMenu
}: Props = $props();
const meta = $derived(
[
typeLabel,
subtitle,
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
]
.filter((part) => !!part)
.join(' · ')
);
</script>
<MediaCard
href={href ?? `/albums/${id}`}
{title}
subtitle={meta}
{index}
oncontextmenu={onContextMenu}
>
{#snippet art(artClass)}
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
{/snippet}
</MediaCard>
@@ -1,30 +1,59 @@
<script lang="ts" module>
export const mediaCardArt =
'aspect-square w-full rounded-art transition duration-300 ease-out';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
interface Props {
href: string;
title: string;
subtitle?: string | null;
index?: number;
src?: string | null;
trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
index?: number;
}
let { href, title, subtitle, index = 0, oncontextmenu, art }: Props = $props();
let {
href,
title,
subtitle,
src,
trackIds = [],
onPlay,
art,
oncontextmenu,
index = 0
}: Props = $props();
</script>
<a
{href}
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
{oncontextmenu}
>
{@render art(mediaCardArt)}
<a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
<div class="relative">
{#if art}
{@render art()}
{:else}
<Artwork
{src}
{trackIds}
size="fill"
alt={title}
class="rounded-art transition duration-300 group-hover:scale-105"
/>
{/if}
{#if onPlay}
<div
class="pointer-events-none absolute inset-0 rounded-art bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<PlayButton
variant="glass"
size="sm"
onclick={onPlay}
label="Reproducir {title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover:opacity-100"
/>
{/if}
</div>
<div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div>
@@ -1,27 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
minMobile?: string;
class?: string;
children: Snippet;
}
let {
as = 'div',
min = '165px',
minMobile = '150px',
class: klass = '',
children
}: Props = $props();
</script>
<svelte:element
this={as}
class="grid media-grid gap-3 sm:gap-4 {klass}"
style="--mf-grid-min:{min}; --mf-grid-min-mobile:{minMobile}"
>
{@render children()}
</svelte:element>
@@ -1,53 +0,0 @@
<script lang="ts">
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
interface Props {
mix: Mix;
index?: number;
class?: string;
}
let { mix, index = 0, class: klass = '' }: Props = $props();
const trackIds = $derived(mix.items.map((item) => item.trackId));
function play(event: MouseEvent) {
event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
player.playQueue(items, 0);
}
</script>
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}"
style="--i:{index}"
>
<Artwork
{trackIds}
size="fill"
alt={mix.title}
class="rounded-art transition duration-300 group-hover/tile:scale-105"
>
<div
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<PlayButton
variant="glass"
size="sm"
onclick={play}
label="Reproducir {mix.title}"
class="absolute right-2.5 bottom-2.5 opacity-0 transition-opacity duration-150 group-hover/tile:opacity-100"
/>
</Artwork>
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title}
</div>
{#if mix.subtitle}
<div class="mt-1 truncate text-xs text-fg-3">{mix.subtitle}</div>
{/if}
</a>
@@ -1,30 +0,0 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import Artwork from './Artwork.svelte';
import { plural } from '$lib/format';
interface Props {
id: string;
name: string;
trackCount: number;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, trackCount, trackIds, updatedAt, index = 0 }: Props = $props();
const subtitle = $derived(plural(trackCount, 'canción', 'canciones'));
</script>
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="fill"
alt={name}
class={artClass}
/>
{/snippet}
</MediaCard>