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 -1
View File
@@ -775,7 +775,7 @@ Tareas:
| P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) | | P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) |
| P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) | | P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) |
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` | | P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
| P3 | ⬜ | | | | P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
| P4 | ⬜ | | | | P4 | ⬜ | | |
| P5 | ⬜ | | | | P5 | ⬜ | | |
| P6 | ⬜ | | | | P6 | ⬜ | | |
@@ -20,7 +20,7 @@
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end'; import ListEnd from '@lucide/svelte/icons/list-end';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte'; import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
@@ -99,11 +99,10 @@
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small"
trackIds={[]} size="xs"
size="small" class="shrink-0"
class="h-9 w-9 shrink-0 rounded-control"
/> />
<span class="truncate text-fg-2">{playlist.name}</span> <span class="truncate text-fg-2">{playlist.name}</span>
</button> </button>
@@ -5,6 +5,7 @@
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from './ui/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import AccountMenu from './ui/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -40,15 +41,7 @@
aria-expanded={open} aria-expanded={open}
class="flex items-center rounded-full" class="flex items-center rounded-full"
> >
{#if user.picture} <Avatar name={user.name} src={user.picture} size="sm" />
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 text-sm uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
{#snippet extraItems({ close })} {#snippet extraItems({ close })}
+7 -6
View File
@@ -4,7 +4,7 @@
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte'; import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import PlaylistArt from './ui/PlaylistArt.svelte'; import Artwork from './ui/Artwork.svelte';
import Home from '@lucide/svelte/icons/house'; import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass'; import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
@@ -97,12 +97,13 @@
? 'bg-surface-hover' ? 'bg-surface-hover'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="xs"
size="small" class="shrink-0 opacity-90"
class="h-8.5 w-8.5 shrink-0 overflow-hidden rounded-lg opacity-90"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}"> <div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
+8 -10
View File
@@ -8,6 +8,7 @@
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import AccountMenu from './ui/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from './ui/Avatar.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -113,17 +114,14 @@
onclick={toggle} onclick={toggle}
aria-label="Tu cuenta" aria-label="Tu cuenta"
aria-expanded={open} aria-expanded={open}
class="block h-8.25 w-8.25 shrink-0 overflow-hidden rounded-full border border-line-strong transition active:scale-95" class="block shrink-0 rounded-full transition active:scale-95"
> >
{#if user.picture} <Avatar
<img src={user.picture} alt="" class="h-full w-full object-cover opacity-90" /> name={user.name}
{:else} src={user.picture}
<span size="sm"
class="grid h-full w-full place-items-center bg-surface-2 text-xs font-semibold text-fg uppercase" class="border border-line-strong opacity-90"
> />
{user.name.charAt(0)}
</span>
{/if}
</button> </button>
{/snippet} {/snippet}
</AccountMenu> </AccountMenu>
@@ -21,7 +21,7 @@
import { enhance } from '$app/forms'; import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import GlassMenu from '$lib/components/ui/GlassMenu.svelte'; import GlassMenu from '$lib/components/ui/GlassMenu.svelte';
interface Props { interface Props {
@@ -91,11 +91,10 @@
type="submit" type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover" class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=small"
trackIds={[]} size="xs"
size="small" class="shrink-0"
class="h-9 w-9 shrink-0 rounded-control"
/> />
<span class="truncate text-fg-2">{playlist.name}</span> <span class="truncate text-fg-2">{playlist.name}</span>
</button> </button>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import { fmtTime } from '$lib/format'; import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
@@ -35,11 +35,11 @@
{#if player.current.id} {#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p> <p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5"> <div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Cover <Artwork
trackId={player.current.id} trackIds={[player.current.id]}
size="small" size="sm"
alt={player.current.title} alt={player.current.title}
class="h-10 w-10 shrink-0 rounded-lg" class="shrink-0"
/> />
<div class="min-w-0"> <div class="min-w-0">
<div class="truncate text-sm text-accent-soft"> <div class="truncate text-sm text-accent-soft">
@@ -55,9 +55,7 @@
{/if} {/if}
<div class="mb-2.75 flex items-center justify-between gap-3"> <div class="mb-2.75 flex items-center justify-between gap-3">
<span <span class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase">
class="min-w-0 flex-1 truncate text-xs font-medium tracking-widest text-fg-3 uppercase"
>
A continuación A continuación
</span> </span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
@@ -85,12 +83,7 @@
}} }}
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover" class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
> >
<Cover <Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
trackId={track.id}
size="small"
alt={track.title}
class="h-10 w-10 shrink-0 rounded-lg opacity-80"
/>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-xs text-fg">{track.title}</div> <div class="truncate text-xs text-fg">{track.title}</div>
<ArtistLink <ArtistLink
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
@@ -29,14 +29,14 @@
<div class="flex min-w-0 items-center gap-2.5 md:gap-3.25"> <div class="flex min-w-0 items-center gap-2.5 md:gap-3.25">
{#if player.current.id} {#if player.current.id}
<Cover <Artwork
trackId={player.current.id} trackIds={[player.current.id]}
size="small" size="sm"
alt={player.current.title} alt={player.current.title}
class="h-11 w-11 shrink-0 rounded-xl md:h-12 md:w-12" class="shrink-0 md:size-cover-md"
/> />
{:else} {:else}
<div class="h-11 w-11 shrink-0 rounded-xl bg-surface md:h-12 md:w-12"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import MediaCard from './MediaCard.svelte'; import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import Artwork from './Artwork.svelte';
import Cover from './Cover.svelte';
interface Props { interface Props {
id: string; id: string;
@@ -53,10 +52,6 @@
oncontextmenu={onContextMenu} oncontextmenu={onContextMenu}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
{#if coverSrc} <Artwork src={coverSrc} {trackIds} size="fill" alt={title} class={artClass} />
<Cover trackId={id} src={coverSrc} size="large" alt={title} class={artClass} />
{:else}
<PlaylistArt {trackIds} class={artClass} />
{/if}
{/snippet} {/snippet}
</MediaCard> </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"> <script lang="ts">
import Play from '@lucide/svelte/icons/play'; 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 { player } from '$lib/player/player.svelte';
import { queueItemForTarget } from '$lib/tracks'; import { queueItemForTarget } from '$lib/tracks';
import { mixItemTarget, type Mix } from '$lib/mixes'; import { mixItemTarget, type Mix } from '$lib/mixes';
@@ -13,6 +13,8 @@
let { mix, index = 0, class: klass = '' }: Props = $props(); let { mix, index = 0, class: klass = '' }: Props = $props();
const trackIds = $derived(mix.items.map((item) => item.trackId));
function play(event: MouseEvent) { function play(event: MouseEvent) {
event.preventDefault(); event.preventDefault();
const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item))); const items = mix.items.map((item) => queueItemForTarget(mixItemTarget(item)));
@@ -23,15 +25,16 @@
<a <a
href="/mixes/{mix.id}" href="/mixes/{mix.id}"
class="group/tile animate-enter block cursor-pointer {klass}" 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"> <Artwork
<MixArt {trackIds}
items={mix.items} size="fill"
class="h-full w-full scale-100 transition duration-300 group-hover/tile:scale-105" alt={mix.title}
/> class="rounded-art transition duration-300 group-hover/tile:scale-105"
>
<div <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> ></div>
<button <button
type="button" type="button"
@@ -41,8 +44,8 @@
> >
<Play class="h-3.5 w-3.5" fill="currentColor" /> <Play class="h-3.5 w-3.5" fill="currentColor" />
</button> </button>
</div> </Artwork>
<div class="mt-3.25 truncate text-sm font-semibold tracking-[-0.01em] text-fg"> <div class="mt-3.25 truncate text-sm font-semibold text-fg">
{mix.title} {mix.title}
</div> </div>
{#if mix.subtitle} {#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"> <script lang="ts">
import MediaCard from './MediaCard.svelte'; import MediaCard from './MediaCard.svelte';
import PlaylistArt from './PlaylistArt.svelte'; import Artwork from './Artwork.svelte';
interface Props { interface Props {
id: string; id: string;
@@ -18,6 +18,12 @@
<MediaCard href="/playlists/{id}" title={name} {subtitle} {index}> <MediaCard href="/playlists/{id}" title={name} {subtitle} {index}>
{#snippet art(artClass)} {#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} {/snippet}
</MediaCard> </MediaCard>
@@ -1,20 +1,8 @@
<script lang="ts" module>
export type SearchResultKind = 'track' | 'album' | 'playlist' | 'user';
</script>
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.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 { interface Props {
kind: SearchResultKind;
title: string; title: string;
subtitle?: string | null; subtitle?: string | null;
meta?: string; meta?: string;
@@ -26,7 +14,6 @@
} }
let { let {
kind,
title, title,
subtitle, subtitle,
meta, meta,
@@ -37,7 +24,7 @@
art art
}: Props = $props(); }: Props = $props();
const artClass = $derived(`relative h-13 w-13 shrink-0 overflow-hidden ${RADIUS[kind]}`); const artClass = 'relative shrink-0';
</script> </script>
{#snippet content()} {#snippet content()}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Cover from './Cover.svelte'; import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte'; import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte'; import ArtistLink from './ArtistLink.svelte';
@@ -10,7 +10,7 @@
artist?: string | null; artist?: string | null;
ownerUserId?: string | number | null; ownerUserId?: string | number | null;
coverSrc?: string | null; coverSrc?: string | null;
coverSize?: string; size?: 'sm' | 'lg';
explicit?: boolean; explicit?: boolean;
active?: boolean; active?: boolean;
titleClass?: string; titleClass?: string;
@@ -25,7 +25,7 @@
artist, artist,
ownerUserId, ownerUserId,
coverSrc, coverSrc,
coverSize = 'h-10.5 w-10.5', size = 'sm',
explicit = false, explicit = false,
active = false, active = false,
titleClass = '', titleClass = '',
@@ -53,15 +53,9 @@
onkeydown={onKeydown} onkeydown={onKeydown}
class="flex min-w-0 items-center gap-3.5 text-left" class="flex min-w-0 items-center gap-3.5 text-left"
> >
<Cover <Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
{trackId}
src={coverSrc}
size="small"
alt={title}
class="{coverSize} shrink-0 rounded-control"
>
{@render overlay?.()} {@render overlay?.()}
</Cover> </Artwork>
<div class="min-w-0"> <div class="min-w-0">
<div class="flex min-w-0 items-center gap-2"> <div class="flex min-w-0 items-center gap-2">
{#if explicit} {#if explicit}
@@ -3,7 +3,7 @@
import Pause from '@lucide/svelte/icons/pause'; import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen'; import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash'; 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 BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -51,12 +51,7 @@
<CollectionHeader eyebrow="Álbum" {title} {description} {meta}> <CollectionHeader eyebrow="Álbum" {title} {description} {meta}>
{#snippet cover()} {#snippet cover()}
<PlaylistArt <Artwork src={coverUrl} {trackIds} size="hero" alt={title} class="shrink-0" />
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41"
/>
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}> <Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
@@ -9,8 +9,8 @@
import Alert from '$lib/components/ui/Alert.svelte'; import Alert from '$lib/components/ui/Alert.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte'; import Avatar from '$lib/components/ui/Avatar.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte'; import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte'; import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
@@ -296,40 +296,25 @@
{#if topResult} {#if topResult}
{#snippet topResultBody()} {#snippet topResultBody()}
{#if topResult.kind === 'track'} {#if topResult.kind === 'track'}
<Cover <Artwork
trackId={targetId(topResult.target)} trackIds={[targetId(topResult.target)]}
size="large" size="xl"
alt={targetTitle(topResult.target)} alt={targetTitle(topResult.target)}
class="h-22 w-22 shrink-0 rounded-2xl" class="shrink-0"
/> />
{:else if topResult.kind === 'album'} {:else if topResult.kind === 'album'}
<PlaylistArt <Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
trackIds={topResult.album.coverTrackIds}
class="h-22 w-22 shrink-0 rounded-2xl"
/>
{:else if topResult.kind === 'playlist'} {:else if topResult.kind === 'playlist'}
<PlaylistArt <Artwork
playlistId={topResult.playlist.id} src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
topResult.playlist.updatedAt
)}"
trackIds={topResult.playlist.coverTrackIds} trackIds={topResult.playlist.coverTrackIds}
version={topResult.playlist.updatedAt} size="xl"
class="h-22 w-22 shrink-0 rounded-2xl" class="shrink-0"
/> />
{:else} {:else}
<div <Avatar name={topResult.user.name} src={topResult.user.profilePictureUrl} size="lg" />
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>
{/if} {/if}
<div class="flex h-22 min-w-0 flex-1 flex-col justify-center gap-3"> <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))} {#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li> <li>
<SearchResultRow <SearchResultRow
kind="track"
title={targetTitle(item)} title={targetTitle(item)}
subtitle={targetArtist(item)} subtitle={targetArtist(item)}
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}" meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
@@ -452,16 +436,16 @@
oncontextmenu={(e) => openContextMenu(e, item)} oncontextmenu={(e) => openContextMenu(e, item)}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<Cover <Artwork
trackId={targetId(item)} trackIds={[targetId(item)]}
size="small" size="lg"
alt={targetTitle(item)} alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset" class="{artClass} ring-1 ring-line ring-inset"
> >
{#if isTargetCurrent(item)} {#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} /> <NowPlaying paused={!player.playing} />
{/if} {/if}
</Cover> </Artwork>
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -495,14 +479,13 @@
{#each capped(albums) as album (album.id)} {#each capped(albums) as album (album.id)}
<li> <li>
<SearchResultRow <SearchResultRow
kind="album"
title={album.title} title={album.title}
meta="{Number(album.trackCount)} canciones" meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}" href="/albums/{album.id}"
oncontextmenu={(e) => openAlbumMenu(e, album.id)} oncontextmenu={(e) => openAlbumMenu(e, album.id)}
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<PlaylistArt trackIds={album.coverTrackIds} class={artClass} /> <Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -525,17 +508,9 @@
<ul class="flex flex-col gap-1"> <ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)} {#each capped(users) as u (u.id)}
<li> <li>
<SearchResultRow kind="user" title={u.name} href="/u/{u.id}"> <SearchResultRow title={u.name} href="/u/{u.id}">
{#snippet art(artClass)} {#snippet art(artClass)}
<div class="{artClass} grid place-items-center bg-surface-2"> <Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
{#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>
{/snippet} {/snippet}
</SearchResultRow> </SearchResultRow>
</li> </li>
@@ -559,16 +534,17 @@
{#each capped(playlistMatches) as playlist (playlist.id)} {#each capped(playlistMatches) as playlist (playlist.id)}
<li> <li>
<SearchResultRow <SearchResultRow
kind="playlist"
title={playlist.name} title={playlist.name}
subtitle={playlist.description} subtitle={playlist.description}
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
> >
{#snippet art(artClass)} {#snippet art(artClass)}
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="lg"
class={artClass} class={artClass}
/> />
{/snippet} {/snippet}
@@ -9,7 +9,7 @@
import BackLink from '$lib/components/ui/BackLink.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.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 CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import TrackTable from '$lib/components/ui/TrackTable.svelte'; import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte'; import TrackRow from '$lib/components/ui/TrackRow.svelte';
@@ -75,7 +75,12 @@
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}" meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
> >
{#snippet cover()} {#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}
{#snippet actions()} {#snippet actions()}
<Button size="sm" onclick={playAll} disabled={queue.length === 0}> <Button size="sm" onclick={playAll} disabled={queue.length === 0}>
@@ -1,5 +1,5 @@
<script lang="ts"> <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 Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
@@ -44,12 +44,12 @@
align="center" align="center"
> >
{#snippet cover()} {#snippet cover()}
<PlaylistArt <Artwork
playlistId={id} src="/api/playlists/{id}/cover?size=large&v={encodeURIComponent(updatedAt)}"
{trackIds} {trackIds}
size="large" size="hero"
version={updatedAt} alt={name}
class="h-36 w-36 shrink-0 rounded-art-lg sm:h-41 sm:w-41" class="shrink-0"
/> />
{/snippet} {/snippet}
{#snippet actions()} {#snippet actions()}
@@ -7,7 +7,7 @@
import PlaylistCard from '$lib/components/ui/PlaylistCard.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 ArtistAvatar from '$lib/components/ui/ArtistAvatar.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';
@@ -65,7 +65,7 @@
actions={canFollow ? followAction : undefined} actions={canFollow ? followAction : undefined}
> >
{#snippet cover()} {#snippet cover()}
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} /> <Avatar name={profile.name} src={profile.profilePictureUrl} size="hero" />
{/snippet} {/snippet}
</CollectionHeader> </CollectionHeader>
+20 -21
View File
@@ -20,7 +20,7 @@
type TrackTarget type TrackTarget
} from '$lib/tracks'; } from '$lib/tracks';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; 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 MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte'; import EqBars from '$lib/components/ui/EqBars.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte'; import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
@@ -215,7 +215,7 @@
title={item.title} title={item.title}
artist={item.artist} artist={item.artist}
ownerUserId={item.ownerUserId} ownerUserId={item.ownerUserId}
coverSize="h-14 w-14" size="lg"
active={isCurrent} active={isCurrent}
onClick={() => playContinue(i)} onClick={() => playContinue(i)}
/> />
@@ -250,12 +250,14 @@
onclick={() => goto(`/playlists/${spotlight.id}`)} onclick={() => goto(`/playlists/${spotlight.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))} onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))}
> >
<PlaylistArt <Artwork
playlistId={spotlight.id} src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
spotlight.updatedAt
)}"
trackIds={spotlight.coverTrackIds} trackIds={spotlight.coverTrackIds}
version={spotlight.updatedAt} size="2xl"
size="large" alt={spotlight.name}
class="h-34 w-34 shrink-0 rounded-2xl" class="shrink-0"
/> />
<div class="flex min-w-0 flex-col gap-3"> <div class="flex min-w-0 flex-col gap-3">
<p class="text-xs font-medium tracking-widest text-fg-2 uppercase"> <p class="text-xs font-medium tracking-widest text-fg-2 uppercase">
@@ -292,9 +294,7 @@
? 'bg-accent-tint' ? 'bg-accent-tint'
: 'hover:bg-hover'}" : 'hover:bg-hover'}"
> >
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums" <span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
>{i + 1}</span
>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div <div
class="truncate text-sm {isTargetCurrent(target) class="truncate text-sm {isTargetCurrent(target)
@@ -309,9 +309,7 @@
class="mt-0.5 text-xs text-fg-3" class="mt-0.5 text-xs text-fg-3"
/> />
</div> </div>
<span class="shrink-0 text-xs text-muted tabular-nums" <span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
>{fmtTime(seconds)}</span
>
</div> </div>
{:else} {:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p> <p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
@@ -369,7 +367,6 @@
title={targetTitle(target)} title={targetTitle(target)}
artist={targetArtist(target)} artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)} ownerUserId={targetOwnerUserId(target)}
coverSize="h-10 w-10"
explicit={targetExplicit(target)} explicit={targetExplicit(target)}
active={isCurrent} active={isCurrent}
onClick={() => playPopular(i)} onClick={() => playPopular(i)}
@@ -414,17 +411,19 @@
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
class="group/card animate-enter flex items-center gap-3.5 rounded-2xl p-3 transition-colors hover:bg-hover" class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
style="animation-delay:{Math.min(i, 10) * 40}ms" style="--i:{i}"
onclick={() => goto(`/playlists/${playlist.id}`)} onclick={() => goto(`/playlists/${playlist.id}`)}
onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))} onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))}
> >
<PlaylistArt <Artwork
playlistId={playlist.id} src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds} trackIds={playlist.coverTrackIds}
version={playlist.updatedAt} size="lg"
size="medium" alt={playlist.name}
class="h-15.5 w-15.5 shrink-0 rounded-control" class="shrink-0"
/> />
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<div class="truncate text-sm tracking-tight text-fg"> <div class="truncate text-sm tracking-tight text-fg">