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 -1
View File
@@ -781,7 +781,7 @@ Tareas:
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) | | P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) | | P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) | | P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
| P9 | ⬜ | | | | P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
| P10 | ⬜ | | | | P10 | ⬜ | | |
| P11 | ⬜ | | | | P11 | ⬜ | | |
| P12 | ⬜ | | | | P12 | ⬜ | | |
@@ -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"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Artwork from './Artwork.svelte';
import PlayButton from './PlayButton.svelte';
interface Props { interface Props {
href: string; href: string;
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
index?: number; src?: string | null;
trackIds?: (string | number)[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void; 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> </script>
<a <a {href} class="group animate-enter min-w-0 text-left" style="--i:{index}" {oncontextmenu}>
{href} <div class="relative">
class="group animate-enter min-w-0 text-left" {#if art}
style="animation-delay:{index * 45}ms" {@render art()}
{oncontextmenu} {:else}
> <Artwork
{@render art(mediaCardArt)} {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> <div class="mt-2.5 truncate text-sm text-fg">{title}</div>
{#if subtitle} {#if subtitle}
<div class="mt-0.5 truncate text-xs text-fg-3">{subtitle}</div> <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>
+13
View File
@@ -26,3 +26,16 @@ export function fmtPlays(count: number | string | undefined): string {
const value = Math.max(0, Math.floor(Number(count ?? 0))); const value = Math.max(0, Math.floor(Number(count ?? 0)));
return plural(value, 'reproducción', 'reproducciones'); return plural(value, 'reproducción', 'reproducciones');
} }
export function albumMeta(releaseYear?: number, trackCount?: number): string {
return [
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
]
.filter((part): part is string => !!part)
.join(' · ');
}
export function playlistMeta(trackCount: number): string {
return plural(trackCount, 'canción', 'canciones');
}
@@ -3,12 +3,12 @@
import Disc from '@lucide/svelte/icons/disc-3'; import Disc from '@lucide/svelte/icons/disc-3';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte'; import MediaCard from '$lib/components/ui/MediaCard.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import AlbumForm from '$lib/components/ui/AlbumForm.svelte'; import AlbumForm from '$lib/components/ui/AlbumForm.svelte';
import Modal from '$lib/components/ui/Modal.svelte'; import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import { albumMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -35,19 +35,21 @@
</PageHeader> </PageHeader>
{#if items.length > 0} {#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)} {#each items as album, i (album.id)}
<AlbumCard <MediaCard
id={album.id} href="/albums/{album.id}"
title={album.title} title={album.title}
releaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)} subtitle={albumMeta(
trackCount={Number(album.trackCount)} album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds} trackIds={album.coverTrackIds}
coverSrc="/api/albums/{album.id}/cover?size=large" src="/api/albums/{album.id}/cover?size=large"
index={i} index={i}
/> />
{/each} {/each}
</MediaGrid> </div>
{:else} {:else}
<EmptyState <EmptyState
icon={Disc} icon={Disc}
@@ -378,13 +378,12 @@
{#if !data.query} {#if !data.query}
<!-- GÉNEROS --> <!-- 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)} {#each genreTiles as genre, i (genre.query)}
<a <a
href={searchHref(genre.query)} 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" 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="animation-delay:{Math.min(i, 10) * style="--i:{i}; --tile-hue:{genre.hue}"
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}))"
> >
<div class="text-on-art"> <div class="text-on-art">
{genre.label} {genre.label}
@@ -2,12 +2,11 @@
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte'; import MediaCard from '$lib/components/ui/MediaCard.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte'; import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong, plural } from '$lib/format'; import { fmtDurationLong, plural, playlistMeta } from '$lib/format';
let { data } = $props(); let { data } = $props();
@@ -36,18 +35,20 @@
</PageHeader> </PageHeader>
{#if items.length > 0} {#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)} {#each items as playlist, i (playlist.id)}
<PlaylistCard <MediaCard
id={playlist.id} href="/playlists/{playlist.id}"
name={playlist.name} title={playlist.name}
trackCount={playlist.trackCount} subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt} src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i} index={i}
/> />
{/each} {/each}
</MediaGrid> </div>
{:else} {:else}
<EmptyState <EmptyState
icon={ListMusic} icon={ListMusic}
+16 -12
View File
@@ -3,13 +3,13 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte'; import Page from '$lib/components/ui/Page.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte'; import MediaCard from '$lib/components/ui/MediaCard.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte'; import Avatar from '$lib/components/ui/Avatar.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { plural, playlistMeta } from '$lib/format';
let { data, form } = $props(); let { data, form } = $props();
@@ -58,9 +58,11 @@
<CollectionHeader <CollectionHeader
eyebrow="Perfil" eyebrow="Perfil"
title={profile.name} title={profile.name}
meta="{profile.followersCount} {profile.followersCount === 1 meta="{plural(
? 'seguidor' Number(profile.followersCount),
: 'seguidores'} · {profile.followingCount} siguiendo" 'seguidor',
'seguidores'
)} · {profile.followingCount} siguiendo"
align="center" align="center"
actions={canFollow ? followAction : undefined} actions={canFollow ? followAction : undefined}
> >
@@ -73,18 +75,20 @@
<SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" /> <SectionHeading title="Playlists públicas" subtitle="Creadas y compartidas por este usuario" />
{#if playlists.length > 0} {#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)} {#each playlists as playlist, i (playlist.id)}
<PlaylistCard <MediaCard
id={playlist.id} href="/playlists/{playlist.id}"
name={playlist.name} title={playlist.name}
trackCount={playlist.trackCount} subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt} src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
)}"
index={i} index={i}
/> />
{/each} {/each}
</MediaGrid> </div>
{:else} {:else}
<EmptyState <EmptyState
icon={ListMusic} icon={ListMusic}
+20 -4
View File
@@ -6,6 +6,7 @@
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte'; import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable'; import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed'; import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { mixItemTarget } from '$lib/mixes';
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format'; import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
import { import {
isTargetCurrent, isTargetCurrent,
@@ -20,7 +21,7 @@
type TrackTarget type TrackTarget
} from '$lib/tracks'; } from '$lib/tracks';
import Artwork from '$lib/components/ui/Artwork.svelte'; 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 EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte'; import ListRow from '$lib/components/ui/ListRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
@@ -107,6 +108,14 @@
function openContextMenu(event: MouseEvent, target: TrackTarget) { function openContextMenu(event: MouseEvent, target: TrackTarget) {
trackMenu.open(event, target); 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> </script>
<svelte:head> <svelte:head>
@@ -274,9 +283,16 @@
<section> <section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" /> <SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0} {#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)} {#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} {/each}
</div> </div>
{:else} {:else}
@@ -351,7 +367,7 @@
{/snippet} {/snippet}
</SectionHeading> </SectionHeading>
{#if playlists.length > 0} {#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)} {#each playlists as playlist, i (playlist.id)}
<ListRow <ListRow
onclick={() => goto(`/playlists/${playlist.id}`)} onclick={() => goto(`/playlists/${playlist.id}`)}
+7
View File
@@ -196,6 +196,13 @@
--mf-dock-h: var(--mf-player-h); --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 { @layer base {
svg.lucide { svg.lucide {