Add likes, follows and playlist visibility; redesign web-player UI

Backend:
- New Likes feature (toggle/get liked tracks) and endpoints
- User follow/unfollow, listening stats, public user profile
- Playlist visibility (public/private) support
- Various auth and DTO/response adjustments to support the above

Frontend (web-player):
- New design system pass: theme tokens/mode (light/dark), glass menu,
  tabs bar, player dock/extras/queue components, mix tiles, artist link
- New routes: Liked, public user profile (/u/[id]), likes API proxy
- Search refactor: shared SearchBox component used by TopBar and
  MobileHeader (search now reachable on mobile), live results as you
  type instead of requiring Enter, dropped the "Buscando…" quick-result
  preview
- Removed now-unused components (MobileNav, CoverBadge, MixBentoTile,
  PlayPauseOverlay, SuggestionsTable, userDirectory)
- Design handoff reference docs added under web-player/design_handoff_musify_player
  and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
2026-09-14 23:42:45 +02:00
parent 389b3b9244
commit dc1ba89a6d
166 changed files with 14271 additions and 1982 deletions
+366 -506
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import History from '@lucide/svelte/icons/history';
import Shuffle from '@lucide/svelte/icons/shuffle';
import ListMusic from '@lucide/svelte/icons/list-music';
import { player } from '$lib/player/player.svelte';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { shuffle as shuffleList } from '$lib/collections';
import { fmtTime, fmtDurationLong } from '$lib/format';
import {
isTargetCurrent,
queueItemForTarget,
@@ -15,64 +15,33 @@
targetExplicit,
targetForQueueItem,
targetId,
targetOwnerUserId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { fmtTime } from '$lib/format';
import Cover from '$lib/components/ui/Cover.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import MixBentoTile from '$lib/components/ui/MixBentoTile.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import AlbumContextMenu, {
albumContextMenuStateFor,
type AlbumMenuState
} from '$lib/components/AlbumContextMenu.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import type { YouTubeAlbumResult, YouTubeSong } from '$lib/types';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
import { HOME_LATEST_PAGE_SIZE, HOME_POPULAR_MAX } from '$lib/config';
import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config';
let { data } = $props();
let contextMenu = $state<ContextMenuState | null>(null);
let albumMenu = $state<AlbumMenuState | null>(null);
let topMusicFillerItems = $state<YouTubeSong[]>([]);
let youtubeFillerItems = $state<YouTubeSong[]>([]);
let popularFillerItems = $state<YouTubeSong[]>([]);
let albumFillerItems = $state<YouTubeAlbumResult[]>([]);
let topMusicRail: ReturnType<typeof Rail> | undefined = $state();
let recommendedRail: ReturnType<typeof Rail> | undefined = $state();
$effect(() => {
topMusicFillerItems = [];
let cancelled = false;
data.topMusicFiller.then((filler) => {
if (cancelled) return;
topMusicFillerItems = appendUnique([], filler.items, (song) => song.videoId);
});
return () => {
cancelled = true;
};
});
$effect(() => {
youtubeFillerItems = [];
let cancelled = false;
data.youtubeFiller.then((filler) => {
if (cancelled) return;
youtubeFillerItems = appendUnique([], filler.items, (song) => song.videoId);
});
return () => {
cancelled = true;
};
});
let spotlightItems = $state<TrackTarget[]>([]);
let spotlightTotal = $state(0);
$effect(() => {
popularFillerItems = [];
@@ -87,96 +56,76 @@
});
$effect(() => {
albumFillerItems = [];
spotlightItems = [];
spotlightTotal = 0;
let cancelled = false;
data.albumFiller.then((filler) => {
data.spotlightTracks.then(({ items, totalCount }) => {
if (cancelled) return;
albumFillerItems = appendUnique([], filler.items, (album) => album.albumId);
spotlightItems = items.map((track) => ({ kind: 'local' as const, track }));
spotlightTotal = totalCount;
});
return () => {
cancelled = true;
};
});
const albums = $derived(data.albums);
const mixes = $derived(data.mixes);
const playlists = $derived(data.playlists);
const newTargets = $derived<TrackTarget[]>(
data.newReleases.map((track) => ({ kind: 'local' as const, track }))
const spotlight = $derived(data.spotlightPlaylist);
const spotlightDurationSeconds = $derived(
spotlightItems.some((item) => targetDurationSeconds(item) !== undefined)
? spotlightItems.reduce((sum, item) => sum + (targetDurationSeconds(item) ?? 0), 0)
: undefined
);
const topMusicTargets = $derived<TrackTarget[]>([
...newTargets,
...topMusicFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const recTargets = $derived<TrackTarget[]>(
youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length);
const spotlightVisibilityLabel = $derived(
spotlight?.visibility === 'Public' ? 'Pública' : 'Privada'
);
const spotlightMeta = $derived(
spotlightTrackCount === 0
? spotlightVisibilityLabel
: [
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'canción' : 'canciones'}`,
spotlightDurationSeconds !== undefined
? fmtDurationLong(spotlightDurationSeconds)
: '0 min',
spotlightVisibilityLabel
].join(' · ')
);
type RecommendedAlbum =
| { kind: 'recent'; album: (typeof albums)[number] }
| { kind: 'discover'; album: YouTubeAlbumResult };
const recentlyPlayed = $derived.by(() => {
const seen = new Set<string>();
const merged: QueueItem[] = [];
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
});
}
for (const item of toQueueItems(data.recentlyPlayed)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl,
explicit: item.explicit
});
}
return merged.slice(0, HOME_LATEST_PAGE_SIZE);
});
const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTargets = $derived<TrackTarget[]>(
[
...recentlyPlayed.map(targetForQueueItem),
...recentPool.map(targetForQueueItem),
...popularFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
].slice(0, HOME_POPULAR_MAX)
);
const recommendedAlbums = $derived<RecommendedAlbum[]>([
...albums.map((album) => ({ kind: 'recent' as const, album })),
...albumFillerItems.map((album) => ({ kind: 'discover' as const, album }))
]);
const heroTrack = $derived(recentlyPlayed[0] ?? null);
const heroPlaying = $derived(
heroTrack !== null && player.current.id === heroTrack.id && player.playing
const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget));
const spotlightIsCurrent = $derived(
spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id)
);
const heroPlaying = $derived(spotlightIsCurrent && player.playing);
function playRecent(index: number) {
player.playOrToggle(recentlyPlayed, index);
}
function playTopMusic(index: number) {
player.playOrToggle(topMusicTargets.map(queueItemForTarget), index);
function playContinue(index: number) {
player.playOrToggle(continueItems, index);
}
function playPopular(index: number) {
player.playOrToggle(popularTargets.map(queueItemForTarget), index);
}
function playRecommended(index: number) {
player.playOrToggle(recTargets.map(queueItemForTarget), index);
function playSpotlightTrack(index: number) {
player.playOrToggle(spotlightQueue, index);
}
function playSpotlight() {
if (spotlightQueue.length === 0) return;
if (spotlightIsCurrent) player.toggle();
else player.playQueue(spotlightQueue, 0);
}
function shuffleSpotlight() {
if (spotlightQueue.length === 0) return;
player.playQueue(shuffleList(spotlightQueue), 0);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
@@ -187,39 +136,6 @@
player.addToQueue(queueItemForTarget(contextMenu));
contextMenu = null;
}
function openAlbumMenu(event: MouseEvent, kind: 'local' | 'youtube', albumId: string) {
albumMenu = albumContextMenuStateFor(event, kind, albumId);
}
async function albumPlayNext() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(kind, albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { kind, albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(kind, albumId));
}
function albumCaption(album: (typeof albums)[number]) {
return [
album.releaseYear ? String(album.releaseYear) : undefined,
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
]
.filter(Boolean)
.join(' · ');
}
function youtubeAlbumCaption(album: YouTubeAlbumResult) {
return [album.artist, album.releaseYear ? String(album.releaseYear) : undefined]
.filter(Boolean)
.join(' · ');
}
</script>
<svelte:head>
@@ -227,399 +143,353 @@
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
{#snippet railArrows(rail: ReturnType<typeof Rail> | undefined)}
<div class="flex shrink-0 gap-2">
<button
type="button"
onclick={() => rail?.scrollByPage(-1)}
aria-label="Anterior"
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
>
<ChevronLeft class="h-4 w-4" />
</button>
<button
type="button"
onclick={() => rail?.scrollByPage(1)}
aria-label="Siguiente"
class="grid h-8 w-8 place-items-center rounded-full border border-line text-fg-2 transition hover:border-accent-soft/50 hover:text-accent-soft"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
{/snippet}
<section class="relative overflow-hidden page-x pt-8 pb-8 sm:pt-10 sm:pb-10">
<div class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<section class="page-x pt-3 sm:pt-4">
<div
class="relative overflow-hidden rounded-[24px] bg-[image:var(--mf-hero-bg)] px-6 py-8 transition-[background] duration-[600ms] sm:px-9 sm:py-8.5"
>
<div
class="absolute inset-x-0 top-[-19rem] h-[34rem] sm:h-[42rem]"
style="background:radial-gradient(58% 100% at 50% 0%, color-mix(in oklch, var(--color-accent), transparent 5%), color-mix(in oklch, var(--color-accent), transparent 48%) 42%, transparent 78%); animation:breathe 14s ease-in-out infinite"
></div>
<div class="absolute inset-x-0 bottom-0 h-16 bg-linear-to-b from-transparent to-bg"></div>
</div>
<div class="relative flex flex-col gap-10 lg:flex-row lg:items-end lg:justify-between lg:gap-8">
<div class="animate-enter flex max-w-2xl flex-col gap-4">
<p class="text-xs font-semibold tracking-[0.28em] text-fg-2 uppercase sm:text-sm">
{data.greeting}
</p>
<h1
class="font-display text-[clamp(3rem,7.5vw,5.5rem)] leading-[0.98] font-medium tracking-tight text-fg"
>
Tu música,<br /><span class="text-accent-soft">sin límites.</span>
</h1>
<p class="max-w-md text-base text-fg-2 sm:text-lg">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
</div>
{#if heroTrack}
<button
type="button"
onclick={() => playRecent(0)}
aria-label="{heroPlaying ? 'Pausar' : 'Reproducir'} {heroTrack.title}"
class="group/hero animate-enter relative flex w-full max-w-sm shrink-0 items-center gap-4 overflow-hidden rounded-panel border border-line-strong bg-surface/70 p-4 text-left backdrop-blur-xl transition duration-300 hover:border-accent-soft/50 hover:bg-surface-hover hover:shadow-glow sm:p-5"
style="animation-delay:140ms"
>
<Cover
trackId={heroTrack.id}
src={heroTrack.coverUrl}
size="medium"
alt={heroTrack.title}
class="h-16 w-16 shrink-0 rounded-art shadow-art sm:h-20 sm:w-20"
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
>
<div class="animate-enter max-w-135 min-w-65">
<h1
class="font-display text-[28px] leading-[1.1] font-semibold tracking-[-0.035em] text-pretty text-fg sm:text-[36px]"
>
{#if player.current.id === heroTrack.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<p class="text-[0.65rem] font-semibold tracking-[0.2em] text-fg-2 uppercase">
Seguir escuchando
</p>
<p class="mt-1 truncate font-display text-lg text-fg">{heroTrack.title}</p>
{#if heroTrack.artist}
<p class="truncate text-sm text-fg-2">{heroTrack.artist}</p>
{data.greeting}
</h1>
<p class="mt-3.5 max-w-108 text-[13.5px] leading-[1.65] text-fg-2">
Sube lo tuyo, descubre lo nuevo y escúchalo todo en un solo sitio.
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight}
<Button
variant="accent"
class="!bg-cta-strong !font-bold !text-ink hover:!brightness-95"
onclick={playSpotlight}
>
{heroPlaying ? 'Pausar' : 'Reanudar'}
{spotlight.name}
</Button>
{/if}
<Button href="/explore" variant="secondary" class="!font-normal">Explorar música</Button>
</div>
<span
class="grid h-11 w-11 shrink-0 place-items-center rounded-full bg-accent-soft text-on-accent shadow-art transition duration-300 group-hover/hero:scale-105"
>
{#if heroPlaying}
<Pause class="h-4.5 w-4.5" fill="currentColor" />
{:else}
<Play class="h-4.5 w-4.5 translate-x-0.5" fill="currentColor" />
{/if}
</span>
</button>
{/if}
</div>
{#await data.listeningStats then stats}
<div class="flex gap-6.5">
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{stats.tracksThisWeek}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Canciones nuevas escuchadas esta semana</div>
</div>
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{fmtDurationLong(Number(stats.secondsThisWeek))}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Tiempo de escucha esta semana</div>
</div>
<div>
<div class="font-display text-[25px] font-semibold tracking-[-0.03em] text-fg">
{stats.streakDays}
</div>
<div class="mt-1 text-[11.5px] text-fg-3">Días consecutivos escuchando</div>
</div>
</div>
{/await}
</div>
</div>
</section>
<div class="flex flex-col gap-12 page-x pb-8 sm:pb-10">
{#if topMusicTargets.length > 0}
<section>
<SectionHeading title="Top música">
{#snippet actions()}
{@render railArrows(topMusicRail)}
{/snippet}
</SectionHeading>
<Rail bind:this={topMusicRail}>
{#each topMusicTargets as target, i (targetId(target))}
<button
type="button"
onclick={() => playTopMusic(i)}
oncontextmenu={(e) => openContextMenu(e, target)}
aria-label="Reproducir {targetTitle(target)}"
class="group/card animate-enter block w-36 shrink-0 rounded-art text-left focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none"
style="animation-delay:{Math.min(i, 10) * 45}ms"
<div class="flex flex-col gap-11.5 page-x pt-10 pb-8 sm:pb-10">
<!-- CONTINUAR ESCUCHANDO -->
<section>
<SectionHeading title="Continuar escuchando" />
{#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<div
role="button"
tabindex="0"
onclick={() => playContinue(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playContinue(i);
}}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
class="group/cont animate-enter flex items-center gap-3.25 rounded-[13px] p-2.25 pr-3.5 text-left transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-white/[3%]'}"
style="animation-delay:{Math.min(i, 10) * 40}ms"
>
<div class="relative h-36 w-36">
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="large"
alt={targetTitle(target)}
class="h-full w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
<Cover
trackId={item.id}
src={item.coverUrl}
size="small"
alt={item.title}
class="h-13 w-13 shrink-0 rounded-[10px]"
/>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm leading-[1.3] font-medium {isCurrent
? 'text-accent-soft'
: 'text-fg'}"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
{item.title}
</div>
<ArtistLink
name={item.artist || '—'}
ownerUserId={item.ownerUserId}
class="mt-0.75 text-[12.5px] text-fg-3"
/>
</div>
<div class="mt-3 flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span class="truncate text-fg transition-colors group-hover/card:text-accent-soft">
{targetTitle(target)}
</div>
{/each}
</div>
{:else}
<EmptyState
icon={History}
title="Aún no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</section>
<!-- PLAYLIST DESTACADA -->
{#if spotlight}
<section>
<div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg bg-[image:var(--mf-spotlight-bg)] transition-[background] duration-[600ms] lg:grid-cols-2"
>
<div class="flex flex-col justify-center gap-5 p-8 sm:p-9">
<div class="flex items-center gap-5">
<PlaylistArt
playlistId={spotlight.id}
trackIds={spotlight.coverTrackIds}
version={spotlight.updatedAt}
size="large"
class="h-28 w-28 shrink-0 rounded-[16px] shadow-[0_16px_40px_rgba(0,0,0,.5)]"
/>
<div class="min-w-0">
<p class="text-[11px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
Playlist destacada
</p>
<h3
class="mt-2.5 truncate font-display text-[28px] font-semibold tracking-[-0.03em] text-fg"
>
{spotlight.name}
</h3>
<p class="mt-2 text-[13px] text-fg-2">{spotlightMeta}</p>
</div>
</div>
<p class="max-w-110 text-sm leading-[1.7] text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-1 flex gap-2.5">
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button>
<Button variant="secondary" class="!font-normal" onclick={shuffleSpotlight}>
Aleatorio
</Button>
</div>
</div>
<div
class="flex flex-col gap-0.5 p-5.5 sm:p-6"
style="background-image:linear-gradient(to right, color-mix(in oklch, var(--mf-scrim) 26%, transparent), transparent)"
>
{#each spotlightItems as target, i (targetId(target))}
{@const durationSeconds = targetDurationSeconds(target)}
<div
role="button"
tabindex="0"
onclick={() => playSpotlightTrack(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playSpotlightTrack(i);
}}
class="flex items-center gap-3 rounded-[10px] p-2 text-left transition-colors hover:bg-white/[3%] {isTargetCurrent(
target
)
? 'bg-accent-tint'
: ''}"
>
<span class="w-5 shrink-0 text-center text-[11.5px] text-muted tabular-nums"
>{i + 1}</span
>
<div class="min-w-0 flex-1">
<div
class="truncate text-[12.5px] font-medium {isTargetCurrent(target)
? 'text-accent-soft'
: 'text-fg'}"
>
{targetTitle(target)}
</div>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-[11px] text-fg-3"
/>
</div>
<span class="shrink-0 text-[11.5px] text-muted tabular-nums">
{durationSeconds !== undefined ? fmtTime(durationSeconds) : '—'}
</span>
</div>
{#if targetArtist(target)}
<div class="mt-0.5 truncate text-sm text-fg-2">{targetArtist(target)}</div>
{/if}
</button>
{/each}
</Rail>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
{/each}
</div>
</div>
</section>
{/if}
<div class="grid grid-cols-1 gap-10 lg:grid-cols-2 lg:gap-8">
<section class="min-w-0">
<SectionHeading title="Popular" />
<div class="flex flex-col gap-1.5">
<!-- MIXES -->
<section>
<SectionHeading title="Mixes" subtitle="Generados a partir de lo que más repites" />
{#if mixes.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(164px,1fr))] gap-5">
{#each mixes as mix, i (mix.id)}
<MixTile {mix} index={i} />
{/each}
</div>
{:else}
<EmptyState
icon={Shuffle}
title="Aún no tienes mezclas"
description="Se generan automáticamente a partir de lo que escuchas."
/>
{/if}
</section>
<!-- POPULARES ESTA SEMANA -->
<section>
<SectionHeading
title="Populares esta semana"
subtitle="Lo más escuchado entre quienes comparten tu gusto"
/>
{#if popularTargets.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTargets as target, i (targetId(target))}
{@const isCurrent = isTargetCurrent(target)}
<div
class="group/row flex min-w-0 items-center gap-3 rounded-control p-2 transition duration-200 {isCurrent
? 'bg-surface-hover'
: 'hover:bg-hover'}"
role="button"
tabindex="0"
onclick={() => playPopular(i)}
onkeydown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
playPopular(i);
}}
oncontextmenu={(e) => openContextMenu(e, target)}
class="flex items-center gap-3.5 rounded-[11px] p-2 text-left transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-white/[3%]'}"
>
<button
type="button"
onclick={() => playPopular(i)}
aria-label="{isCurrent && player.playing ? 'Pausar' : 'Reproducir'} {targetTitle(
target
)}"
class="relative shrink-0"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-11 w-11 rounded-control shadow-art transition duration-200"
>
{#if isCurrent}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
{#if !isCurrent}
<span
class="absolute inset-0 grid place-items-center rounded-control bg-scrim/0 opacity-0 transition duration-200 group-hover/row:bg-scrim/50 group-hover/row:opacity-100"
>
<Play class="h-3.5 w-3.5 translate-x-0.5 text-on-art" fill="currentColor" />
</span>
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</button>
<button type="button" onclick={() => playPopular(i)} class="min-w-0 flex-1 text-left">
</span>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-9.5 w-9.5 shrink-0 rounded-[8px]"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
class="truncate text-[12.5px] font-medium {isCurrent
? 'text-accent-soft'
: 'text-fg'}"
>
{targetTitle(target)}
</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</button>
<button
type="button"
onclick={(e) => openContextMenu(e, target)}
aria-label="Más opciones"
class="shrink-0 rounded-full p-1.5 text-fg-2 opacity-0 transition group-hover/row:opacity-100 hover:text-fg"
>
<MoreHorizontal class="h-4 w-4" />
</button>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-[11.5px] text-fg-3"
/>
</div>
</div>
{/each}
</div>
</section>
<section class="min-w-0">
<SectionHeading title="Álbumes recomendados">
{:else}
<EmptyState
icon={History}
title="Aún no has escuchado nada"
description="Reproduce alguna canción y tu actividad reciente aparecerá aquí."
>
{#snippet actions()}
{@render railArrows(recommendedRail)}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</SectionHeading>
<Rail bind:this={recommendedRail}>
{#each recommendedAlbums as entry, i (entry.kind === 'recent' ? entry.album.id : entry.album.albumId)}
{#if entry.kind === 'recent'}
{@const album = entry.album}
<a
href={album.source === 'YouTube'
? `/albums/external/youtube/${album.externalId}`
: `/albums/${album.id}`}
class="group/card animate-enter block w-36 shrink-0 text-center"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) =>
openAlbumMenu(e, album.source === 'YouTube' ? 'youtube' : 'local', album.id)}
>
{#if album.source === 'YouTube' && album.thumbnailUrl}
<Cover
trackId={album.id}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
{:else}
<PlaylistArt
trackIds={album.coverTrackIds}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
{/if}
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-2">{albumCaption(album)}</div>
</a>
{:else}
{@const album = entry.album}
<a
href="/albums/external/youtube/{album.albumId}"
class="group/card animate-enter block w-36 shrink-0 text-center"
style="animation-delay:{Math.min(i, 10) * 45}ms"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', album.albumId)}
>
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="mx-auto h-36 w-36 rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-2">{youtubeAlbumCaption(album)}</div>
</a>
{/if}
{/each}
</Rail>
</section>
</div>
</EmptyState>
{/if}
</section>
{#if mixes.length > 0}
<section>
<SectionHeading title="Mezclas" />
<div class="grid grid-cols-2 gap-3 sm:auto-rows-25 sm:grid-cols-4 sm:gap-4">
{#each mixes as mix, i (mix.id)}
<MixBentoTile
{mix}
index={i}
featured={i === 0}
banner={i === 4}
class={i === 0
? 'col-span-2 min-h-42 sm:row-span-2 sm:min-h-0'
: i === 1
? 'col-span-2 min-h-30 sm:min-h-0'
: i === 4
? 'col-span-2 min-h-24 sm:col-span-4 sm:min-h-0'
: 'min-h-30 sm:min-h-0'}
/>
{/each}
</div>
</section>
{/if}
{#if playlists.length > 0}
<section>
<SectionHeading title="Listas de reproducción" />
<Rail>
<!-- TUS PLAYLISTS -->
<section>
<SectionHeading title="Tus playlists" subtitle="Creadas y guardadas por ti">
{#snippet actions()}
{#if data.playlistsHasMore}
<a
href="/playlists"
class="shrink-0 text-[11.5px] font-semibold tracking-[0.07em] text-fg-3 uppercase transition-colors hover:text-fg"
>
Ver todas
</a>
{/if}
{/snippet}
</SectionHeading>
{#if playlists.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
{#each playlists as playlist, i (playlist.id)}
<a
href="/playlists/{playlist.id}"
class="group/card animate-enter block w-40 shrink-0 pr-2 pb-2"
style="animation-delay:{Math.min(i, 10) * 45}ms"
class="group/card animate-enter flex items-center gap-3.5 rounded-[15px] p-3 transition-colors hover:bg-white/[3%]"
style="animation-delay:{Math.min(i, 10) * 40}ms"
>
<div class="relative h-40 w-40">
<div
class="absolute inset-0 translate-x-2 translate-y-2 rounded-art bg-surface-2 ring-1 ring-line"
></div>
<div
class="absolute inset-0 translate-x-1 translate-y-1 rounded-art bg-surface-hover ring-1 ring-line"
></div>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
class="absolute inset-0 h-full w-full rounded-art shadow-art-lg ring-1 ring-line-strong"
/>
<PlaylistArt
playlistId={playlist.id}
trackIds={playlist.coverTrackIds}
version={playlist.updatedAt}
size="medium"
class="h-15.5 w-15.5 shrink-0 rounded-[11px] shadow-[0_10px_24px_rgba(0,0,0,.42)]"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-[13.5px] font-semibold tracking-[-0.01em] text-fg">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-1 truncate text-[11.5px] text-fg-3">{playlist.description}</div>
{/if}
</div>
<div
class="mt-3.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-0.5 truncate text-sm text-fg-2">{playlist.description}</div>
{/if}
</a>
{/each}
<a
href="/playlists"
class="group/more grid h-40 w-40 shrink-0 place-items-center rounded-art border border-dashed border-line text-fg-2 transition duration-300 hover:border-accent-soft/50 hover:text-accent-soft"
>
<div class="flex flex-col items-center gap-2">
<span
class="grid h-9 w-9 place-items-center rounded-full bg-surface-2 transition-transform duration-300 group-hover/more:translate-x-0.5"
>
<ArrowRight class="h-4 w-4" />
</span>
<span class="text-sm">Ver todas</span>
</div>
</a>
</Rail>
</section>
{/if}
{#if recTargets.length > 0}
<section>
<SectionHeading title="Quizá te guste" />
<ul class="grid grid-cols-1 gap-1 lg:grid-cols-2 lg:gap-x-8">
{#each recTargets as target, i (targetId(target))}
<li>
<button
type="button"
onclick={() => playRecommended(i)}
oncontextmenu={(e) => openContextMenu(e, target)}
aria-label="Reproducir {targetTitle(target)}"
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-hover focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
<Cover
trackId={targetId(target)}
src={targetCoverSrc(target)}
size="small"
alt={targetTitle(target)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(target)}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
{#if targetExplicit(target)}
<ExplicitBadge />
{/if}
<span
class="truncate text-sm text-fg transition-colors group-hover/row:text-accent-soft"
>
{targetTitle(target)}
</span>
</div>
{#if targetArtist(target)}
<div class="truncate text-xs text-fg-2">{targetArtist(target)}</div>
{/if}
</div>
{#if targetDurationSeconds(target)}
<span class="shrink-0 text-sm text-fg-2 tabular-nums">
{fmtTime(targetDurationSeconds(target) ?? 0)}
</span>
{/if}
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea tu primera lista y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button href="/playlists">Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</div>
{#if contextMenu}
@@ -630,13 +500,3 @@
onAddToQueue={addToQueue}
/>
{/if}
{#if albumMenu}
<AlbumContextMenu
menu={albumMenu}
{playlists}
onClose={() => (albumMenu = null)}
onPlayNext={albumPlayNext}
onAddToQueue={albumAddToQueue}
/>
{/if}