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).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 22:39:05 +02:00
co-authored by Claude Sonnet 5
parent d447495933
commit 1186903d22
25 changed files with 326 additions and 359 deletions
@@ -1,7 +1,6 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte';
import Cover from './Cover.svelte';
import Artwork from './Artwork.svelte';
interface Props {
id: string;
@@ -53,10 +52,6 @@
oncontextmenu={onContextMenu}
>
{#snippet art(artClass)}
{#if coverSrc}
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
{:else}
<PlaylistArt {trackIds} class={artClass} />
{/if}
<Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
{/snippet}
</MediaCard>
@@ -1,36 +0,0 @@
<script lang="ts">
interface Props {
name: string;
imageUrl?: string | null;
size?: number;
}
let { name, imageUrl, size = 108 }: Props = $props();
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
</script>
<div class="flex flex-col items-center gap-2.5 text-center" style="width:{size}px">
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface ring-1 ring-line transition duration-300 ease-out ring-inset"
style="width:{size}px;height:{size}px"
>
{#if imageUrl}
<img src={imageUrl} alt="" class="absolute inset-0 h-full w-full object-cover" />
{:else}
<div
class="pointer-events-none absolute inset-0"
style="background:repeating-linear-gradient(135deg, color-mix(in oklch, var(--mf-text) 12%, transparent) 0 2px, transparent 2px 10px)"
></div>
<span class="relative text-lg font-semibold text-fg">{initials}</span>
{/if}
</div>
<div class="w-full truncate text-sm text-fg">{name}</div>
</div>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props {
src?: string | null;
trackIds?: (string | number)[];
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string;
class?: string;
children?: Snippet;
}
let {
src,
trackIds = [],
size,
shape = 'auto',
imageSize,
fallback = 'music',
alt = '',
class: klass = '',
children
}: Props = $props();
const DIMENSION: Record<Size, string> = {
xs: 'size-cover-xs',
sm: 'size-cover-sm',
md: 'size-cover-md',
lg: 'size-cover-lg',
xl: 'size-cover-xl',
'2xl': 'size-cover-2xl',
hero: 'size-cover-hero sm:size-cover-hero-sm',
fill: 'aspect-square w-full'
};
const RADIUS: Record<Size, string> = {
xs: 'rounded-thumb',
sm: 'rounded-art',
md: 'rounded-art',
lg: 'rounded-art',
xl: 'rounded-art',
'2xl': 'rounded-art-lg',
hero: 'rounded-art-lg',
fill: ''
};
const AUTO_IMAGE_SIZE: Record<Size, 'small' | 'medium' | 'large'> = {
xs: 'small',
sm: 'small',
md: 'medium',
lg: 'medium',
xl: 'medium',
'2xl': 'large',
hero: 'large',
fill: 'large'
};
const resolvedImageSize = $derived(imageSize ?? AUTO_IMAGE_SIZE[size]);
const radius = $derived(shape === 'round' ? 'rounded-full' : RADIUS[size]);
let srcFailed = $state(false);
$effect(() => {
src;
srcFailed = false;
});
let singleFailed = $state(false);
$effect(() => {
trackIds[0];
singleFailed = false;
});
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
{#if showSrc}
<img
{src}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (srcFailed = true)}
class="h-full w-full object-cover"
/>
{:else if showMosaic}
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)}
<img
src="/api/tracks/{id}/cover?size=small"
alt=""
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={(e) => ((e.currentTarget as HTMLImageElement).style.visibility = 'hidden')}
class="h-full w-full object-cover"
/>
{/each}
</div>
{:else if showSingle}
<img
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (singleFailed = true)}
class="h-full w-full object-cover"
/>
{:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,60 @@
<script lang="ts">
type Size = 'sm' | 'md' | 'lg' | 'hero';
interface Props {
name: string;
src?: string | null;
size: Size;
class?: string;
}
let { name, src, size, class: klass = '' }: Props = $props();
const DIMENSION: Record<Size, string> = {
sm: 'size-cover-xs',
md: 'size-cover-lg',
lg: 'size-cover-xl',
hero: 'size-cover-hero'
};
const TEXT: Record<Size, string> = {
sm: 'text-sm',
md: 'text-base',
lg: 'text-xl',
hero: 'text-3xl'
};
const initials = $derived(
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? '')
.join('')
);
let failed = $state(false);
$effect(() => {
src;
failed = false;
});
</script>
<div
class="relative grid shrink-0 place-items-center overflow-hidden rounded-full bg-surface-2 {DIMENSION[
size
]} {klass}"
>
{#if src && !failed}
<img
{src}
alt=""
loading="lazy"
decoding="async"
onerror={() => (failed = true)}
class="absolute inset-0 h-full w-full object-cover"
/>
{:else}
<span class="font-semibold text-fg uppercase {TEXT[size]}">{initials}</span>
{/if}
</div>
@@ -1,47 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
trackId: string | number;
size?: 'small' | 'medium' | 'large';
src?: string | null;
alt?: string;
class?: string;
children?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
}
let {
trackId,
size = 'medium',
src,
alt = '',
class: klass = '',
children,
oncontextmenu
}: Props = $props();
let failed = $state(false);
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
$effect(() => {
imageSrc;
failed = false;
});
</script>
<div class="relative overflow-hidden bg-surface {klass}" role="presentation" {oncontextmenu}>
{#if !failed}
<img
src={imageSrc}
{alt}
loading="lazy"
decoding="async"
fetchpriority="low"
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -1,25 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { mixItemKey, type MixItem } from '$lib/mixes';
interface Props {
items: MixItem[];
class?: string;
}
let { items, class: klass = '' }: Props = $props();
const tiles = $derived(items.slice(0, 4));
</script>
{#if tiles.length >= 4}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each tiles as item (mixItemKey(item))}
<Cover trackId={item.trackId} size="small" class="h-full w-full" />
{/each}
</div>
{:else if tiles.length > 0}
<Cover trackId={tiles[0].trackId} size="medium" class={klass} />
{:else}
<div class="bg-surface {klass}"></div>
{/if}
+13 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import MixArt from './MixArt.svelte';
import Artwork from './Artwork.svelte';
import { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes';
@@ -13,6 +13,8 @@
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)));
@@ -23,15 +25,16 @@
<a
href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}"
style="animation-delay:{Math.min(index, 10) * 45}ms"
style="--i:{index}"
>
<div class="relative aspect-square overflow-hidden rounded-art">
<MixArt
items={mix.items}
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105"
/>
<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-black/42"
class="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent from-45% to-scrim/40"
></div>
<button
type="button"
@@ -41,8 +44,8 @@
>
<Play class="h-3.5 w-3.5" fill="currentColor" />
</button>
</div>
<div class="mt-3.25 truncate text-sm font-semibold tracking-[-0.01em] text-fg">
</Artwork>
<div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title}
</div>
{#if mix.subtitle}
@@ -1,61 +0,0 @@
<script lang="ts">
import Cover from './Cover.svelte';
interface Props {
playlistId?: string;
coverUrl?: string;
trackIds: (string | number)[];
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let {
playlistId,
coverUrl,
trackIds,
size = 'medium',
version,
class: klass = ''
}: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
const src = $derived(
coverUrl
? coverUrl
: playlistId
? `/api/playlists/${playlistId}/cover?size=${size}${version ? `&v=${encodeURIComponent(version)}` : ''}`
: undefined
);
</script>
{#if src && !coverFailed}
<div class="relative overflow-hidden bg-surface {klass}">
<img
{src}
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} size="medium" class={klass} />
{:else}
<div class="flex items-center justify-center bg-surface {klass}">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/>
</svg>
</div>
{/if}
@@ -1,6 +1,6 @@
<script lang="ts">
import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte';
import Artwork from './Artwork.svelte';
interface Props {
id: string;
@@ -18,6 +18,12 @@
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)}
<PlaylistArt playlistId={id} {trackIds} version={updatedAt} class={artClass} />
<Artwork
src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds}
size="fill"
alt={name}
class={artClass}
/>
{/snippet}
</MediaCard>
@@ -1,20 +1,8 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
const RADIUS: Record<SearchResultKind, string> = {
track: 'rounded-control',
album: 'rounded-md',
playlist: 'rounded-art',
user: 'rounded-full'
};
interface Props {
kind: SearchResultKind;
title: string;
subtitle?: string | null;
meta?: string;
@@ -26,7 +14,6 @@
}
let {
kind,
title,
subtitle,
meta,
@@ -37,7 +24,7 @@
art
}: Props = $props();
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`);
const artClass = 'relative shrink-0';
</script>
{#snippet content()}
@@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte';
@@ -10,7 +10,7 @@
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
coverSize?: string;
size?: 'sm' | 'lg';
explicit?: boolean;
active?: boolean;
titleClass?: string;
@@ -25,7 +25,7 @@
artist,
ownerUserId,
coverSrc,
coverSize = 'h-10.5 w-10.5',
size = 'sm',
explicit = false,
active = false,
titleClass = '',
@@ -53,15 +53,9 @@
onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
{trackId}
src={coverSrc}
size="small"
alt={title}
class="{coverSize} shrink-0 rounded-control"
>
<Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
{@render overlay?.()}
</Cover>
</Artwork>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}