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).
140 lines
4.3 KiB
Svelte
140 lines
4.3 KiB
Svelte
<script lang="ts">
|
|
import Play from '@lucide/svelte/icons/play';
|
|
import Pause from '@lucide/svelte/icons/pause';
|
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
|
import { player } from '$lib/player/player.svelte';
|
|
import { fmtTime } from '$lib/format';
|
|
import { shuffle } from '$lib/collections';
|
|
import Page from '$lib/components/ui/Page.svelte';
|
|
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 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';
|
|
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
|
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
|
|
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
|
import TrackContextMenu, {
|
|
contextMenuStateFor,
|
|
type ContextMenuState
|
|
} from '$lib/components/TrackContextMenu.svelte';
|
|
import { mixItemKey, mixItemTarget } from '$lib/mixes';
|
|
import {
|
|
isTargetCurrent,
|
|
queueItemForTarget,
|
|
targetArtist,
|
|
targetId,
|
|
targetListensCount,
|
|
targetTitle
|
|
} from '$lib/tracks';
|
|
|
|
let { data, form } = $props();
|
|
|
|
const mix = $derived(data.mix);
|
|
const items = $derived(mix.items);
|
|
const targets = $derived(items.map(mixItemTarget));
|
|
const queue = $derived(targets.map(queueItemForTarget));
|
|
|
|
const totalSeconds = $derived(
|
|
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
|
);
|
|
const isCurrentQueue = $derived(targets.some(isTargetCurrent));
|
|
|
|
let contextMenu = $state<ContextMenuState | null>(null);
|
|
|
|
function playFrom(index: number) {
|
|
player.playOrToggle(queue, index);
|
|
}
|
|
|
|
function playAll() {
|
|
if (queue.length === 0) return;
|
|
if (isCurrentQueue) player.toggle();
|
|
else player.playQueue(queue, 0);
|
|
}
|
|
|
|
function playShuffled() {
|
|
if (queue.length === 0) return;
|
|
player.playQueue(shuffle(queue), 0);
|
|
}
|
|
</script>
|
|
|
|
<svelte:head>
|
|
<title>{mix.title}</title>
|
|
<meta name="description" content="Mezcla {mix.title}, hecha para ti." />
|
|
</svelte:head>
|
|
|
|
<Page>
|
|
<BackLink href="/" label="Volver al inicio" />
|
|
|
|
<CollectionHeader
|
|
eyebrow="Mezcla"
|
|
title={mix.title}
|
|
description={mix.subtitle ?? undefined}
|
|
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
|
|
>
|
|
{#snippet cover()}
|
|
<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}>
|
|
{#if isCurrentQueue && player.playing}
|
|
<Pause class="h-4 w-4" strokeWidth={1.5} />
|
|
Pausar
|
|
{:else}
|
|
<Play class="h-4 w-4" strokeWidth={1.5} />
|
|
Reproducir
|
|
{/if}
|
|
</Button>
|
|
<Button variant="secondary" size="sm" onclick={playShuffled} disabled={queue.length === 0}>
|
|
<Shuffle class="h-4 w-4" />
|
|
Aleatorio
|
|
</Button>
|
|
{/snippet}
|
|
</CollectionHeader>
|
|
|
|
{#if form?.message}
|
|
<Alert tone="danger" class="mt-4">{form.message}</Alert>
|
|
{/if}
|
|
|
|
<TrackTable index meta={[{ label: 'Escuchas', width: '100px' }]} class="mt-6 sm:mt-8">
|
|
{#each items as item, i (mixItemKey(item))}
|
|
{@const active = isTargetCurrent(targets[i])}
|
|
<TrackRow {active} oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
|
|
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
|
|
<TrackTitleCell
|
|
trackId={targetId(targets[i])}
|
|
title={targetTitle(targets[i])}
|
|
artist={targetArtist(targets[i])}
|
|
{active}
|
|
onClick={() => playFrom(i)}
|
|
/>
|
|
<TrackMeta class="hidden sm:block">{Number(targetListensCount(targets[i]) ?? 0)}</TrackMeta>
|
|
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
|
|
</TrackRow>
|
|
{/each}
|
|
</TrackTable>
|
|
</Page>
|
|
|
|
{#if contextMenu}
|
|
<TrackContextMenu
|
|
menu={contextMenu}
|
|
playlists={data.playlists}
|
|
onClose={() => (contextMenu = null)}
|
|
onAddToQueue={() => {
|
|
const menu = contextMenu;
|
|
if (menu) {
|
|
const index = targets.findIndex((target) => targetId(target) === targetId(menu));
|
|
if (index >= 0) player.addToQueue(queue[index]);
|
|
}
|
|
contextMenu = null;
|
|
}}
|
|
/>
|
|
{/if}
|