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
@@ -35,7 +35,7 @@
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
<MediaGrid class="mt-7 sm:mt-8">
{#each items as album, i (album.id)}
<AlbumCard
id={album.id}
@@ -55,10 +55,7 @@
description="Crea el primero y añádele las canciones que hayas subido."
>
{#snippet actions()}
<Button onclick={() => (creating = true)}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear álbum
</Button>
<Button onclick={() => (creating = true)}>Crear álbum</Button>
{/snippet}
</EmptyState>
{/if}
@@ -42,7 +42,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
(track) => track.source === 'Local' && !inAlbum.has(track.id)
);
const isOwner = user !== null && Number(album.ownerUserId) === Number(user.sub);
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
return { album, tracks, library, isOwner, section: isOwner ? '/albums' : null };
};
@@ -48,17 +48,17 @@
label={isOwner ? 'Volver a tus álbumes' : 'Volver al inicio'}
/>
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<PlaylistArt
{trackIds}
{coverUrl}
size="large"
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Álbum</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Álbum</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{title}
</h1>
@@ -72,10 +72,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -31,18 +31,16 @@
<TrackTable index action={isOwner} class="mt-6 sm:mt-8">
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === queueIdForTrack(track)}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
@@ -28,12 +28,15 @@
<SectionHeading title="Tus canciones subidas" />
<TrackTable action>
{#each library as track, i (track.id)}
<TrackRow>
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
@@ -79,21 +79,21 @@
<Page>
<BackLink href="/explore" label="Volver a explorar" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<Cover
trackId={album.albumId}
src={album.thumbnailUrl}
size="large"
alt={album.title}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
oncontextmenu={(e) => (albumMenu = albumContextMenuStateFor(e, 'youtube', album.albumId))}
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">
{album.isSingle ? 'Single' : album.isEp ? 'EP' : 'Álbum'}
</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{album.title}
</h1>
@@ -113,10 +113,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -132,19 +132,19 @@
<TrackTable index class="mt-6 sm:mt-8">
{#each tracks as track, i (track.videoId)}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}>
<TrackIndexCell
index={i}
active={player.current.id === track.videoId}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === track.videoId}
<TrackRow
{active}
oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targetFor(i)))}
>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
trackId={track.videoId}
title={track.title}
artist={album.artist}
coverSrc={album.thumbnailUrl}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(track.durationSeconds))}</TrackMeta>
@@ -1,6 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { YOUTUBE_FILLER_QUERIES } from '$lib/server/youtube';
import {
addAlbumToPlaylistAction,
addTrackAction,
@@ -10,9 +10,8 @@ import {
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
YOUTUBE_FILLER_LIMIT
EXPLORE_USERS_LIMIT
} from '$lib/config';
import { searchUsers } from '$lib/server/userDirectory';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? '';
@@ -39,10 +38,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
})
: Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes] = await Promise.all([
const usersPromise = query
? api.GET('/users', {
params: { query: { usernameSearch: query, pageNumber: 1, pageSize: EXPLORE_USERS_LIMIT } }
})
: Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
tracksPromise,
playlistsPromise,
albumsPromise
albumsPromise,
usersPromise
]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
@@ -54,10 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
tracks,
albums: albumItems.flatMap((item) => (item.album ? [item.album] : [])),
youtubeAlbums: albumItems.flatMap((item) => (item.youTubeAlbum ? [item.youTubeAlbum] : [])),
users: searchUsers(query),
youtubeFiller: query
? Promise.resolve(null)
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
users: usersRes?.data?.items ?? [],
genres: YOUTUBE_FILLER_QUERIES,
playlists: playlistsRes?.data?.items ?? []
};
};
+163 -159
View File
@@ -1,8 +1,5 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/albums';
import { fmtTime } from '$lib/format';
@@ -10,17 +7,16 @@
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Input from '$lib/components/ui/Input.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import AlbumCard from '$lib/components/ui/AlbumCard.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
import Rail from '$lib/components/ui/Rail.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE, SEARCH_DEBOUNCE_MS } from '$lib/config';
import { EXPLORE_ALBUMS_LIMIT, EXPLORE_PAGE_SIZE } from '$lib/config';
import type { YouTubeSong } from '$lib/types';
import TrackContextMenu, {
contextMenuStateFor,
@@ -42,10 +38,24 @@
type TrackTarget
} from '$lib/tracks';
let { data, form } = $props();
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let { data, form } = $props();
let ytItems = $state<YouTubeSong[]>([]);
let ytUnavailable = $state(false);
@@ -71,26 +81,26 @@
if (data.query) {
ytItems = appendUnique(
[],
data.tracks.items.flatMap((item) => (item.youTubeSong ? [item.youTubeSong as YouTubeSong] : [])),
data.tracks.items.flatMap((item) =>
item.youTubeSong ? [item.youTubeSong as YouTubeSong] : []
),
(i) => i.videoId
);
ytUnavailable = data.tracks.youtubeUnavailable;
return;
} else {
ytItems = [];
ytUnavailable = false;
}
ytItems = [];
ytUnavailable = false;
let cancelled = false;
data.youtubeFiller?.then((filler) => {
if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId);
});
return () => {
cancelled = true;
};
});
const genreTiles = $derived(
data.genres.map((query, i) => ({
query,
hue: Math.round((i * 360) / data.genres.length),
...(GENRE_INFO[query] ?? { label: query, tagline: 'Explora este estilo' })
}))
);
const songRows = $derived([
...localItems.map((track) => ({
target: { kind: 'local' as const, track },
@@ -126,35 +136,10 @@
!!data.query && !ytUnavailable && items.length === 0 && !hasAlbums && !hasUsers
);
function localAlbumCaption(album: LocalAlbum) {
return [
album.releaseYear === null ? undefined : String(album.releaseYear),
`${Number(album.trackCount)} ${Number(album.trackCount) === 1 ? 'canción' : 'canciones'}`
]
.filter(Boolean)
.join(' · ');
}
function youtubeAlbumCaption(album: YouTubeAlbum) {
return [album.artist, album.releaseYear === null ? undefined : String(album.releaseYear)]
.filter(Boolean)
.join(' · ');
}
function buildHref(query: string) {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
function onSearchInput(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const term = value.trim();
if (term === data.query) return;
goto(buildHref(term), { keepFocus: true, replaceState: true, noScroll: true });
}, SEARCH_DEBOUNCE_MS);
}
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemForTarget), index);
}
@@ -224,87 +209,99 @@
</script>
<svelte:head>
<title>Buscar</title>
<meta name="description" content="Busca canciones, álbumes y usuarios en Musify." />
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
<meta
name="description"
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
/>
</svelte:head>
<Page>
<PageHeader title="Buscar" subtitle="Encuentra canciones, álbumes y personas." />
<div class="mt-6">
<Input
type="search"
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="¿Qué quieres reproducir?"
{#if !data.query}
<PageHeader
title="Descubrir"
subtitle="Encuentra tu próxima canción favorita explorando por género."
/>
</div>
{:else if !nothingFound}
<PageHeader title="Resultados para «{data.query}»" />
{/if}
{#if form?.message}
<Alert class="mt-4">{form.message}</Alert>
{/if}
{#if nothingFound}
<EmptyState
icon={Music}
title="Sin resultados"
description="No hay resultados para «{data.query}»."
/>
<div class="mx-auto max-w-115 py-[8vh] text-center">
<h1 class="font-display text-xl font-semibold tracking-[-0.025em] text-fg">
Sin resultados para «{data.query}»
</h1>
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
Revisa la ortografía o prueba con un término más corto. También puedes buscar por álbum o
por playlist.
</p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)}
<a
href={buildHref(genre.query)}
class="rounded-full border border-white/8 px-3.5 py-2 text-xs text-fg-2 transition-colors hover:bg-white/5 hover:text-fg"
>
{genre.label}
</a>
{/each}
</div>
</div>
{/if}
{#if !data.query}
<!-- GÉNEROS -->
<div class="mt-7 grid grid-cols-[repeat(auto-fill,minmax(268px,1fr))] gap-3.5">
{#each genreTiles as genre, i (genre.query)}
<a
href={buildHref(genre.query)}
class="animate-enter group relative block h-28 overflow-hidden rounded-2xl p-4.25 transition-[filter] hover:brightness-125"
style="animation-delay:{Math.min(i, 10) *
40}ms; background:linear-gradient(140deg, oklch(0.24 0.07 {genre.hue}), rgba(9,9,12,.95))"
>
<div class="font-display text-[15.5px] font-semibold tracking-[-0.02em] text-fg">
{genre.label}
</div>
<div class="mt-1.5 text-[11.5px] text-fg-3">{genre.tagline}</div>
</a>
{/each}
</div>
{/if}
{#if hasAlbums}
<div class="mt-8">
<div class="mt-9">
<SectionHeading title="Álbumes" />
<MediaGrid as="ul" min="168px" minMobile="150px">
{#each albumEntries as entry, i (entry.kind === 'local' ? entry.album.id : entry.album.albumId)}
<li>
{#if entry.kind === 'local'}
<a
href="/albums/{entry.album.id}"
class="group/card animate-enter block min-w-0 rounded-art 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"
oncontextmenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
>
<div class="relative">
<PlaylistArt
trackIds={entry.album.coverTrackIds}
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{entry.album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{localAlbumCaption(entry.album)}
</div>
</a>
<AlbumCard
id={entry.album.id}
title={entry.album.title}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
trackCount={Number(entry.album.trackCount)}
trackIds={entry.album.coverTrackIds}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'local', entry.album.id)}
/>
{:else}
<a
<AlbumCard
id={entry.album.albumId}
href="/albums/external/youtube/{entry.album.albumId}"
class="group/card animate-enter block min-w-0 rounded-art 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"
oncontextmenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
>
<div class="relative">
<Cover
trackId={entry.album.albumId}
src={entry.album.thumbnailUrl}
size="large"
alt={entry.album.title}
class="aspect-square w-full rounded-art shadow-art ring-1 ring-line transition duration-300 ease-out ring-inset group-hover/card:shadow-art-lg"
/>
</div>
<div
class="mt-2.5 truncate text-fg transition-colors group-hover/card:text-accent-soft"
>
{entry.album.title}
</div>
<div class="mt-0.5 truncate text-sm text-fg-3">
{youtubeAlbumCaption(entry.album)}
</div>
</a>
title={entry.album.title}
subtitle={entry.album.artist}
releaseYear={entry.album.releaseYear === null
? undefined
: Number(entry.album.releaseYear)}
coverSrc={entry.album.thumbnailUrl}
index={i}
onContextMenu={(e) => openAlbumMenu(e, 'youtube', entry.album.albumId)}
/>
{/if}
</li>
{/each}
@@ -313,71 +310,78 @@
{/if}
{#if hasUsers}
<div class="mt-8">
<div class="mt-9">
<SectionHeading title="Usuarios" />
<Rail>
{#each users as u, i (u.id)}
<div
<a
href="/u/{u.id}"
class="group/artist animate-enter shrink-0"
style="animation-delay:{Math.min(i, 10) * 45}ms"
>
<ArtistAvatar name={u.name} size={96} />
<div class="mt-1 w-24 truncate text-center text-xs text-fg-3">@{u.handle}</div>
</div>
<ArtistAvatar name={u.name} imageUrl={u.profilePictureUrl} size={96} />
</a>
{/each}
</Rail>
</div>
{/if}
<div class="mt-6">
{#if songRows.length > 0}
<ul class="flex flex-col gap-1">
{#each songRows as { target: item, seconds }, i (targetId(item))}
<li>
<button
type="button"
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
aria-label="Reproducir {targetTitle(item)}"
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-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
<Cover
trackId={targetId(item)}
src={targetCoverSrc(item)}
size="small"
alt={targetTitle(item)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
{#if data.query}
<div class="mt-9">
{#if songRows.length > 0}
<SectionHeading title="Canciones" />
<ul class="flex flex-col gap-1">
{#each songRows as { target: item, seconds }, i (targetId(item))}
<li>
<button
type="button"
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
aria-label="Reproducir {targetTitle(item)}"
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-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{#if isTargetCurrent(item)}
<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(item)}
<ExplicitBadge />
<Cover
trackId={targetId(item)}
src={targetCoverSrc(item)}
size="small"
alt={targetTitle(item)}
class="h-11 w-11 shrink-0 rounded-control shadow-art ring-1 ring-line ring-inset"
>
{#if isTargetCurrent(item)}
<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(item)}
<ExplicitBadge />
{/if}
<span class="truncate text-[13px] font-medium text-fg">
{targetTitle(item)}
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-[11.5px] text-fg-3">
Canción · {targetArtist(item)}
</div>
{/if}
<span class="truncate text-fg transition-colors group-hover/row:text-accent-soft">
{targetTitle(item)}
</span>
</div>
{#if targetArtist(item)}
<div class="truncate text-sm text-fg-3">Canción · {targetArtist(item)}</div>
{/if}
</div>
<span class="shrink-0 text-sm text-muted tabular-nums">{fmtTime(seconds)}</span>
</button>
</li>
{/each}
</ul>
<span class="shrink-0 text-[11.5px] text-muted tabular-nums"
>{fmtTime(seconds)}</span
>
</button>
</li>
{/each}
</ul>
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{#if hasMore}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
{:else if !ytUnavailable && !nothingFound}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if}
{:else if data.query && !ytUnavailable}
<EmptyState icon={Music} description="No hay canciones que coincidan con «{data.query}»." />
{/if}
</div>
</div>
{/if}
{#if ytUnavailable}
<EmptyState
@@ -51,20 +51,28 @@
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Subida', width: '100px' },
{ label: 'Escuchas', width: '100px' }
]}
class="mt-6 sm:mt-8"
class="mt-7 sm:mt-8"
>
{#each items as track, i (track.id)}
<TrackRow>
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
active={player.current.id === track.id}
{active}
playing={player.playing}
onToggle={() => togglePlay(i)}
/>
<TrackTitleCell trackId={track.id} title={track.title} onClick={() => togglePlay(i)} />
<TrackTitleCell
trackId={track.id}
title={track.title}
{active}
onClick={() => togglePlay(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals }) => {
if (!locals.user) redirect(302, '/explore');
return {};
};
@@ -0,0 +1,136 @@
<script lang="ts">
import Heart from '@lucide/svelte/icons/heart';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import X from '@lucide/svelte/icons/x';
import { player, type QueueItem } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import { fmtDate } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import EmptyState from '$lib/components/ui/EmptyState.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';
const tracks = $derived(liked.list);
const queueItems = $derived<QueueItem[]>(
tracks.map((t) => ({
id: t.id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
}))
);
const isCurrentQueue = $derived(tracks.some((t) => t.id === player.current.id));
function playFrom(index: number) {
player.playOrToggle(queueItems, index);
}
function playAll() {
if (queueItems.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(queueItems, 0);
}
</script>
<svelte:head>
<title>Me gusta</title>
<meta name="description" content="Las canciones que te gustan en Musify." />
</svelte:head>
<Page>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<div
class="grid h-36 w-36 shrink-0 place-items-center rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
style="background:linear-gradient(150deg, oklch(0.4 0.09 330), oklch(0.18 0.04 10))"
>
<Heart class="h-10 w-10 text-white/90" fill="currentColor" strokeWidth={0} />
</div>
<div class="min-w-0 flex-1">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Colección</p>
<h1
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] text-fg sm:text-[33px]"
>
Me gusta
</h1>
<p class="mt-3.25 text-[12.5px] text-fg-2">
{tracks.length}
{tracks.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={tracks.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>
</div>
{#if tracks.length > 0}
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Añadida', width: '140px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell
index={i}
{active}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={track.coverUrl}
explicit={track.explicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
<TrackMeta>—</TrackMeta>
<IconButton
label="Quitar de Me gusta"
size="sm"
revealOnHover
onclick={() => liked.toggle(track)}
>
<X class="h-3.5 w-3.5" />
</IconButton>
</TrackRow>
{/each}
</TrackTable>
{:else}
<EmptyState
icon={Heart}
title="Todavía no tienes canciones con me gusta"
description="Toca el corazón en cualquier canción del reproductor para guardarla aquí."
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
@@ -67,12 +67,15 @@
<Page>
<BackLink href="/" label="Volver al inicio" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<MixArt {items} class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44" />
<div class="mt-3 flex flex-col gap-5 sm:mt-4 sm:flex-row sm:items-end sm:gap-6">
<MixArt
{items}
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Mezcla</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Mezcla</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-1.5 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{mix.title}
</h1>
@@ -89,10 +92,10 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={playAll} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Pause class="h-4 w-4" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
<Play class="h-4 w-4" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
@@ -108,19 +111,16 @@
<TrackTable index class="mt-6 sm:mt-8">
{#each items as item, i (mixItemKey(item))}
<TrackRow oncontextmenu={(e) => (contextMenu = contextMenuStateFor(e, targets[i]))}>
<TrackIndexCell
index={i}
active={isTargetCurrent(targets[i])}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@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])}
coverSrc={targetCoverSrc(targets[i])}
explicit={item.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
@@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
return { playlists: data };
const tracksByPlaylist = await Promise.all(
data.items.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
})
.then((res) => res.data?.items ?? [])
)
);
const items = data.items.map((playlist, i) => {
const tracks = tracksByPlaylist[i];
const trackCount = tracks.length;
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
return { ...playlist, trackCount, durationSeconds };
});
const totals = {
playlistCount: items.length,
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
};
return { playlists: { ...data, items }, totals };
};
export const actions: Actions = {
@@ -30,6 +53,7 @@ export const actions: Actions = {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
@@ -55,7 +79,7 @@ export const actions: Actions = {
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId }
body: { name, description, pictureIntentId, visibility }
});
if (err || !data)
@@ -1,43 +1,21 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong } from '$lib/format';
const playlistNames = [
'Mi Playlist',
'Tus favoritos',
'Lo mejor de hoy',
'Para relajarse',
'Buena vibra',
'Modo estudio',
'Road Trip',
'Domingos',
'Late Night',
'Descubriendo música',
'Todo un poco',
'En bucle',
'Mi colección',
'Canciones para repetir',
'Hits del momento'
];
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
let { data, form } = $props();
let { data } = $props();
const items = $derived(data.playlists.items);
let creating = $state(false);
function openCreate() {
creating = true;
}
const totals = $derived(data.totals);
const summary = $derived(
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
);
</script>
<svelte:head>
@@ -46,24 +24,24 @@
</svelte:head>
<Page>
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
<PageHeader
title="Mis listas"
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
>
{#snippet actions()}
{#if items.length > 0}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista
</Button>
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<MediaGrid class="mt-9">
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackCount={playlist.trackCount}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
@@ -77,22 +55,8 @@
description="Crea la primera y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear lista
</Button>
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<PlaylistForm
action="?/create"
{namePlaceholder}
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -8,75 +8,28 @@ import {
unwrapOrFail
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { shuffle } from '$lib/collections';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types';
const MAX_NAME = 100;
const SUGGESTIONS_LIMIT = 20;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening);
optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
const [playlistRes, tracksRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
}),
user
? api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null),
user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null)
})
]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const inPlaylist = new Set(tracks.map((t) => t.id));
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
const alreadyLinked = new Set(
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
);
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
const youtube = fetchYoutubeFiller(
api,
locals.accessToken,
SUGGESTIONS_LIMIT + alreadyLinked.size,
seed
)
.then((filler) => ({
query: filler.query,
items: (filler.items as YouTubeSong[])
.filter((song) => !alreadyLinked.has(song.videoId))
.slice(0, SUGGESTIONS_LIMIT)
}))
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
return {
playlist,
tracks,
library,
youtube,
playlists: playlistsRes?.data?.items ?? []
};
return { playlist, tracks };
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
@@ -98,6 +51,7 @@ export const actions: Actions = {
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
@@ -119,7 +73,7 @@ export const actions: Actions = {
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId }
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
});
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
@@ -1,30 +1,21 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/Page.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistHeader from './components/PlaylistHeader.svelte';
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
import SuggestionsTable from './components/SuggestionsTable.svelte';
import type { YouTubeSong } from '$lib/types';
let { data, form } = $props();
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const library = $derived(data.library);
let editing = $state(false);
let confirmingDelete = $state(false);
let youtubeSuggestions = $state<YouTubeSong[]>([]);
$effect(() => {
youtubeSuggestions = [];
data.youtube.then((result) => {
youtubeSuggestions = result.items;
});
});
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
@@ -47,6 +38,7 @@
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
visibility={playlist.visibility}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
@@ -55,10 +47,17 @@
{#if tracks.length > 0}
<PlaylistTrackTable {tracks} />
{/if}
{#if library.length > 0 || youtubeSuggestions.length > 0}
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
{:else}
<EmptyState
icon={ListMusic}
title="Esta playlist todavía está vacía"
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
class="mt-6 sm:mt-8"
>
{#snippet actions()}
<Button href="/explore" variant="secondary">Explorar música</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
@@ -67,6 +66,7 @@
action="?/rename"
initialName={playlist.name}
initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
@@ -79,10 +79,10 @@
</Modal>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-semibold tracking-tight text-fg">
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2 text-base text-fg-3">
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
@@ -4,7 +4,6 @@
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
interface Props {
@@ -13,39 +12,50 @@
description?: string | null;
trackIds: (string | number)[];
updatedAt: string;
visibility: 'Public' | 'Private';
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
$props();
let {
id,
name,
description,
trackIds,
updatedAt,
visibility,
playing,
onPlayAll,
onEdit,
onDelete
}: Props = $props();
</script>
<BackLink href="/playlists" label="Volver a tus listas" />
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<PlaylistArt
playlistId={id}
{trackIds}
size="large"
version={updatedAt}
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Lista</p>
<h1
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{name}
</h1>
{#if description}
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">
<p class="mt-3.25 text-[12.5px] text-fg-2">
{trackIds.length}
{trackIds.length === 1 ? 'canción' : 'canciones'}
{trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public'
? 'Pública'
: 'Privada'}
</p>
</div>
</div>
@@ -53,19 +63,15 @@
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
{#if playing}
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Pausar
{:else}
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
Reproducir
{/if}
</Button>
<Button size="sm" variant="secondary" onclick={onEdit}>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</Button>
<Button size="sm" variant="secondary" onclick={onDelete}>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</Button>
</div>
@@ -31,23 +31,29 @@
}
</script>
<TrackTable index action meta={[{ label: 'Añadida', width: '140px' }]} class="mt-6 sm:mt-8">
<TrackTable
index
action
meta={[
{ label: 'Álbum', width: '160px' },
{ label: 'Añadida', width: '140px' }
]}
class="mt-6 sm:mt-8"
>
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
{@const active = player.current.id === queueIdForTrack(track)}
<TrackRow {active}>
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet badge()}
@@ -58,6 +64,7 @@
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
@@ -1,151 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import {
player,
queueIdForTrack,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import type { YouTubeSong } from '$lib/types';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.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 TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string };
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
interface Props {
library: LibraryTrack[];
youtube: YouTubeSong[];
playlists: { id: string; name: string }[];
}
let { library, youtube, playlists }: Props = $props();
let contextMenu = $state<ContextMenuState | null>(null);
function openContextMenu(event: MouseEvent, item: Suggestion) {
contextMenu = contextMenuStateFor(event, item);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu as Suggestion));
closeContextMenu();
}
const suggestions = $derived<Suggestion[]>([
...library.map((track) => ({ kind: 'local' as const, track })),
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
]);
function idFor(item: Suggestion) {
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
}
function queueItemFor(item: Suggestion): QueueItem {
return item.kind === 'youtube'
? {
id: item.song.videoId,
title: item.song.title,
artist: item.song.artist,
source: 'youtube',
coverUrl: item.song.thumbnailUrl,
explicit: item.song.isExplicit
}
: {
id: queueIdForTrack(item.track),
title: item.track.title,
artist: item.track.artist ?? undefined,
source: 'local',
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
};
}
function playFrom(index: number) {
player.playOrToggle(suggestions.map(queueItemFor), index);
}
</script>
<div class="mt-10 sm:mt-12">
<SectionHeading title="Sugerencias" />
<TrackTable action>
{#each suggestions as item, i (idFor(item))}
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
<TrackTitleCell
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
title={item.kind === 'youtube' ? item.song.title : item.track.title}
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
explicit={item.kind === 'youtube' && item.song.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === idFor(item)}
<NowPlaying paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>
{fmtTime(
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
)}
</TrackMeta>
{#if item.kind === 'youtube'}
<form
method="POST"
action="?/addYouTubeToPlaylist"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="videoId" value={item.song.videoId} />
<input type="hidden" name="title" value={item.song.title} />
<input type="hidden" name="artist" value={item.song.artist} />
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{:else}
<form
method="POST"
action="?/addTrack"
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={item.track.id} />
<IconButton type="submit" label="Añadir a la playlist" size="sm">
<Plus class="h-4 w-4" />
</IconButton>
</form>
{/if}
</TrackRow>
{/each}
</TrackTable>
</div>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}
@@ -0,0 +1,75 @@
import type { PageServerLoad, Actions } from './$types';
import {
createApiClient,
optionalUser,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [profileRes, playlistsRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: params.id },
query: { pageNumber: 1, pageSize: PROFILE_PLAYLISTS_PAGE_SIZE, onlyPublic: true }
}
})
]);
const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404);
const playlistItems = playlistsRes.data?.items ?? [];
const tracksByPlaylist = await Promise.all(
playlistItems.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
})
.then((res) => Number(res.data?.totalItemCount ?? 0))
)
);
const playlists = playlistItems.map((playlist, i) => ({
...playlist,
trackCount: tracksByPlaylist[i]
}));
const isOwnProfile = viewer !== null && viewer.sub === params.id;
const isFollowing =
viewer && !isOwnProfile
? await api
.GET('/users/{id}/is-following', { params: { path: { id: params.id } } })
.then((res) => res.data ?? false)
.catch(() => false)
: false;
return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null };
};
export const actions: Actions = {
follow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.POST('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: true };
},
unfollow: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
await api.DELETE('/users/{id}/follow', { params: { path: { id: params.id } } });
return { following: false };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ArtistAvatar from '$lib/components/ui/ArtistAvatar.svelte';
let { data, form } = $props();
const profile = $derived(data.profile);
const playlists = $derived(data.playlists);
let following = $state(untrack(() => data.isFollowing));
let submitting = $state(false);
$effect(() => {
if (form && 'following' in form && typeof form.following === 'boolean')
following = form.following;
});
</script>
<svelte:head>
<title>{profile.name}</title>
<meta name="description" content="Perfil de {profile.name} en Musify." />
</svelte:head>
<Page>
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
<ArtistAvatar name={profile.name} imageUrl={profile.profilePictureUrl} size={144} />
<div class="min-w-0 flex-1">
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Perfil</p>
<h1
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
>
{profile.name}
</h1>
<p class="mt-3.25 text-[12.5px] text-fg-2">
{profile.followersCount}
{profile.followersCount === 1 ? 'seguidor' : 'seguidores'} · {profile.followingCount} siguiendo
</p>
</div>
</div>
{#if !data.isOwnProfile && !data.isAnonymous}
<div class="mt-6 flex items-center gap-2.5 sm:mt-7">
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
>
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
</div>
{/if}
<div class="mt-9">
<h2 class="font-display text-lg font-semibold tracking-[-0.02em] text-fg">
Playlists públicas
</h2>
{#if playlists.length > 0}
<MediaGrid min="210px" minMobile="180px" class="mt-6">
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
trackCount={playlist.trackCount}
trackIds={playlist.coverTrackIds}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
{:else}
<EmptyState
icon={ListMusic}
title="Sin playlists públicas"
description="Este usuario todavía no ha compartido ninguna playlist pública."
class="mt-6"
/>
{/if}
</div>
</Page>
+41 -44
View File
@@ -30,9 +30,9 @@
let audioInput = $state<HTMLInputElement>();
const steps = [
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
{ key: 'upload', label: 'Subir', hint: 'Portada y audio.' },
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming.' },
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
];
const busy = $derived(status === 'uploading');
@@ -47,14 +47,8 @@
return ['idle', 'idle', 'idle'];
});
const dropClass = $derived(
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
);
function stepClass(s: StepState) {
if (s === 'active') return 'border-accent/40 bg-accent/5';
if (s === 'done') return 'border-accent/40';
return '';
return s === 'idle' ? '' : 'bg-accent-tint';
}
function formatSize(bytes: number) {
@@ -112,37 +106,39 @@
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-8 grid gap-3 sm:mt-10 sm:grid-cols-3 sm:gap-4">
<ol class="mt-7 grid gap-3 sm:mt-8 sm:grid-cols-3 sm:gap-4">
{#each steps as step, i (step.key)}
<li>
<Surface class={stepClass(stepStates[i])}>
<Surface padding="sm" class={stepClass(stepStates[i])}>
<div class="flex items-center gap-3">
<span
class="grid h-8 w-8 place-items-center rounded-full bg-accent-soft text-sm font-bold text-on-accent"
class="grid h-7 w-7 shrink-0 place-items-center rounded-full text-xs font-bold {stepStates[
i
] === 'idle'
? 'bg-white/[3.5%] text-fg-2'
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="h-4 w-4" strokeWidth={2.5} />
<Check class="h-3.5 w-3.5" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
</span>
<span class="text-fg">{step.label}</span>
<span class="text-sm font-medium text-fg">{step.label}</span>
</div>
<p class="mt-3 text-base text-muted">{step.hint}</p>
<p class="mt-2.5 text-xs text-muted">{step.hint}</p>
</Surface>
</li>
{/each}
</ol>
{#if status === 'done'}
<Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
<span
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
>
<Check class="h-8 w-8" strokeWidth={2.5} />
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="h-7 w-7" strokeWidth={2.5} />
</span>
<h2 class="mt-6 text-2xl font-semibold tracking-tight text-fg">¡Subida!</h2>
<p class="mt-3 text-fg-2">
<h2 class="mt-6 font-display text-2xl font-semibold tracking-[-0.02em] text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
</p>
@@ -184,7 +180,9 @@
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="relative flex min-h-56 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-6 text-center transition sm:min-h-72 sm:p-10 {dropClass}"
class="relative flex min-h-48 flex-col items-center justify-center rounded-panel p-6 text-center transition sm:min-h-64 sm:p-10 {dragging
? 'bg-surface-2 ring-2 ring-accent/50'
: 'bg-surface'}"
>
<input
bind:this={audioInput}
@@ -192,33 +190,32 @@
name="audio"
accept="audio/*"
onchange={onAudioInput}
class="absolute inset-0 cursor-pointer opacity-0"
class="absolute inset-0 cursor-pointer opacity-0 focus:outline-none"
aria-label="Seleccionar archivo de audio"
/>
<span
class="grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent sm:h-20 sm:w-20"
>
{#if audioName === ''}
<Headphones class="h-8 w-8 sm:h-9 sm:w-9" />
{:else}
<Music class="h-8 w-8 sm:h-9 sm:w-9" />
{/if}
</span>
{#if audioName === ''}
<p class="mt-5 text-fg sm:text-lg">Arrastra tu audio aquí o haz clic para elegir</p>
<p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
<Headphones class="h-9 w-9 text-muted sm:h-10 sm:w-10" strokeWidth={1.4} />
{:else}
<p class="mt-5 max-w-full truncate text-fg sm:text-lg">
<Music class="h-9 w-9 text-accent-soft sm:h-10 sm:w-10" strokeWidth={1.4} />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir
</p>
<p class="mt-1.5 text-xs text-muted">MP3, FLAC, WAV…</p>
{:else}
<p class="mt-4 max-w-full truncate text-sm font-medium text-fg">
{audioName}
</p>
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
<p class="mt-1.5 text-xs text-muted">{formatSize(audioSize)} · Listo para subir</p>
{/if}
</div>
<div class="grid items-start gap-6 sm:grid-cols-[auto_1fr] sm:gap-8">
<Field label="Portada">
<label
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50 sm:h-44 sm:w-44"
class="relative grid h-36 w-36 cursor-pointer place-items-center overflow-hidden rounded-control transition sm:h-44 sm:w-44"
style={coverPreview === '' ? 'background:var(--mf-cover-grad)' : undefined}
>
<input
type="file"
@@ -231,7 +228,7 @@
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
<ImageIcon class="h-11 w-11 text-white/70" strokeWidth={1.3} />
{/if}
</label>
</Field>
@@ -263,12 +260,12 @@
</Checkbox>
{#if status === 'uploading'}
<Surface>
<div class="flex items-center justify-between text-base">
<Surface padding="sm">
<div class="flex items-center justify-between text-sm">
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-muted">un momento</span>
<span class="text-xs text-muted">un momento</span>
</div>
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-surface-2">
<div class="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
</div>
</Surface>