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
@@ -3,12 +3,12 @@
import Disc from '@lucide/svelte/icons/disc-3';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props();
@@ -35,19 +35,21 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-7 sm:mt-8">
<div class="mt-7 grid-cards sm:mt-8">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
<MediaCard
href="/albums/{album.id}"
title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
trackCount={Number(album.trackCount)}
subtitle={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large"
src="/api/albums/{album.id}/cover?size=large"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={Disc}
@@ -378,13 +378,12 @@
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
<div class="mt-7 grid-wide">
{#each genreTiles as genre, i (genre.query)}
<a
href={searchHref(genre.query)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-110"
style="animation-delay:{Math.min(i, 10) *
40}ms; background:linear-gradient(140deg, oklch(var(--mf-tile-l) var(--mf-tile-c) {genre.hue}), oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) {genre.hue}))"
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4.25 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{genre.hue}"
>
<div class="text-on-art">
{genre.label}
@@ -2,12 +2,11 @@
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong, plural } from '$lib/format';
import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
let { data } = $props();
@@ -36,18 +35,20 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
<div class="mt-7 grid-cards-lg sm:mt-8">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={ListMusic}
+16 -12
View File
@@ -3,13 +3,13 @@
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/format';
let { data, form } = $props();
@@ -58,9 +58,11 @@
<CollectionHeader
eyebrow="Perfil"
title={profile.name}
meta="{profile.followersCount} {profile.followersCount === 1
? 'seguidor'
: 'seguidores'} · {profile.followingCount} siguiendo"
meta="{plural(
Number(profile.followersCount),
'seguidor',
'seguidores'
)} · {profile.followingCount} siguiendo"
align="center"
actions={canFollow ? followAction : undefined}
>
@@ -73,18 +75,20 @@
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-6">
<div class="mt-6 grid-cards-lg">
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i}
/>
{/each}
</MediaGrid>
</div>
{:else}
<EmptyState
icon={ListMusic}
+20 -4
View File
@@ -6,6 +6,7 @@
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { mixItemTarget } from '$lib/mixes';
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
import {
isTargetCurrent,
@@ -20,7 +21,7 @@
type TrackTarget
} from '$lib/tracks';
import Artwork from '$lib/components/ui/Artwork.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import MediaCard from '$lib/components/ui/MediaCard.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
@@ -107,6 +108,14 @@
function openContextMenu(event: MouseEvent, target: TrackTarget) {
trackMenu.open(event, target);
}
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
event.preventDefault();
player.playQueue(
mix.items.map((item) => queueItemForTarget(mixItemTarget(item))),
0
);
}
</script>
<svelte:head>
@@ -274,9 +283,16 @@
<section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
<div class="grid-tiles">
{#each mixes as mix, i (mix.id)}
<MixTile {mix} index={i} />
<MediaCard
href="/mixes/{mix.id}"
title={mix.title}
subtitle={mix.subtitle}
trackIds={mix.items.map((item) => item.trackId)}
onPlay={(event) => playMix(mix, event)}
index={i}
/>
{/each}
</div>
{:else}
@@ -351,7 +367,7 @@
{/snippet}
</SectionHeading>
{#if playlists.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
<div class="grid-wide">
{#each playlists as playlist, i (playlist.id)}
<ListRow
onclick={() => goto(`/playlists/${playlist.id}`)}
+7
View File
@@ -196,6 +196,13 @@
--mf-dock-h: var(--mf-player-h);
}
}
@utility genre-tile {
background: linear-gradient(
140deg,
oklch(var(--mf-tile-l) var(--mf-tile-c) var(--tile-hue)),
oklch(var(--mf-tile-shade-l) var(--mf-tile-shade-c) var(--tile-hue))
);
}
@layer base {
svg.lucide {