web-player: P3 — Artwork y Avatar unificados

Crea src/lib/components/ui/Artwork.svelte y Avatar.svelte (B.3) y
migra todos los usos de Cover, PlaylistArt, MixArt y ArtistAvatar
(Sidebar, Queue, TrackInfo, TrackContextMenu, AlbumContextMenu,
TrackTitleCell, AlbumCard, PlaylistCard, MixTile, AlbumHeader,
PlaylistHeader, Home, Explorar, mezcla, perfil de usuario, TopBar,
MobileHeader). Borra los 4 componentes viejos.

Artwork: tamaño decide dimensión y radio automáticamente (tokens
cover-*/radius de P1); cascada src → mosaico (≥4 trackIds) → primera
carátula → fallback, con caída en cascada si cada paso falla (antes
repartida en 3 componentes con lógica ligeramente distinta). Añade un
tamaño 'fill' (aspect-square w-full) no descrito en la spec, necesario
para las tarjetas de grid fluidas hasta que P9 introduzca MediaCard.

TrackTitleCell cambia su prop `coverSize` (clases Tailwind libres) por
`size: 'sm'|'lg'` delegando en Artwork. SearchResultRow pierde su prop
`kind` y el radio por tipo (ahora lo decide Artwork/Avatar).

Decisión de producto (P3, sin bloquear en pregunta): se quita el
nombre bajo el avatar de perfil en /u/[id] por ser redundante con el
título de CollectionHeader.

Small deltas de radio/tamaño documentados en README del plan (auto-
radio por tamaño reemplaza clases ad-hoc; ver tabla B.1.1/B.1.2).

Verificado con npm run check/build; sin QA visual autenticada (Docker
no disponible en esta sesión).
This commit is contained in:
2026-09-20 22:39:05 +02:00
parent 3e56725566
commit 521cafc5c4
25 changed files with 326 additions and 359 deletions
@@ -3,7 +3,7 @@
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -51,12 +51,7 @@
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()}
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
/>
<Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
@@ -9,8 +9,8 @@
import Alert from '$lib/components/ui/Alert.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
@@ -296,40 +296,25 @@
{#if topResult}
{#snippet topResultBody()}
{#if topResult.kind === 'track'}
<Cover
trackId={targetId(topResult.target)}
size="large"
<Artwork
trackIds={[targetId(topResult.target)]}
size="xl"
alt={targetTitle(topResult.target)}
class="h-22 w-22 shrink-0 rounded-2xl"
class="shrink-0"
/>
{:else if topResult.kind === 'album'}
<PlaylistArt
trackIds={topResult.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl"
/>
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
{:else if topResult.kind === 'playlist'}
<PlaylistArt
playlistId={topResult.playlist.id}
<Artwork
src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
topResult.playlist.updatedAt
)}"
trackIds={topResult.playlist.coverTrackIds}
version={topResult.playlist.updatedAt}
class="h-22 w-22 shrink-0 rounded-2xl"
size="xl"
class="shrink-0"
/>
{:else}
<div
class="grid h-22 w-22 shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2"
>
{#if topResult.user.profilePictureUrl}
<img
src={topResult.user.profilePictureUrl}
alt=""
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xl font-semibold text-fg uppercase"
>{topResult.user.name.charAt(0)}</span
>
{/if}
</div>
<Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
{/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3">
@@ -443,7 +428,6 @@
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li>
<SearchResultRow
kind="track"
title={targetTitle(item)}
subtitle={targetArtist(item)}
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
@@ -452,16 +436,16 @@
oncontextmenu={(e) => openContextMenu(e, item)}
>
{#snippet art(artClass)}
<Cover
trackId={targetId(item)}
size="small"
<Artwork
trackIds={[targetId(item)]}
size="lg"
alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
</Artwork>
{/snippet}
</SearchResultRow>
</li>
@@ -495,14 +479,13 @@
{#each capped(albums) as album (album.id)}
<li>
<SearchResultRow
kind="album"
title={album.title}
meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}"
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
>
{#snippet art(artClass)}
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} />
<Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
{/snippet}
</SearchResultRow>
</li>
@@ -525,17 +508,9 @@
<ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)}
<li>
<SearchResultRow kind="user" title={u.name} href="/u/{u.id}">
<SearchResultRow title={u.name} href="/u/{u.id}">
{#snippet art(artClass)}
<div class="{artClass} grid place-items-center bg-surface-2">
{#if u.profilePictureUrl}
<img src={u.profilePictureUrl} alt="" class="h-full w-full object-cover" />
{:else}
<span class="text-sm font-semibold text-fg uppercase">
{u.name.charAt(0)}
</span>
{/if}
</div>
<Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
{/snippet}
</SearchResultRow>
</li>
@@ -559,16 +534,17 @@
{#each capped(playlistMatches) as playlist (playlist.id)}
<li>
<SearchResultRow
kind="playlist"
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
>
{#snippet art(artClass)}
<PlaylistArt
playlistId={playlist.id}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="lg"
class={artClass}
/>
{/snippet}
@@ -9,7 +9,7 @@
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import MixArt from '$lib/components/ui/MixArt.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
@@ -75,7 +75,12 @@
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
>
{#snippet cover()}
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41" />
<Artwork
trackIds={items.map((item) => item.trackId)}
size="hero"
alt={mix.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
@@ -1,5 +1,5 @@
<script lang="ts">
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -44,12 +44,12 @@
align="center"
>
{#snippet cover()}
<PlaylistArt
playlistId={id}
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="large"
version={updatedAt}
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
size="hero"
alt={name}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
@@ -7,7 +7,7 @@
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.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';
@@ -65,7 +65,7 @@
actions={canFollow ? followAction : undefined}
>
{#snippet cover()}
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
<Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet}
</CollectionHeader>
+20 -21
View File
@@ -20,7 +20,7 @@
type TrackTarget
} from '$lib/tracks';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
@@ -215,7 +215,7 @@
title={item.title}
artist={item.artist}
ownerUserId={item.ownerUserId}
coverSize="h-14 w-14"
size="lg"
active={isCurrent}
onClick={() => playContinue(i)}
/>
@@ -250,12 +250,14 @@
onclick={() => goto(`/playlists/${spotlight.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
>
<PlaylistArt
playlistId={spotlight.id}
<Artwork
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
spotlight.updatedAt
)}"
trackIds={spotlight.coverTrackIds}
version={spotlight.updatedAt}
size="large"
class="h-34 w-34 shrink-0 rounded-2xl"
size="2xl"
alt={spotlight.name}
class="shrink-0"
/>
<div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
@@ -292,9 +294,7 @@
? 'bg-accent-tint'
: 'hover:bg-hover'}"
>
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums"
>{i + 1}</span
>
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm {isTargetCurrent(target)
@@ -309,9 +309,7 @@
class="mt-0.5 text-xs text-fg-3"
/>
</div>
<span class="shrink-0 text-xs text-muted tabular-nums"
>{fmtTime(seconds)}</span
>
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
</div>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
@@ -369,7 +367,6 @@
title={targetTitle(target)}
artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
coverSize="h-10 w-10"
explicit={targetExplicit(target)}
active={isCurrent}
onClick={() => playPopular(i)}
@@ -414,17 +411,19 @@
<div
role="button"
tabindex="0"
class="group/card animate-enter flex items-center gap-3.5 rounded-2xl p-3 transition-colors hover:bg-hover"
style="animation-delay:{Math.min(i, 10) * 40}ms"
class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
style="--i:{i}"
onclick={() => goto(`/playlists/${playlist.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
>
<PlaylistArt
playlistId={playlist.id}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="medium"
class="h-15.5 w-15.5 shrink-0 rounded-control"
size="lg"
alt={playlist.name}
class="shrink-0"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm tracking-tight text-fg">