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:
+366
-506
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user