Se aborda junto con esta parte la tarea 5 diferida desde P5: se
elimina el envoltorio TrackTarget de tracks.ts y sus 8 accesores
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explorar, Inicio, Mezcla, menus.svelte.ts y
mixes.ts pasan a usar los objetos de pista directamente, con
toQueueItem/trackFromQueueItem (nuevo, en player.svelte.ts) como
únicos conversores. TypeScript reveló en el proceso que el campo
isExplicit no existe en el schema real de /tracks: el badge
"explicit" de Explorar ya era un no-op antes del refactor (siempre
undefined ?? false); se retira el binding en vez de fingir soporte
que nunca existió.
Explorar: GENRE_INFO/genreTiles se extraen a src/lib/genres.ts; la
lógica de búsqueda (showGroup/capped/matchPlaylists/searchCounts/
searchChips/findTopResult) se extrae a src/lib/search.ts como
funciones puras parametrizadas por sfilter/query en vez de cerrar
sobre el estado del componente. "Mejor resultado" unifica el botón/
enlace duplicado con <svelte:element>. Decisión tomada sin bloquear:
no se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/
Playlists) en un único {#each groups} — cada grupo tiene snippets,
menú contextual y props de ListRow genuinamente distintos, y una
configuración genérica habría añadido más indirección de la que
elimina.
Inicio: usa Page en vez de page-x a mano; las 3 estadísticas pasan a
{#each}; los fondos de hero/spotlight (style= con variables CSS) se
convierten en las utilities hero-surface/spotlight-surface, y
gap-11.5 se sustituye por el token --spacing-section (2.875rem) vía
gap-section.
Verificado con npm run check (0 errores) y npm run build. /explore
sin sesión redirige a /login porque +layout.server.ts exige
allowAnonymousListening desde el backend (inalcanzable sin Docker en
esta sesión) — comportamiento preexistente, no una regresión. Sin QA
visual autenticada; smoke test de /login sin errores de consola.
126 lines
3.3 KiB
Svelte
126 lines
3.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,
|
|
isQueueCurrent,
|
|
playAllOrToggle,
|
|
playShuffled,
|
|
toQueueItem
|
|
} from '$lib/player/player.svelte';
|
|
import { fmtTime, plural } from '$lib/format';
|
|
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 PageHeader from '$lib/components/ui/PageHeader.svelte';
|
|
import TrackList from '$lib/components/ui/TrackList.svelte';
|
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
|
import { createTrackMenu } from '$lib/menus.svelte';
|
|
import { mixItemTrack } from '$lib/mixes';
|
|
|
|
let { data, form } = $props();
|
|
|
|
const mix = $derived(data.mix);
|
|
const items = $derived(mix.items);
|
|
const tracks = $derived(items.map(mixItemTrack));
|
|
const queue = $derived(tracks.map(toQueueItem));
|
|
const listTracks = $derived(
|
|
items.map((item) => ({
|
|
id: item.trackId,
|
|
title: item.title,
|
|
artist: item.artist,
|
|
duration: item.durationSeconds,
|
|
listensCount: item.listensCount
|
|
}))
|
|
);
|
|
|
|
const totalSeconds = $derived(
|
|
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
|
);
|
|
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
|
|
|
const trackMenu = createTrackMenu();
|
|
|
|
function playFrom(index: number) {
|
|
player.playOrToggle(queue, index);
|
|
}
|
|
</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" />
|
|
|
|
<PageHeader
|
|
eyebrow="Mezcla"
|
|
title={mix.title}
|
|
description={mix.subtitle ?? undefined}
|
|
meta="{plural(items.length, '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={() => playAllOrToggle(queue)} 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(queue)}
|
|
disabled={queue.length === 0}
|
|
>
|
|
<Shuffle class="h-4 w-4" />
|
|
Aleatorio
|
|
</Button>
|
|
{/snippet}
|
|
</PageHeader>
|
|
|
|
{#if form?.message}
|
|
<Alert tone="danger" class="mb-6">{form.message}</Alert>
|
|
{/if}
|
|
|
|
<TrackList
|
|
tracks={listTracks}
|
|
columns={['plays']}
|
|
onPlay={playFrom}
|
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
|
/>
|
|
</Page>
|
|
|
|
{#if trackMenu.state}
|
|
<ContextMenu
|
|
x={trackMenu.state.x}
|
|
y={trackMenu.state.y}
|
|
openLeft={trackMenu.state.openLeft}
|
|
onClose={() => trackMenu.close()}
|
|
items={[
|
|
{ icon: ListPlus, label: 'Reproducir a continuación', onclick: () => trackMenu.playNext() }
|
|
]}
|
|
playlistAction={{
|
|
action: '?/addTrack',
|
|
fields: { trackId: String(trackMenu.state.track.id) },
|
|
label: 'Añadir a una playlist'
|
|
}}
|
|
playlists={data.playlists}
|
|
/>
|
|
{/if}
|