This commit is contained in:
2026-07-18 23:29:57 +02:00
parent 4fed9bd059
commit 2dc544b2c0
109 changed files with 5134 additions and 2163 deletions
-20
View File
@@ -9,8 +9,6 @@
"version": "0.0.1",
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
"@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3",
@@ -49,24 +47,6 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/manrope": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/manrope/-/manrope-5.2.8.tgz",
"integrity": "sha512-nc9lOuCRz73UHnovDE2bwXUdghE2SEOc7Aii0qGe3CLyE03W1a7VnY5Z6euRiapiKbCkGS+eXbY3s/kvWeGeSw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource-variable/sora": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/sora/-/sora-5.2.8.tgz",
"integrity": "sha512-sP+ILTfi5r3cqmMBntI4ooQNxFI+LyHyD5hcPbWwwL4uh4kKFAkOz0w2veCXjz18fKRdva6OG0dY+5yQ02f30w==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
-2
View File
@@ -26,8 +26,6 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@fontsource-variable/manrope": "^5.2.8",
"@fontsource-variable/sora": "^5.2.8",
"@lucide/svelte": "^1.23.0",
"@sveltejs/adapter-node": "^5.5.7",
"jose": "^6.2.3",
+15
View File
@@ -0,0 +1,15 @@
export function appendUnique<T>(
current: T[],
incoming: T[],
keyOf: (item: T) => string | number
): T[] {
const seen = new Set(current.map(keyOf));
const added: T[] = [];
for (const item of incoming) {
const key = keyOf(item);
if (seen.has(key)) continue;
seen.add(key);
added.push(item);
}
return added.length > 0 ? [...current, ...added] : current;
}
@@ -1,8 +0,0 @@
<footer class="border-t border-white/10 py-8">
<div
class="flex flex-col items-center justify-between gap-4 px-8 text-sm text-neutral-500 sm:flex-row"
>
<p>© {new Date().getFullYear()} Musify · Streaming de música.</p>
<p class="text-neutral-600">Tu música, siempre contigo.</p>
</div>
</footer>
-56
View File
@@ -1,56 +0,0 @@
<script lang="ts">
import type { SessionUser } from '$lib/types';
interface Props {
user: SessionUser | null;
}
let { user }: Props = $props();
</script>
<header class="sticky top-0 z-50 border-b border-white/10 bg-neutral-950/80 backdrop-blur">
<nav class="flex items-center justify-between gap-4 px-8 py-4">
<a href="/" class="text-lg tracking-tight text-neutral-100 transition hover:text-white">
Musify
</a>
<div class="flex items-center gap-6">
{#if user}
<a href="/explore" class="flex items-center gap-2 text-sm text-neutral-200">
{#if user.picture}
<img src={user.picture} alt="" class="h-7 w-7 rounded-full object-cover" />
{:else}
<span
class="grid h-7 w-7 place-items-center rounded-full bg-neutral-700 text-xs font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="hidden sm:inline">{user.name}</span>
</a>
<form method="POST" action="/logout">
<button
class="rounded-full border border-white/10 px-4 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Salir
</button>
</form>
{:else}
<a
href="/login"
data-sveltekit-reload
class="text-sm font-medium text-neutral-300 transition hover:text-white"
>
Iniciar sesión
</a>
<a
href="/login?mode=register"
data-sveltekit-reload
class="rounded-full bg-[var(--mf-accent)] px-4 py-2 text-sm font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110"
>
Registrarse
</a>
{/if}
</div>
</nav>
</header>
+20 -20
View File
@@ -3,8 +3,8 @@
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
import Library from '@lucide/svelte/icons/library-big';
import ListMusic from '@lucide/svelte/icons/list-music';
import Folder from '@lucide/svelte/icons/folder';
import Music from '@lucide/svelte/icons/music';
import Upload from '@lucide/svelte/icons/upload';
import LogOut from '@lucide/svelte/icons/log-out';
@@ -30,11 +30,11 @@
const mainLinks = [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: ListMusic }
{ href: '/playlists', label: 'Listas', icon: Music }
];
const secondaryLinks = [
{ href: '/library', label: 'Canciones subidas', icon: Library },
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
@@ -65,32 +65,32 @@
<a
href={link.href}
data-active={active}
class="relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 font-semibold transition-colors duration-100 {active
? 'text-[var(--mf-text)]'
: 'text-[var(--mf-text-2)] hover:bg-white/5 hover:text-[var(--mf-text)]'}"
class="relative flex items-center gap-4 rounded-control px-4 py-3 transition-colors duration-100 {active
? 'text-fg'
: 'text-fg-3 hover:bg-hover hover:text-fg'}"
>
<link.icon
class="h-[18px] w-[18px] flex-shrink-0"
strokeWidth={2}
style={active ? 'color:var(--mf-accent)' : ''}
class="h-6 w-6 shrink-0"
style={active ? 'color:var(--mf-accent-60)' : ''}
/>
{link.label}
</a>
{/snippet}
<aside
class="sticky top-0 flex h-screen w-[264px] flex-shrink-0 flex-col gap-7 border-r border-[var(--mf-border)] px-[18px] py-[26px]"
class="sticky top-0 flex h-screen flex-shrink-0 flex-col gap-7 border-r border-line px-[18px] py-[26px]"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="px-3 font-display text-xl font-extrabold tracking-tight">Musify</a>
<a href="/" class="px-3 text-xl font-extrabold tracking-wide">Musify</a>
<div class="relative flex flex-col gap-7" bind:this={navBox}>
<div
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-[10px] bg-white/10 {animate
class="pointer-events-none absolute inset-x-0 z-0 overflow-hidden rounded-control bg-surface-hover {animate
? 'transition-[top,height] duration-200 ease-[cubic-bezier(0.4,0,0.2,1)]'
: ''}"
style="top:{pillTop}px; height:{pillHeight}px; opacity:{pillVisible ? 1 : 0}"
>
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full bg-[var(--mf-accent)]"></span>
<span class="absolute inset-y-0 left-0 w-[3px] rounded-r-full" style="background:var(--mf-accent-60)"></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
@@ -99,7 +99,7 @@
{/each}
</nav>
<div class="mx-2 border-t border-[var(--mf-border)]"></div>
<div class="mx-2 border-t border-line"></div>
<nav class="relative z-10 flex flex-col gap-1">
{#each secondaryLinks as link (link.href)}
@@ -111,12 +111,12 @@
<div class="relative mt-auto" bind:this={menuRef}>
{#if menuOpen}
<div
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-xl border border-[var(--mf-border)] bg-[var(--mf-elevated)] py-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
class="absolute bottom-full left-0 mb-2 w-full overflow-hidden rounded-panel border border-line bg-elevated py-1.5 shadow-menu"
>
<form method="POST" action="/logout" data-sveltekit-reload>
<button
type="submit"
class="flex w-full items-center gap-3 px-3 py-2.5 text-sm font-semibold text-[var(--mf-text-2)] transition hover:bg-white/5"
class="flex w-full items-center gap-3 px-3 py-2.5 text-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir
@@ -127,18 +127,18 @@
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
class="flex w-full items-center gap-2.5 rounded-xl px-3 py-2.5 text-left transition hover:bg-white/5"
class="flex w-full items-center gap-2.5 rounded-control px-3 py-2.5 text-left transition hover:bg-hover"
>
{#if user.picture}
<img src={user.picture} alt="" class="h-9 w-9 rounded-full object-cover" />
{:else}
<span
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-[var(--mf-surface-2)] text-xs font-semibold uppercase"
class="grid h-9 w-9 flex-shrink-0 place-items-center rounded-full bg-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate text-[13px] font-semibold leading-[1.35]">{user.name}</span>
<span class="truncate text-base font-semibold leading-[1.35]">{user.name}</span>
</button>
</div>
</aside>
@@ -0,0 +1,129 @@
<script lang="ts" module>
import type { ApiTrackLike } from '$lib/player/player.svelte';
import type { YouTubeSong } from '$lib/types';
export type ContextMenuTarget =
| { kind: 'youtube'; song: YouTubeSong }
| { kind: 'local'; track: ApiTrackLike };
export type ContextMenuState = ContextMenuTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
export function contextMenuStateFor(
event: MouseEvent,
target: ContextMenuTarget
): ContextMenuState {
event.preventDefault();
return {
...target,
x: Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
menu: ContextMenuState;
playlists: { id: string; name: string }[];
onClose: () => void;
onAddToQueue: () => void;
}
let { menu, playlists, onClose, onAddToQueue }: Props = $props();
function onWindowKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
<div
class="fixed inset-0 z-30"
role="presentation"
onclick={onClose}
oncontextmenu={(e) => {
e.preventDefault();
onClose();
}}
></div>
<div
class="fixed z-40 w-60 rounded-panel border border-line bg-elevated p-1.5 shadow-menu"
style="left:{menu.x}px; top:{menu.y}px;"
>
<button
type="button"
onclick={onAddToQueue}
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm text-fg-2 transition hover:bg-hover"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Reproducir a continuación</span>
</button>
{#if playlists.length > 0}
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-control px-2 py-2.5 text-sm text-fg-2 transition group-hover/addmenu:bg-hover"
>
<span class="flex-1 cursor-default text-base">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-fg-3" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {menu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div
class="w-64 overflow-hidden rounded-panel border border-line bg-elevated p-2 shadow-menu"
>
<div class="max-h-80 overflow-y-auto">
{#each playlists as playlist (playlist.id)}
<form
method="POST"
action={menu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
onClose();
return update({ reset: false, invalidateAll: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if menu.kind === 'youtube'}
<input type="hidden" name="videoId" value={menu.song.videoId} />
<input type="hidden" name="title" value={menu.song.title} />
<input type="hidden" name="artist" value={menu.song.artist} />
<input type="hidden" name="durationSeconds" value={menu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={menu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={menu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-base text-fg transition hover:bg-hover"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-control"
/>
<span class="truncate text-base text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlayPauseOverlay from '$lib/components/ui/PlayPauseOverlay.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
interface Props {
id: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: 'small' | 'medium' | 'large';
hue: number;
explicit?: boolean;
active: boolean;
playing: boolean;
index: number;
onClick: () => void;
onContextMenu?: (event: MouseEvent) => void;
badge?: Snippet;
}
let {
id,
title,
artist,
coverSrc,
coverSize = 'large',
hue,
explicit = false,
active,
playing,
index,
onClick,
onContextMenu,
badge
}: Props = $props();
</script>
<li
class="group animate-enter relative min-w-0"
style="animation-delay:{index * 40}ms"
oncontextmenu={onContextMenu}
>
<button
type="button"
onclick={onClick}
aria-label="{active && playing ? 'Pausar' : 'Reproducir'} {title}"
class="block w-full text-left"
>
<Cover
trackId={id}
src={coverSrc}
{hue}
size={coverSize}
alt={title}
class="aspect-square w-full rounded-art shadow-art"
>
<PlayPauseOverlay {active} {playing} />
</Cover>
<div class="mt-2.5 flex min-w-0 items-center gap-1.5">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg" title={title}>{title}</span>
</div>
{#if badge || artist}
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{@render badge?.()}
{#if artist}
<span class="truncate text-sm text-fg-3" title={artist}>{artist}</span>
{/if}
</div>
{/if}
</button>
</li>
@@ -6,7 +6,8 @@
</script>
<div
class="animate-enter fixed bottom-0 left-[264px] right-0 z-50 h-[92px] border-t border-[var(--mf-border)] bg-[var(--mf-bg)]"
class="animate-enter fixed bottom-0 right-0 z-50 h-[92px] border-t border-line bg-bg"
style="left:var(--mf-sidebar-w)"
>
<div
class="grid h-full items-center gap-5 px-8"
@@ -8,18 +8,18 @@
}
</script>
<div class="flex w-full max-w-[900px] items-center gap-[9px]">
<span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]">
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs tabular-nums text-muted">
{fmtTime(player.progress)}
</span>
<button
type="button"
onclick={seek}
aria-label="Barra de progreso"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-[var(--mf-track)] transition-[height] hover:h-2"
class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-track transition-[height] hover:h-2"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-[var(--mf-accent)] group-hover:brightness-110"
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%"
></div>
<span
@@ -27,7 +27,7 @@
style="left:{player.progressPercent}%"
></span>
</button>
<span class="w-8 text-[11px] tabular-nums text-[var(--mf-text-4)]">
<span class="text-xs tabular-nums text-(--mf-text-4)">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -1,6 +1,7 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
</script>
<div class="flex min-w-0 items-center gap-3">
@@ -11,18 +12,21 @@
hue={player.current.hue}
size="small"
alt={player.current.title}
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
/>
{:else}
<div
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
style="background:{player.artGradient}"
></div>
{/if}
<div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]">
{player.current.title}
<div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{player.current.title}</span>
</div>
<div class="truncate text-xs text-[var(--mf-text-3)]">{player.current.artist || '—'}</div>
<div class="truncate text-sm text-muted">{player.current.artist || '—'}</div>
</div>
</div>
@@ -7,7 +7,7 @@
type="button"
onclick={() => player.previous()}
aria-label="Anterior"
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<rect x="0" y="0" width="2.5" height="14" rx="1" />
@@ -19,7 +19,7 @@
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95"
class="grid h-[36px] w-[36px] place-items-center rounded-full bg-fg text-bg shadow-lg transition duration-100 active:scale-95"
>
{#if player.playing}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
@@ -37,7 +37,7 @@
type="button"
onclick={() => player.next()}
aria-label="Siguiente"
class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
class="flex text-fg opacity-70 transition hover:opacity-100 active:scale-90"
>
<svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M1 0 11 7 1 14z" />
@@ -17,7 +17,7 @@
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)"
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'border-line bg-surface text-fg-2',
danger: 'border-danger/40 bg-danger/10 text-danger-fg'
};
</script>
<p class="rounded-control border px-4 py-3 text-base {tones[tone]} {klass}">
{@render children()}
</p>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
tone?: 'neutral' | 'accent' | 'danger';
class?: string;
children: Snippet;
}
let { tone = 'neutral', class: klass = '', children }: Props = $props();
const tones = {
neutral: 'bg-surface-2 text-fg',
accent: 'bg-accent/20 text-accent',
danger: 'bg-danger/10 text-danger-fg'
};
</script>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-sm leading-none font-semibold {tones[tone]} {klass}"
>
{@render children()}
</span>
@@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
variant?: 'primary' | 'secondary' | 'subtle' | 'danger';
size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit';
href?: string;
reload?: boolean;
disabled?: boolean;
loading?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
variant = 'primary',
size = 'md',
type = 'button',
href,
reload = false,
disabled = false,
loading = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = {
sm: 'px-4 py-2 text-base',
md: 'px-5 py-2.5 text-base',
lg: 'px-8 py-3.5 text-base'
};
const variants = {
primary: 'bg-accent-soft font-semibold text-on-accent active:scale-[0.97]',
secondary: 'border border-line text-fg hover:bg-hover',
subtle: 'bg-surface-2 font-semibold text-fg hover:bg-surface-hover',
danger: 'bg-danger font-semibold text-white hover:brightness-110'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-control transition disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
);
</script>
{#if href}
<a {href} data-sveltekit-reload={reload ? '' : undefined} class={classes}>
{@render children()}
</a>
{:else}
<button {type} disabled={disabled || loading} {onclick} class={classes}>
{#if loading}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
{@render children()}
</button>
{/if}
@@ -0,0 +1,29 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Check from '@lucide/svelte/icons/check';
interface Props {
checked?: boolean;
name?: string;
class?: string;
children: Snippet;
}
let { checked = $bindable(false), name, class: klass = '', children }: Props = $props();
</script>
<label class="flex items-center gap-3 text-base text-fg-2 {klass}">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
{name}
bind:checked
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-line bg-surface transition checked:border-accent-soft checked:bg-accent-soft focus-visible:border-accent/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-on-accent opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span class="cursor-pointer">{@render children()}</span>
</label>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import type { LucideIcon } from '@lucide/svelte';
interface Props {
icon?: LucideIcon;
title?: string;
description?: string;
class?: string;
actions?: Snippet;
}
let { icon: Icon, title, description, class: klass = '', actions }: Props = $props();
</script>
<div class="flex flex-col items-center px-6 py-14 text-center {klass}">
{#if Icon}
<span class="grid h-14 w-14 place-items-center rounded-full bg-accent-soft text-on-accent">
<Icon class="h-6 w-6" />
</span>
{/if}
{#if title}
<h3 class="mt-5 text-2xl font-bold tracking-tight text-fg">{title}</h3>
{/if}
{#if description}
<p class="mt-2 text-fg-2">{description}</p>
{/if}
{#if actions}
<div class="mt-6 flex items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: klass = '' }: Props = $props();
</script>
<span
class="grid h-4 w-4 shrink-0 place-items-center rounded-sm bg-fg-3 text-[11px] leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito"
>E</span>
@@ -0,0 +1,27 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
for?: string;
optional?: boolean;
class?: string;
hint?: Snippet;
children: Snippet;
}
let { label, for: forId, optional = false, class: klass = '', hint, children }: Props = $props();
</script>
<div class="flex min-w-0 flex-col {klass}">
<label for={forId} class="mb-2 block text-base font-medium text-fg-2">
{label}
{#if optional}<span class="text-muted">(Opcional)</span>{/if}
</label>
{@render children()}
{#if hint}
<div class="mt-2 flex items-center justify-between gap-3 text-sm text-muted">
{@render hint()}
</div>
{/if}
</div>
@@ -0,0 +1,37 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
label: string;
type?: 'button' | 'submit';
size?: 'sm' | 'md';
revealOnHover?: boolean;
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
}
let {
label,
type = 'button',
size = 'md',
revealOnHover = false,
onclick,
class: klass = '',
children
}: Props = $props();
const sizes = { sm: 'h-7 w-7', md: 'h-8 w-8' };
</script>
<button
{type}
{onclick}
aria-label={label}
title={label}
class="grid place-items-center rounded-control text-muted transition hover:bg-hover hover:text-fg {sizes[
size
]} {revealOnHover ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : ''} {klass}"
>
{@render children()}
</button>
@@ -0,0 +1,47 @@
<script lang="ts">
import ImageIcon from '@lucide/svelte/icons/image';
interface Props {
name: string;
fallbackUrl?: string;
class?: string;
}
let {
name,
fallbackUrl,
class: klass = 'h-52 w-52 rounded-control border border-line bg-surface hover:border-accent/50'
}: Props = $props();
let preview = $state('');
let fallbackFailed = $state(false);
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) preview = URL.createObjectURL(file);
}
</script>
<label class="relative grid cursor-pointer place-items-center overflow-hidden transition-colors {klass}">
<input
type="file"
{name}
accept="image/*"
onchange={onInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if preview !== ''}
<img src={preview} alt="Portada" class="h-full w-full object-cover" />
{:else if fallbackUrl && !fallbackFailed}
<img
src={fallbackUrl}
alt="Portada actual"
loading="lazy"
onerror={() => (fallbackFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-muted" />
{/if}
</label>
@@ -0,0 +1,38 @@
<script lang="ts">
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props {
onLoadMore: () => void;
hasMore: boolean;
loading: boolean;
rootMargin?: string;
}
let { onLoadMore, hasMore, loading, rootMargin = '600px' }: Props = $props();
let sentinel = $state<HTMLDivElement>();
let visible = $state(false);
$effect(() => {
const node = sentinel;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
visible = entries[0].isIntersecting;
},
{ rootMargin }
);
observer.observe(node);
return () => observer.disconnect();
});
$effect(() => {
if (visible && hasMore && !loading) onLoadMore();
});
</script>
<div bind:this={sentinel} class="flex min-h-12 items-center justify-center py-8">
{#if loading}
<LoaderCircle class="h-5 w-5 animate-spin text-muted" />
{/if}
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { LucideIcon } from '@lucide/svelte';
interface Props {
value?: string;
id?: string;
name?: string;
type?: 'text' | 'search';
placeholder?: string;
maxlength?: number;
required?: boolean;
autocomplete?: 'on' | 'off';
icon?: LucideIcon;
oninput?: (event: Event) => void;
class?: string;
}
let {
value = $bindable(''),
id,
name,
type = 'text',
placeholder,
maxlength,
required = false,
autocomplete = 'off',
icon: Icon,
oninput,
class: klass = ''
}: Props = $props();
</script>
<div class="relative w-full">
{#if Icon}
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-muted">
<Icon class="h-4 w-4" />
</span>
{/if}
<input
{id}
{name}
{type}
{placeholder}
{maxlength}
{required}
{autocomplete}
{oninput}
bind:value
class="w-full rounded-control border border-line bg-surface py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {Icon
? 'pr-4 pl-11'
: 'px-4'} {klass}"
/>
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
as?: 'ul' | 'div';
min?: string;
class?: string;
children: Snippet;
}
let { as = 'div', min = '180px', class: klass = '', children }: Props = $props();
</script>
<svelte:element
this={as}
class="grid gap-4 {klass}"
style="grid-template-columns:repeat(auto-fill,minmax({min},1fr))"
>
{@render children()}
</svelte:element>
@@ -0,0 +1,94 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import X from '@lucide/svelte/icons/x';
import IconButton from './IconButton.svelte';
interface Props {
open: boolean;
onClose: () => void;
title?: string;
maxWidth?: string;
panelClass?: string;
children: Snippet;
}
let {
open,
onClose,
title,
maxWidth = 'max-w-2xl',
panelClass = 'border border-line bg-elevated',
children
}: Props = $props();
let panel: HTMLDivElement | undefined = $state();
$effect(() => {
if (!open) return;
const trigger = document.activeElement as HTMLElement | null;
panel?.focus();
return () => trigger?.focus();
});
function onBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) onClose();
}
function focusableElements(): HTMLElement[] {
if (!panel) return [];
return Array.from(
panel.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
)
);
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) return;
const first = elements[0];
const last = elements[elements.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={onWindowKeydown} />
{#if open}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="presentation"
onclick={onBackdropClick}
>
<div
bind:this={panel}
class="animate-pop w-full {maxWidth} rounded-panel p-6 shadow-2xl outline-none {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
tabindex="-1"
>
{#if title}
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight text-fg">{title}</h2>
<IconButton label="Cerrar" onclick={onClose}>
<X class="h-4 w-4" />
</IconButton>
</div>
{/if}
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<section class="page-x pt-11 pb-10 {klass}">
{@render children()}
</section>
@@ -0,0 +1,24 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
class?: string;
actions?: Snippet;
}
let { title, subtitle, class: klass = '', actions }: Props = $props();
</script>
<div class="flex items-end justify-between gap-4 {klass}">
<div class="min-w-0">
<h1 class="text-3xl font-bold tracking-tight text-fg sm:text-4xl">{title}</h1>
{#if subtitle}
<p class="mt-2 text-fg-2">{subtitle}</p>
{/if}
</div>
{#if actions}
<div class="flex shrink-0 items-center gap-3">{@render actions()}</div>
{/if}
</div>
@@ -0,0 +1,23 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
interface Props {
active: boolean;
playing: boolean;
}
let { active, playing }: Props = $props();
</script>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-accent-soft text-on-accent opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={active}
class:!translate-y-0={active}
>
{#if active && playing}
<Pause class="h-5 w-5" fill="currentColor" />
{:else}
<Play class="h-5 w-5" fill="currentColor" />
{/if}
</span>
@@ -1,23 +1,17 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, HUES } from '$lib/theme/color';
import { gradientForHue, hueFor } from '$lib/theme/color';
interface Props {
playlistId?: string;
trackIds: (string | number)[];
hue: number;
size?: 'small' | 'medium' | 'large';
version?: string;
class?: string;
}
let { playlistId, trackIds, hue, size = 'medium', class: klass = '' }: Props = $props();
function hueFor(id: string | number) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
let { playlistId, trackIds, hue, size = 'medium', version, class: klass = '' }: Props = $props();
let coverFailed = $state(false);
@@ -28,7 +22,7 @@
{#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img
src="/api/playlists/{playlistId}/cover?size={size}"
src="/api/playlists/{playlistId}/cover?size={size}{version ? `&v=${encodeURIComponent(version)}` : ''}"
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
@@ -0,0 +1,33 @@
<script lang="ts">
import PlaylistArt from './PlaylistArt.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
index?: number;
}
let { id, name, description, trackIds, updatedAt, index = 0 }: Props = $props();
</script>
<a
href="/playlists/{id}"
class="group animate-enter min-w-0 text-left"
style="animation-delay:{index * 45}ms"
>
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
version={updatedAt}
class="aspect-square w-full rounded-art shadow-art transition group-hover:shadow-art-lg"
/>
<div class="mt-2.5 truncate text-fg">{name}</div>
{#if description}
<div class="mt-0.5 truncate text-sm text-fg-3">{description}</div>
{/if}
</a>
@@ -0,0 +1,95 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ImageDropzone from './ImageDropzone.svelte';
import Button from './Button.svelte';
import Field from './Field.svelte';
import Input from './Input.svelte';
import Textarea from './Textarea.svelte';
import Alert from './Alert.svelte';
interface Props {
action: string;
initialName?: string;
initialDescription?: string;
namePlaceholder?: string;
coverFallbackUrl?: string;
formMessage?: string;
submitLabel: string;
submittingLabel: string;
onCancel: () => void;
onSuccess?: () => void;
}
let {
action,
initialName = '',
initialDescription = '',
namePlaceholder = '',
coverFallbackUrl,
formMessage,
submitLabel,
submittingLabel,
onCancel,
onSuccess
}: Props = $props();
let name = $state(initialName);
let description = $state(initialDescription);
let submitting = $state(false);
</script>
<form
method="POST"
{action}
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update, result }) => {
await update({ reset: false });
submitting = false;
if (result.type === 'success') onSuccess?.();
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<Field label="Portada" optional>
<ImageDropzone name="cover" fallbackUrl={coverFallbackUrl} />
</Field>
<div class="flex flex-1 flex-col gap-4">
<Field label="Nombre" for="playlist-name">
<Input
id="playlist-name"
name="name"
maxlength={100}
required
bind:value={name}
placeholder={namePlaceholder}
/>
</Field>
<Field label="Descripción" for="playlist-description" optional class="min-h-0 flex-1">
<Textarea
id="playlist-description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="flex-1"
/>
</Field>
</div>
</div>
{#if formMessage}
<Alert tone="danger">{formMessage}</Alert>
{/if}
<div class="flex items-center justify-end gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || name.trim() === ''}>
{submitting ? submittingLabel : submitLabel}
</Button>
</div>
</form>
@@ -0,0 +1,10 @@
<script lang="ts">
interface Props {
title: string;
class?: string;
}
let { title, class: klass = '' }: Props = $props();
</script>
<h2 class="mb-6 text-2xl font-bold text-fg {klass}">{title}</h2>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
padding?: 'sm' | 'md' | 'lg';
class?: string;
children: Snippet;
}
let { padding = 'md', class: klass = '', children }: Props = $props();
const paddings = { sm: 'p-4', md: 'p-6', lg: 'p-12' };
</script>
<div class="rounded-panel border border-line bg-surface transition {paddings[padding]} {klass}">
{@render children()}
</div>
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
value?: string;
id?: string;
name?: string;
placeholder?: string;
maxlength?: number;
class?: string;
}
let {
value = $bindable(''),
id,
name,
placeholder,
maxlength,
class: klass = ''
}: Props = $props();
</script>
<textarea
{id}
{name}
{placeholder}
{maxlength}
bind:value
class="w-full min-h-0 resize-none rounded-control border border-line bg-surface px-4 py-3 text-base text-fg transition placeholder:text-muted focus:border-accent/50 focus:outline-none {klass}"
></textarea>
@@ -0,0 +1,27 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import NowPlaying from './NowPlaying.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-muted"
>
{#if active}
<NowPlaying paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,14 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-sm tabular-nums text-muted {klass}">
{@render children()}
</span>
@@ -0,0 +1,24 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid items-center gap-8 rounded-control px-3 py-2.5 transition hover:bg-surface {klass}"
style="grid-template-columns:{table.columns}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -0,0 +1,39 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
}
</script>
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
columns: string;
class?: string;
headers: Snippet;
children: Snippet;
}
let { columns, class: klass = '', headers, children }: Props = $props();
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
}
});
</script>
<div class={klass}>
<div
class="grid items-center gap-8 border-b border-line px-3 pb-2 text-sm font-medium tracking-wide text-muted uppercase"
style="grid-template-columns:{columns}"
>
{@render headers()}
</div>
<div class="mt-1 flex flex-col">
{@render children()}
</div>
</div>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Cover from './Cover.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
trackId: string | number;
title: string;
artist?: string;
coverSrc?: string;
coverSize?: string;
hue: number;
explicit?: boolean;
titleClass?: string;
onClick: () => void;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
title,
artist,
coverSrc,
coverSize = 'h-[42px] w-[42px]',
hue,
explicit = false,
titleClass = '',
onClick,
overlay,
badge
}: Props = $props();
</script>
<button type="button" onclick={onClick} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
trackId={trackId}
src={coverSrc}
{hue}
size="small"
alt={title}
class="{coverSize} flex-shrink-0 rounded-control"
>
{@render overlay?.()}
</Cover>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="min-w-0 truncate text-fg {titleClass}">{title}</span>
{@render badge?.()}
</div>
{#if artist}
<div class="truncate text-left text-sm text-fg-3">{artist}</div>
{/if}
</div>
</button>
+3
View File
@@ -0,0 +1,3 @@
export const EXPLORE_PAGE_SIZE = 27;
export const HOME_LATEST_PAGE_SIZE = 12;
export const YOUTUBE_FILLER_LIMIT = 27;
+15 -11
View File
@@ -1,5 +1,5 @@
import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube';
@@ -12,6 +12,7 @@ export interface PlayerTrack {
hue: number;
source: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface Playlist {
@@ -26,6 +27,7 @@ export interface QueueItem {
artist?: string;
source?: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface ApiTrackLike {
@@ -35,6 +37,7 @@ export interface ApiTrackLike {
source?: 'Local' | 'YouTube';
externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
}
export function isYouTubeTrack(track: ApiTrackLike): boolean {
@@ -65,7 +68,8 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
? isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small`
: undefined
: undefined,
explicit: track.isExplicit
};
});
}
@@ -74,13 +78,6 @@ const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0], source: 'local' };
function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
@@ -88,6 +85,7 @@ function toTrack(item: QueueItem): PlayerTrack {
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id),
explicit: item.explicit,
source: item.source ?? 'local',
coverUrl: item.coverUrl
};
@@ -99,7 +97,7 @@ class PlayerState {
playing = $state(false);
progress = $state(0);
volume = $state(
browser ? Number(localStorage.getItem("player.volume") ?? 100) : 100
browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100
);
loading = $state(false);
error = $state('');
@@ -333,6 +331,12 @@ class PlayerState {
this.#loadCurrent();
}
playOrToggle(list: QueueItem[], index: number) {
if (index < 0 || index >= list.length) return;
if (this.currentId === list[index].id) this.toggle();
else this.playQueue(list, index);
}
addToQueue(item: QueueItem) {
if (this.currentId === null) {
this.playQueue([item], 0);
@@ -404,7 +408,7 @@ class PlayerState {
const audio = this.#audioEl();
if (audio) {
audio.volume = this.volume / 100;
localStorage.setItem("player.volume", String(this.volume));
localStorage.setItem('player.volume', String(this.volume));
}
}
}
+46
View File
@@ -1,4 +1,5 @@
import createClient from 'openapi-fetch';
import { error, fail, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import { apiConfig } from '$lib/server/config';
@@ -14,3 +15,48 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
});
}
export function requireAccessToken(locals: App.Locals, message = 'Inicia sesión.'): string {
if (!locals.accessToken) error(401, message);
return locals.accessToken;
}
export function requireAccessTokenAction(
locals: App.Locals,
message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message });
}
export function unwrapOrError<T>(
result: { data?: T; error?: unknown; response?: Response },
message: string,
status?: number
): T {
if (result.error || result.data === undefined) {
error(status ?? result.response?.status ?? 502, message);
}
return result.data;
}
export function unwrapOrFail(
result: { error?: unknown },
message: string
): ActionFailure<{ message: string }> | undefined {
return result.error ? fail(502, { message }) : undefined;
}
export async function playlistCoverTrackIds(
api: ReturnType<typeof createApiClient>,
playlistIds: string[]
): Promise<Record<string, string[]>> {
const covers = await Promise.all(
playlistIds.map(async (playlistId) => {
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId }, query: { pageNumber: 1, pageSize: 4 } }
});
return [playlistId, tracks?.items.map((t) => t.id) ?? []] as const;
})
);
return Object.fromEntries(covers);
}
+26
View File
@@ -0,0 +1,26 @@
import { apiConfig } from '$lib/server/config';
const ALLOWED_SIZES = new Set(['small', 'medium', 'large']);
export async function proxyCoverImage(
fetch: typeof globalThis.fetch,
kind: 'playlists' | 'tracks',
id: string,
size: string | null
): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
}
@@ -0,0 +1,66 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId } }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
}
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
}
return { added: true, trackId, playlistId };
}
export async function addYouTubeToPlaylistAction({
request,
params,
locals,
fetch
}: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const videoId = String(form.get('videoId') ?? '');
const title = String(form.get('title') ?? '');
const artist = String(form.get('artist') ?? '');
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
if (!playlistId || !videoId || !title) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
}
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
}
return { added: true, videoId, playlistId };
}
+63
View File
@@ -4,6 +4,33 @@ import { env } from '$env/dynamic/private';
const CONNECT_HOST = env.S3_UPLOAD_CONNECT_HOST || '127.0.0.1';
export const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
export const AUDIO_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wav: 'audio/wav',
ogg: 'audio/ogg',
opus: 'audio/opus'
};
export function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
export function contentTypeOf(file: File, table: Record<string, string> = IMAGE_TYPES): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export async function putPresigned(
presignedUrl: string,
body: Buffer,
@@ -49,3 +76,39 @@ export async function putPresigned(
request.end();
});
}
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage(
reserve: (args: {
fileType: string;
contentType: string;
expectedSizeBytes: number;
}) => Promise<{
data?: { uploadUrl: string; contentType: string; intentId: string } | null;
error?: unknown;
}>,
file: File
): Promise<UploadImageResult> {
const { data: reserved, error: reserveError } = await reserve({
fileType: extOf(file.name) || 'jpg',
contentType: contentTypeOf(file),
expectedSizeBytes: file.size
});
if (reserveError || !reserved) {
return { failMessage: 'No se pudo reservar la subida de la portada.' };
}
try {
const buffer = await file.arrayBuffer();
await putPresigned(reserved.uploadUrl, Buffer.from(buffer), reserved.contentType);
} catch (err) {
return {
failMessage: 'Falló la subida de la portada al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
};
}
return { intentId: reserved.intentId };
}
+54
View File
@@ -0,0 +1,54 @@
import type { createApiClient } from '$lib/server/api';
import type { YouTubeSong } from '$lib/types';
export const YOUTUBE_FILLER_QUERIES = [
'pop',
'rock',
'reggaeton',
'lo-fi',
'indie',
'salsa',
'k-pop',
'electrónica',
'jazz',
'baladas',
'trap',
'cumbia',
'hip hop',
'música clásica'
];
export interface YoutubeFiller {
query: string;
items: YouTubeSong[];
continuationToken: string;
}
export function pickRandomFillerQuery(): string {
return YOUTUBE_FILLER_QUERIES[Math.floor(Math.random() * YOUTUBE_FILLER_QUERIES.length)];
}
export function shuffle<T>(items: T[]): T[] {
const copy = [...items];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
export async function fetchYoutubeFiller(
api: ReturnType<typeof createApiClient>,
accessToken: string | null | undefined,
limit: number,
seed?: string
): Promise<YoutubeFiller> {
if (!accessToken) return { query: '', items: [], continuationToken: '' };
const query = seed?.trim() || pickRandomFillerQuery();
const { data } = await api.GET('/youtube/search', { params: { query: { query } } });
return {
query,
items: ((data?.items ?? []) as YouTubeSong[]).slice(0, limit),
continuationToken: data?.continuationToken ?? ''
};
}
+18 -1
View File
@@ -1,7 +1,18 @@
export const HUES = [145, 330, 40, 152, 300, 200, 10, 120, 280];
export const ACCENT_LIGHTNESS = 60;
export const ACCENT_CHROMA = 0.17;
export const ACCENT_HUE = 145;
export function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
export function accentForHue(hue: number): string {
return `oklch(72% 0.17 ${hue})`;
return `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
}
export function accentSoft(hue: number, pct: number): string {
@@ -18,3 +29,9 @@ export function fmtTime(seconds: number): string {
const rest = total % 60;
return `${minutes}:${rest < 10 ? '0' : ''}${rest}`;
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
export function fmtDate(value: string | number | Date): string {
return dateFormatter.format(new Date(value));
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
@@ -107,7 +108,7 @@ export async function extractAccent(url: string): Promise<Accent | null> {
const h2 = ((hue + 25) % 360).toFixed(1);
return {
accent: `oklch(72% ${c} ${h})`,
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
+9 -9
View File
@@ -1,15 +1,13 @@
/* Paleta de Musify — cambia los colores aquí y afecta a toda la app.
El acento (--mf-accent) es dinámico: lo fija el player según la pista sonando. */
:root {
--mf-bg: #08080a;
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-text: #f5f4f2;
--mf-text-2: rgba(245, 244, 242, 0.7);
--mf-text-3: rgba(245, 244, 242, 0.5);
--mf-text-4: rgba(245, 244, 242, 0.4);
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-surface: rgba(255, 255, 255, 0.045);
--mf-surface: rgb(18, 18, 18);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
@@ -17,6 +15,8 @@
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: rgba(255, 255, 255, 0.12);
--mf-accent: oklch(72% 0.17 145);
--mf-accent: oklch(60% 0.17 145);
--mf-accent-60: color-mix(in oklch, var(--mf-accent) 80%, transparent);
--mf-radius: 12px;
--mf-sidebar-w: 264px;
}
+10
View File
@@ -4,3 +4,13 @@ export interface SessionUser {
email: string;
picture: string;
}
export interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}
@@ -1,8 +1,8 @@
import type { PageServerLoad, Actions } from './$types';
import { error, fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
const PAGE_SIZE = 27;
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { fetchYoutubeFiller } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { EXPLORE_PAGE_SIZE as PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? '';
@@ -31,75 +31,22 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
playlistsPromise
]);
if (tracksRes.error || !tracksRes.data) {
error(502, 'No se pudieron cargar las canciones.');
}
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
return {
query,
tracks: tracksRes.data,
tracks,
ytResults: searchRes?.data ?? null,
ytError: Boolean(searchRes?.error),
youtubeFiller: query
? Promise.resolve(null)
: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
playlists: playlistsRes?.data?.items ?? [],
needsAuth: !locals.user
};
};
export const actions: Actions = {
addTrack: async ({ request, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId, trackId } }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
}
return fail(502, { message: 'No se pudo añadir la canción.', trackId });
}
return { added: true, trackId, playlistId };
},
addYouTubeToPlaylist: async ({ request, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '');
const videoId = String(form.get('videoId') ?? '');
const title = String(form.get('title') ?? '');
const artist = String(form.get('artist') ?? '');
const durationSeconds = Number(form.get('durationSeconds') ?? 0);
const thumbnailUrl = String(form.get('thumbnailUrl') ?? '');
if (!playlistId || !videoId || !title) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/youtube-tracks', {
params: { path: { playlistId } },
body: { videoId, title, artist, durationSeconds, thumbnailUrl }
});
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', videoId });
}
return fail(502, { message: 'No se pudo añadir la canción.', videoId });
}
return { added: true, videoId, playlistId };
}
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
+192 -389
View File
@@ -1,36 +1,25 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
import { goto } from '$app/navigation';
import { enhance } from '$app/forms';
import {
player,
queueIdForTrack,
toQueueItems,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import { HUES } from '$lib/theme/color';
interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}
type ContextMenuTarget =
| { kind: 'youtube'; song: YouTubeSong }
| { kind: 'local'; track: ApiTrackLike };
import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
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 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 { EXPLORE_PAGE_SIZE } from '$lib/config';
import { hueFor } from '$lib/theme/color';
import type { YouTubeSong } from '$lib/types';
import TrackTile from '$lib/components/TrackTile.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuTarget,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { appendUnique } from '$lib/collections.js';
let { data, form } = $props();
@@ -39,98 +28,80 @@
let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytLoadingMore = $state(false);
let contextMenu = $state<(ContextMenuTarget & { x: number; y: number; openLeft: boolean }) | null>(
null
);
let ytSearchTerm = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
type LocalTrack = (typeof data.tracks.items)[number];
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let loadingMore = $state(false);
$effect(() => {
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
ytContinuation = data.ytResults?.continuationToken ?? '';
contextMenu = null;
});
localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
const tracks = $derived(data.tracks);
const currentPage = $derived(Number(data.tracks.pageNumber));
const total = $derived(Number(data.tracks.totalItemCount));
const localVisible = $derived(!data.query || tracks.items.length > 0);
const youtubeVisible = $derived(
!!data.query && (data.needsAuth || data.ytError || ytItems.length > 0)
);
const localHeadingVisible = $derived(!!data.query && localVisible);
const youtubeHeadingVisible = $derived(!!data.query && youtubeVisible);
const nothingFound = $derived(
!!data.query && !localVisible && !youtubeVisible && !data.needsAuth && !data.ytError
);
function buildHref(page = 1, query = data.query) {
const params = new URLSearchParams();
if (query) params.set('q', query);
if (page > 1) params.set('page', String(page));
const qs = params.toString();
return qs ? `/explore?${qs}` : '/explore';
}
function onSearchInput(event: Event) {
const value = (event.currentTarget as HTMLInputElement).value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
goto(buildHref(1, value.trim()), {
keepFocus: true,
replaceState: true,
noScroll: true
});
}, 350);
}
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function togglePlayLocal(index: number) {
const track = data.tracks.items[index];
if (player.current?.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(data.tracks.items), index);
}
function togglePlayYouTube(index: number) {
const song = ytItems[index];
if (player.current?.id === song.videoId) {
player.toggle();
if (data.query) {
ytItems = appendUnique(
[],
(data.ytResults?.items ?? []) as YouTubeSong[],
item => item.videoId
);
ytContinuation = data.ytResults?.continuationToken ?? '';
ytSearchTerm = data.query;
return;
}
player.playQueue(
ytItems.map((s) => ({
id: s.videoId,
title: s.title,
artist: s.artist,
source: 'youtube' as const,
coverUrl: s.thumbnailUrl
})),
index
ytItems = [];
ytContinuation = '';
ytSearchTerm = '';
data.youtubeFiller?.then((filler) => {
if (!filler) return;
ytItems = appendUnique(
[],
filler.items,
item => item.videoId
);
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
});
});
const items = $derived<ContextMenuTarget[]>([
...localItems.map((track) => ({ kind: 'local' as const, track })),
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMore = $derived(localHasNext || ytContinuation !== '');
const nothingFound = $derived(
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
);
function itemId(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
}
function itemTitle(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.title : item.track.title;
}
function itemArtist(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
}
function itemExplicit(item: ContextMenuTarget) {
return item.kind === 'youtube' ? item.song.isExplicit : (item.track.isExplicit ?? false);
}
function itemActive(item: ContextMenuTarget) {
return (
player.current?.id === (item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track))
);
}
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
if (data.playlists.length === 0) return;
event.preventDefault();
const menuWidth = 208;
const submenuWidth = 224;
contextMenu = {
...target,
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
y: Math.min(event.clientY, window.innerHeight - 60),
openLeft: event.clientX + menuWidth + submenuWidth + 16 > window.innerWidth
};
}
function closeContextMenu() {
contextMenu = null;
}
function queueItemFor(target: ContextMenuTarget): QueueItem {
return target.kind === 'youtube'
? {
@@ -138,31 +109,88 @@
title: target.song.title,
artist: target.song.artist,
source: 'youtube',
coverUrl: target.song.thumbnailUrl
coverUrl: target.song.thumbnailUrl,
explicit: target.song.isExplicit
}
: toQueueItems([target.track])[0];
}
function addToQueue(target: ContextMenuTarget | null) {
if (!target) return;
player.addToQueue(queueItemFor(target));
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(() => {
goto(buildHref(value.trim()), {
keepFocus: true,
replaceState: true,
noScroll: true
});
}, 350);
}
function togglePlay(index: number) {
player.playOrToggle(items.map(queueItemFor), index);
}
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemFor(contextMenu));
closeContextMenu();
}
async function loadMoreLocal() {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(
localItems,
next.items,
track => track.id
);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
}
async function loadMoreYouTube() {
if (!ytContinuation || ytLoadingMore) return;
ytLoadingMore = true;
const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
ytItems = appendUnique(
ytItems,
next.items,
item => item.videoId
);
ytContinuation = next.continuationToken;
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
const params = new URLSearchParams({ query: data.query, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
ytItems = [...ytItems, ...next.items];
ytContinuation = next.continuationToken;
if (localHasNext) await loadMoreLocal();
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
} catch {
ytContinuation = '';
if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally {
ytLoadingMore = false;
loadingMore = false;
}
}
</script>
@@ -172,287 +200,62 @@
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
</svelte:head>
<section class="px-8 pt-12 pb-8">
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
<p class="mt-2 text-neutral-400">Busca a la vez en tu música y en YouTube Music.</p>
<Page>
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
<div class="relative mt-8">
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500">
<Search class="h-4 w-4" />
</span>
<input
<div class="mt-8">
<Input
type="search"
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..."
autocomplete="off"
class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
{#if form?.message}
<p class="mt-4 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3 text-sm text-neutral-300">
{form.message}
</p>
<Alert class="mt-4">{form.message}</Alert>
{/if}
{#if nothingFound}
<div class="mt-10 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
<Music class="mx-auto h-10 w-10 text-neutral-500" />
<p class="mt-4 text-neutral-300">No hay resultados para «{data.query}».</p>
</div>
<EmptyState icon={Music} title="Sin resultados" description="No hay resultados para «{data.query}»." />
{/if}
{#if localVisible}
{#if localHeadingVisible}
<h2 class="mt-10 font-display text-lg font-bold tracking-tight">Musify</h2>
{/if}
{#if items.length > 0}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (itemId(item))}
<TrackTile
id={itemId(item)}
title={itemTitle(item)}
artist={itemArtist(item)}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
hue={hueFor(itemId(item))}
explicit={itemExplicit(item)}
active={itemActive(item)}
playing={player.isPlaying}
index={i}
onClick={() => togglePlay(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
<p class="{localHeadingVisible ? 'mt-1' : 'mt-6'} text-sm text-neutral-500">
{#if data.query}
{total}
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
{:else}
{total}
{total === 1 ? 'canción' : 'canciones'}
{/if}
</p>
{#if tracks.items.length === 0}
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
<Music class="mx-auto h-8 w-8 text-neutral-500" />
<p class="mt-3 text-sm text-neutral-300">Todavía no hay canciones. ¡Sé el primero en subir una!</p>
</div>
{:else}
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each tracks.items as track, i (track.id)}
<li
class="group animate-enter relative"
style="animation-delay:{i * 40}ms"
oncontextmenu={(e) => openContextMenu(e, { kind: 'local', track })}
>
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="large"
alt={track.title}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={player.current?.id === queueIdForTrack(track)}
class:!translate-y-0={player.current?.id === queueIdForTrack(track)}
>
{#if player.current?.id === queueIdForTrack(track) && player.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</span>
</Cover>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
{track.title}
</div>
{#if track.artist}
<div class="mt-0.5 truncate text-xs text-neutral-500">{track.artist}</div>
{/if}
</button>
</li>
{/each}
</ul>
{#if tracks.hasPreviousPage || tracks.hasNextPage}
<nav class="mt-8 flex items-center justify-center gap-4">
{#if tracks.hasPreviousPage}
<a
href={buildHref(currentPage - 1)}
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Anterior
</a>
{/if}
<span class="text-sm text-neutral-500">Página {currentPage}</span>
{#if tracks.hasNextPage}
<a
href={buildHref(currentPage + 1)}
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
>
Siguiente
</a>
{/if}
</nav>
{/if}
{/if}
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/if}
{#if youtubeVisible}
{#if youtubeHeadingVisible}
<h2 class="mt-12 font-display text-lg font-bold tracking-tight">YouTube Music</h2>
{/if}
{#if data.needsAuth}
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
<Music class="mx-auto h-8 w-8 text-neutral-500" />
<p class="mt-3 text-sm text-neutral-300">Inicia sesión para buscar en YouTube Music.</p>
</div>
{:else if data.ytError}
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
<Music class="mx-auto h-8 w-8 text-neutral-500" />
<p class="mt-3 text-sm text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p>
</div>
{:else}
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each ytItems as song, i (song.videoId)}
<li
class="group animate-enter relative"
style="animation-delay:{i * 40}ms"
oncontextmenu={(e) => openContextMenu(e, { kind: 'youtube', song })}
>
<button type="button" onclick={() => togglePlayYouTube(i)} class="block w-full text-left">
<Cover
trackId={song.videoId}
src={song.thumbnailUrl}
hue={hueFor(song.videoId)}
alt={song.title}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={player.current?.id === song.videoId}
class:!translate-y-0={player.current?.id === song.videoId}
>
{#if player.current?.id === song.videoId && player.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</span>
</Cover>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={song.title}>
{song.title}
</div>
<div class="mt-0.5 flex min-w-0 items-center gap-1.5">
{#if song.isExplicit}
<span
class="grid h-3.5 w-3.5 shrink-0 place-items-center rounded-[3px] bg-white/20 text-[9px] font-bold leading-none text-neutral-200"
title="Contenido explícito"
>
E
</span>
{/if}
<span class="truncate text-xs text-neutral-500" title={song.artist}>{song.artist}</span>
</div>
</button>
</li>
{/each}
</ul>
{#if ytContinuation}
<div class="mt-8 flex justify-center">
<button
type="button"
onclick={loadMoreYouTube}
disabled={ytLoadingMore}
class="flex items-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5 disabled:opacity-50"
>
{#if ytLoadingMore}
<LoaderCircle class="h-4 w-4 animate-spin" />
{/if}
Cargar más
</button>
</div>
{/if}
{/if}
{#if data.needsAuth}
<EmptyState
icon={Music}
description="Inicia sesión para ver también resultados de YouTube Music."
/>
{:else if data.ytError}
<EmptyState
icon={Music}
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
/>
{/if}
</section>
</Page>
{#if contextMenu}
<div
class="fixed inset-0 z-30"
role="button"
tabindex="0"
onclick={closeContextMenu}
onkeydown={(e) => {
if (e.key === 'Escape') closeContextMenu();
}}
oncontextmenu={(e) => {
e.preventDefault();
closeContextMenu();
}}
></div>
<div
class="fixed z-40 w-60 rounded-xl bg-[var(--mf-elevated)] p-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
style="left:{contextMenu.x}px; top:{contextMenu.y}px;"
>
<button
type="button"
onclick={() => addToQueue(contextMenu)}
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-sm text-[var(--mf-text-2)] transition hover:bg-white/5"
>
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
<span class="text-base">Añadir a la cola</span>
</button>
<div class="group/addmenu relative">
<div
class="flex items-center gap-3 rounded-lg px-2 py-2.5 text-sm text-[var(--mf-text-2)] transition group-hover/addmenu:bg-white/5"
>
<span class="flex-1 text-base cursor-default">Añadir a una playlist</span>
<ChevronRight class="h-4 w-4 shrink-0 text-[var(--mf-text-3)]" />
</div>
<div
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {contextMenu.openLeft
? 'right-full pr-1.5'
: 'left-full pl-1.5'}"
>
<div class="w-64 overflow-hidden rounded-xl bg-[var(--mf-elevated)] p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]">
<div class="max-h-80 overflow-y-auto">
{#each data.playlists as playlist (playlist.id)}
<form
method="POST"
action={contextMenu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
use:enhance={() =>
({ update }) => {
closeContextMenu();
return update({ reset: false });
}}
>
<input type="hidden" name="playlistId" value={playlist.id} />
{#if contextMenu.kind === 'youtube'}
<input type="hidden" name="videoId" value={contextMenu.song.videoId} />
<input type="hidden" name="title" value={contextMenu.song.title} />
<input type="hidden" name="artist" value={contextMenu.song.artist} />
<input type="hidden" name="durationSeconds" value={contextMenu.song.durationSeconds} />
<input type="hidden" name="thumbnailUrl" value={contextMenu.song.thumbnailUrl} />
{:else}
<input type="hidden" name="trackId" value={contextMenu.track.id} />
{/if}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base text-[var(--mf-text)] transition hover:bg-white/5"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={[]}
hue={hueFor(playlist.id)}
size="small"
class="h-8 w-8 flex-shrink-0 rounded-lg"
/>
<span class="truncate text-base text-[var(--mf-text-2)]">{playlist.name}</span>
</button>
</form>
{/each}
</div>
</div>
</div>
</div>
</div>
<TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
{/if}
@@ -1,33 +1,31 @@
import type { PageServerLoad, Actions } from './$types';
import { error, fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { fail } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction, unwrapOrError, unwrapOrFail } from '$lib/server/api';
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.');
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
});
if (err || !data) error(502, 'No se pudo cargar tu biblioteca.');
return { tracks: data };
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
};
export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err } = await api.DELETE('/tracks/{trackId}', {
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } }
});
if (err) return fail(502, { message: 'No se pudo borrar la canción.' });
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
if (failure) return failure;
return { deleted: true };
}
};
+69 -105
View File
@@ -1,30 +1,28 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Upload from '@lucide/svelte/icons/upload';
import Play from '@lucide/svelte/icons/play';
import X from '@lucide/svelte/icons/x';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import { HUES, fmtTime } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems } from '$lib/player/player.svelte';
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import IconButton from '$lib/components/ui/IconButton.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';
let { data } = $props();
const items = $derived(data.tracks.items);
const total = $derived(Number(data.tracks.totalItemCount));
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function togglePlay(index: number) {
const track = items[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(items), index);
player.playOrToggle(toQueueItems(items), index);
}
</script>
@@ -33,103 +31,69 @@
<meta name="description" content="Todas tus canciones subidas a Musify." />
</svelte:head>
<section class="px-8 pt-11 pb-8">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Tus canciones subidas</h1>
<p class="mt-2 text-[var(--mf-text-2)]">
{total}
{total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}.
</p>
</div>
<a
href="/upload"
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
>
<Upload class="h-4 w-4" />
Subir música
</a>
</div>
<Page>
<PageHeader
title="Tus canciones subidas"
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
>
{#snippet actions()}
{#if items.length > 0}
<Button href="/upload">
<Upload class="h-4 w-4" />
Subir canción
</Button>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<div class="mt-8">
<div
class="grid grid-cols-[32px_1fr_100px_100px_64px_36px] items-center gap-8 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<TrackTable columns="32px 1fr 100px 100px 64px 36px" class="mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="text-center">Subida</span>
<span class="text-center">Escuchas</span>
<span class="text-center">Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col">
{#each items as track, i (track.id)}
<div
class="group grid grid-cols-[32px_1fr_100px_100px_64px_36px] items-center gap-8 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60"
>
<button
type="button"
onclick={() => togglePlay(i)}
aria-label={player.current.id === track.id && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-white group-hover:block" fill="currentColor" />
{/if}
</button>
<button
type="button"
onclick={() => togglePlay(i)}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
/>
<div class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</div>
</button>
<div class="truncate text-center text-[12.5px] text-neutral-500">
{dateFormatter.format(new Date(track.createdAt))}
</div>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{Number(track.listensCount)}
</span>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{fmtTime(Number(track.duration))}
</span>
<form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<button
type="submit"
aria-label="Borrar canción"
class="grid h-7 w-7 place-items-center rounded-lg text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
>
<X class="h-3.5 w-3.5" />
</button>
</form>
</div>
{/each}
</div>
</div>
{/snippet}
{#each items as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === track.id}
playing={player.playing}
onToggle={() => togglePlay(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
hue={hueFor(track.id)}
onClick={() => togglePlay(i)}
/>
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form method="POST" action="?/deleteTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Borrar canción" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
{:else}
<div
class="mt-7 max-w-[560px] rounded-xl border border-[var(--mf-border)] bg-[var(--mf-surface)] px-6 py-10 text-center"
<EmptyState
icon={Music}
title="Todavía no tienes canciones"
description="Sube la primera y aparecerá aquí lista para reproducir."
>
<p class="text-sm text-[var(--mf-text-2)]">Todavía no tienes canciones</p>
<a
href="/upload"
class="mt-4 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
>
<Upload class="h-4 w-4" />
Sube la primera
</a>
</div>
{#snippet actions()}
<Button href="/upload">
<Upload class="h-4 w-4" />
Sube la primera
</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</Page>
@@ -1,52 +1,34 @@
import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
playlistCoverTrackIds,
requireAccessTokenAction,
unwrapOrError
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function contentTypeOf(file: File): string {
if (file.type) return file.type;
return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream';
}
export const load: PageServerLoad = async ({ locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.');
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
});
if (err || !data) error(502, 'No se pudieron cargar tus playlists.');
const covers = await Promise.all(
data.items.map(async (playlist) => {
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
});
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
})
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
const trackIds = await playlistCoverTrackIds(
api,
data.items.map((p) => p.id)
);
return { playlists: data, trackIds: Object.fromEntries(covers) as Record<string, string[]> };
return { playlists: data, trackIds };
};
export const actions: Actions = {
create: async ({ request, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
@@ -57,36 +39,18 @@ export const actions: Actions = {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).', name, description });
}
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const contentType = contentTypeOf(cover);
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', {
body: {
fileType: extOf(cover.name) || 'jpg',
contentType,
expectedSizeBytes: cover.size
}
});
if (uploadError || !upload) {
return fail(502, { message: 'No se pudo reservar la subida de la portada.', name, description });
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
}
try {
const coverBuffer = await cover.arrayBuffer();
await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType);
} catch (err) {
return fail(502, {
message: 'Falló la subida de la portada al almacenamiento.',
name,
description,
detail: err instanceof Error ? err.message : undefined
});
}
pictureIntentId = upload.intentId;
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
+69 -210
View File
@@ -1,54 +1,44 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import ListMusic from '@lucide/svelte/icons/list-music';
import ImageIcon from '@lucide/svelte/icons/image';
import { HUES } from '$lib/theme/color';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
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';
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"
'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();
const items = $derived(data.playlists.items);
const trackIds = $derived(data.trackIds);
let creating = $state(false);
let submitting = $state(false);
let name = $state('');
let description = $state('');
let coverPreview = $state('');
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function openCreate() {
creating = true;
}
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
</script>
<svelte:head>
@@ -56,185 +46,54 @@
<meta name="description" content="Mis listas en Musify." />
</svelte:head>
<section class="px-8 py-12">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Mis listas</h1>
<p class="mt-2 text-neutral-400">Crea y organiza tus colecciones.</p>
</div>
{#if items.length > 0}
<button
type="button"
onclick={openCreate}
class="inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-950 transition hover:brightness-110 hover:shadow-[0_8px_24px_-8px] active:scale-[0.97]"
>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista
</button>
{/if}
</div>
<Page>
<PageHeader title="Mis listas" subtitle="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>
{/if}
{/snippet}
</PageHeader>
{#if items.length > 0}
<div class="mt-9 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
<MediaGrid class="mt-9">
{#each items as playlist, i (playlist.id)}
<a
href="/playlists/{playlist.id}"
class="group animate-enter text-left"
style="animation-delay:{i * 45}ms"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={trackIds[playlist.id] ?? []}
hue={hueFor(playlist.id)}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)] transition group-hover:shadow-[0_16px_36px_-10px_rgba(0,0,0,0.75)]"
/>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-0.5 truncate text-xs text-[var(--mf-text-3)]">{playlist.description}</div>
{/if}
</a>
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={trackIds[playlist.id] ?? []}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</div>
</MediaGrid>
{:else}
<div
class="mt-9 p-14 text-center"
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea la primera y añádele canciones de tu biblioteca."
>
<span class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950">
<ListMusic class="h-7 w-7" />
</span>
<h3 class="mt-5 font-display text-2xl font-bold tracking-tight">Todavía no tienes listas</h3>
<p class="mt-2 text-neutral-400">Crea la primera y añádele canciones de tu biblioteca.</p>
<button
type="button"
onclick={openCreate}
class="mt-6 inline-flex items-center gap-2 rounded-lg bg-[var(--mf-accent)] px-5 py-2.5 text-sm font-semibold text-neutral-800 transition active:scale-[0.95]"
>
<Plus class="h-4 w-4" strokeWidth={2.5} />
Crear lista
</button>
</div>
{#snippet actions()}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear lista
</Button>
{/snippet}
</EmptyState>
{/if}
</section>
</Page>
{#if creating}
<div
class="animate-fade fixed inset-0 z-100 grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="dialog"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) creating = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') creating = false;
}}
>
<div class="animate-pop w-full max-w-2xl rounded-lg bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between">
<h2 class="text-2xl font-bold tracking-tight">Nueva lista</h2>
<button
type="button"
onclick={() => (creating = false)}
aria-label="Cerrar"
class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white"
>
<X class="h-4 w-4" />
</button>
</div>
<form
method="POST"
action="?/create"
enctype="multipart/form-data"
use:enhance={() => {
submitting = true;
return async ({ update }) => {
await update();
submitting = false;
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<div class="flex flex-col">
<span class="mb-3 block text-sm font-medium text-neutral-300">
Portada <span class="text-neutral-500">(Opcional)</span>
</span>
<label
class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50 "
>
<input
type="file"
name="cover"
accept="image/*"
onchange={onCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
{/if}
</label>
</div>
<div class="flex flex-1 flex-col gap-4">
<div>
<label for="name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
<input
id="name"
name="name"
type="text"
maxlength={100}
required
autocomplete="off"
bind:value={name}
placeholder={playlistNames[Math.floor(Math.random() * playlistNames.length)]}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="description" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="description"
name="description"
maxlength={300}
bind:value={description}
placeholder="¿De qué va esta lista?"
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div>
{#if form?.message}
<p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{form.message}
</p>
{/if}
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick={() => (creating = false)}
class="rounded-lg border border-white/10 px-5 py-2.5 text-sm text-white/65 transition hover:bg-white/2.5"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || name.trim() === ''}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
>
{submitting ? 'Creando…' : 'Crear'}
</button>
</div>
</form>
</div>
</div>
{/if}
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<PlaylistForm
action="?/create"
{namePlaceholder}
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -1,80 +1,92 @@
import type { PageServerLoad, Actions } from './$types';
import { error, fail, redirect } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import type { YouTubeSong } from '$lib/types';
const MAX_NAME = 100;
const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function contentTypeOf(file: File): string {
if (file.type) return file.type;
return IMAGE_TYPES[extOf(file.name)] ?? 'application/octet-stream';
}
const SUGGESTIONS_LIMIT = 20;
export const load: PageServerLoad = async ({ params, locals, fetch }) => {
if (!locals.user) error(401, 'Inicia sesión.');
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes, libraryRes] = await Promise.all([
const [playlistRes, tracksRes, libraryRes, playlistsRes] = 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 } }
}),
api.GET('/tracks/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
}),
api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user!.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
]);
if (playlistRes.error || !playlistRes.data) error(404, 'Playlist no encontrada.');
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));
return { playlist: playlistRes.data, tracks, library };
const alreadyLinked = new Set(
tracks.filter((t) => t.source === 'YouTube' && t.externalId).map((t) => t.externalId)
);
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a) => !!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 ?? []
};
};
export const actions: Actions = {
addTrack: async ({ request, params, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err } = await api.POST('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
if (err) return fail(502, { message: 'No se pudo añadir la canción.' });
return { added: true };
},
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
removeTrack: async ({ request, params, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err } = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
if (err) return fail(502, { message: 'No se pudo quitar la canción.' });
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
rename: async ({ request, params, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
@@ -83,52 +95,39 @@ export const actions: Actions = {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const contentType = contentTypeOf(cover);
const { data: upload, error: uploadError } = await api.POST('/playlists/upload-picture', {
body: {
fileType: extOf(cover.name) || 'jpg',
contentType,
expectedSizeBytes: cover.size
}
});
if (uploadError || !upload) {
return fail(502, { message: 'No se pudo reservar la subida de la portada.' });
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, detail: result.detail });
}
try {
const coverBuffer = await cover.arrayBuffer();
await putPresigned(upload.uploadUrl, Buffer.from(coverBuffer), upload.contentType);
} catch (err) {
return fail(502, {
message: 'Falló la subida de la portada al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
});
}
newPictureIntentId = upload.intentId;
newPictureIntentId = result.intentId;
}
const { error: err } = await api.PUT('/playlists/{playlistId}', {
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId }
});
if (err) return fail(502, { message: 'No se pudo actualizar la playlist.' });
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
return { renamed: true };
},
delete: async ({ params, locals, fetch }) => {
if (!locals.accessToken) return fail(401, { message: 'Inicia sesión.' });
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { error: err } = await api.DELETE('/playlists/{playlistId}', {
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }
});
if (err) return fail(502, { message: 'No se pudo eliminar la playlist.' });
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
if (failure) return failure;
redirect(303, '/playlists');
}
};
@@ -1,28 +1,13 @@
<script lang="ts">
import { enhance } from '$app/forms';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePause from '@lucide/svelte/icons/square-pause';
import Pencil from '@lucide/svelte/icons/pencil';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import Plus from '@lucide/svelte/icons/plus';
import X from '@lucide/svelte/icons/x';
import Clock from '@lucide/svelte/icons/clock';
import ImageIcon from '@lucide/svelte/icons/image';
import Headphones from '@lucide/svelte/icons/headphones';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl
} from '$lib/player/player.svelte';
import { HUES, fmtTime } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
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 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();
@@ -32,28 +17,14 @@
let editing = $state(false);
let confirmingDelete = $state(false);
let editCoverPreview = $state('');
let editCoverFailed = $state(false);
let savingEdit = $state(false);
let youtubeSuggestions = $state<YouTubeSong[]>([]);
function openEdit() {
editCoverPreview = '';
editCoverFailed = false;
editing = true;
}
function onEditCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) editCoverPreview = URL.createObjectURL(file);
}
const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' });
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
$effect(() => {
youtubeSuggestions = [];
data.youtube.then((result) => {
youtubeSuggestions = result.items;
});
});
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
@@ -62,18 +33,6 @@
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
}
function playFrom(index: number) {
const track = tracks[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(tracks), index);
}
function playFromLibrary(index: number) {
const track = library[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(library), index);
}
</script>
<svelte:head>
@@ -81,376 +40,54 @@
<meta name="description" content="Playlist {playlist.name} en Musify." />
</svelte:head>
<section class="px-8 py-8">
<a
href="/playlists"
class="group inline-flex items-center font-medium text-neutral-400 transition-colors hover:text-[var(--mf-accent)]"
>
<span
class="grid h-8 w-8 place-items-center rounded-lg border-white/15 text-neutral-400 transition-colors duration-150 group-hover:text-[var(--mf-accent)]"
>
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus listas
</a>
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt
playlistId={playlist.id}
trackIds={tracks.map((t) => t.id)}
hue={hueFor(playlist.id)}
size="large"
class="h-44 w-44 flex-shrink-0 rounded-2xl shadow-[0_20px_45px_-15px_rgba(0,0,0,0.7)]"
/>
<div class="min-w-0 flex-1">
<p class="text-xs font-semibold uppercase tracking-[0.14em] text-neutral-400">Lista</p>
<h1 class="mt-1.5 font-display text-5xl font-bold tracking-tight sm:text-7xl">{playlist.name}</h1>
{#if playlist.description}
<p class="mt-3 max-w-2xl text-neutral-300">{playlist.description}</p>
{/if}
<p class="mt-3 text-sm text-neutral-500">
{tracks.length}
{tracks.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-7 flex items-center gap-2.5">
<button
type="button"
onclick={playAll}
disabled={tracks.length === 0}
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--mf-accent)] px-4 py-2 text-neutral-900 transition hover:brightness-110 active:scale-95 disabled:opacity-40"
>
{#if isCurrentQueue && player.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
type="button"
onclick={openEdit}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
>
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
Editar
</button>
<button
type="button"
onclick={() => (confirmingDelete = true)}
class="inline-flex items-center gap-1.5 rounded-lg border border-white/10 px-4 py-2 text-white/65 transition hover:bg-white/2.5"
>
<Trash class="h-4 w-4" strokeWidth={1.5} />
Eliminar
</button>
</div>
<Page>
<PlaylistHeader
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={tracks.map((t) => t.id)}
updatedAt={playlist.updatedAt}
playing={isCurrentQueue && player.playing}
onPlayAll={playAll}
onEdit={() => (editing = true)}
onDelete={() => (confirmingDelete = true)}
/>
{#if tracks.length > 0}
<div class="mt-8">
<div
class="text-center grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<span>#</span>
<span class="text-left">Título</span>
<span>Añadida</span>
<span>Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col">
{#each tracks as track, i (track.id)}
<div
class="text-center group grid grid-cols-[32px_1fr_140px_64px_36px] items-center gap-4 rounded-[10px] px-3 py-[9px] transition hover:bg-neutral-900/60"
>
<button
type="button"
onclick={() => playFrom(i)}
aria-label={player.current.id === queueIdForTrack(track) && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center overflow-hidden rounded text-sm text-neutral-500"
>
{#if player.current.id === queueIdForTrack(track)}
<NowPlaying paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-white group-hover:block" fill="currentColor" />
{/if}
</button>
<button type="button" onclick={() => playFrom(i)} class="flex min-w-0 items-center gap-3.5 text-left">
<Cover
trackId={track.id}
src={isPendingYouTubeTrack(track) ? youTubeThumbnailUrl(track.externalId) : undefined}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[42px] w-[42px] flex-shrink-0 rounded-md"
/>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-2">
<span class="min-w-0 truncate text-[14.5px] font-semibold text-neutral-100">{track.title}</span>
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<span class="shrink-0 rounded-full bg-red-500/10 px-2 py-0.5 text-[10px] font-semibold text-red-300">
Error
</span>
{:else if isPendingYouTubeTrack(track)}
<span
class="shrink-0 rounded-full bg-emerald-500/20 px-2 py-0.5 text-[10px] font-semibold text-emerald-400"
>
Descargando
</span>
{/if}
</div>
{#if track.artist}
<div class="truncate text-left text-xs text-neutral-500">{track.artist}</div>
{/if}
</div>
</button>
<div class="truncate text-[12.5px] text-neutral-500">
{dateFormatter.format(new Date(track.createdAt))}
</div>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{fmtTime(Number(track.duration))}
</span>
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<button
type="submit"
aria-label="Quitar de la playlist"
class="grid h-7 w-7 place-items-center rounded-lg text-neutral-500 opacity-0 transition hover:bg-white/5 hover:text-white group-hover:opacity-100"
>
<X class="h-3.5 w-3.5" />
</button>
</form>
</div>
{/each}
</div>
</div>
<PlaylistTrackTable {tracks} />
{/if}
{#if library.length > 0}
<div class="mt-12">
<h2 class="font-display text-lg font-bold tracking-tight">Añadir de tu biblioteca</h2>
<div
class="mt-4 grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 border-b border-white/10 px-3 pb-2 text-xs uppercase tracking-wide text-neutral-500"
>
<span>Título</span>
<span class="text-center">Escuchas</span>
<span class="text-center">Duración</span>
<span></span>
</div>
<div class="mt-1 flex flex-col">
{#each library as track, i (track.id)}
<div
class="group grid grid-cols-[1fr_100px_64px_110px] items-center gap-12 rounded-[10px] px-3 py-[10px] transition hover:bg-neutral-900/60"
>
<button
type="button"
onclick={() => playFromLibrary(i)}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-[40px] w-[40px] flex-shrink-0 rounded-md"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">{track.title}</div>
</button>
<div class="flex items-center justify-center gap-1.5 text-[12.5px] tabular-nums text-neutral-500">
<Headphones class="h-3.5 w-3.5" />
{Number(track.listensCount)}
</div>
<span class="text-center text-[12.5px] tabular-nums text-neutral-500">
{fmtTime(Number(track.duration))}
</span>
<form
method="POST"
action="?/addTrack"
use:enhance={() => async ({ update }) => update()}
class="flex justify-end"
>
<input type="hidden" name="trackId" value={track.id} />
<button
type="submit"
class="inline-flex items-center gap-1.5 rounded-lg border-2 border-white/20 px-2 py-2 text-xs text-white/80 transition hover:border-[var(--mf-accent)]/50 hover:text-[var(--mf-accent)]"
>
<Plus class="h-4 w-4" />
</button>
</form>
</div>
{/each}
</div>
</div>
{#if library.length > 0 || youtubeSuggestions.length > 0}
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
{/if}
</section>
</Page>
{#if editing}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="button"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) editing = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') editing = false;
}}
>
<div class="animate-pop w-full max-w-2xl rounded-2xl bg-[var(--mf-bg)] p-6 shadow-2xl">
<div class="flex items-center justify-between">
<h2 class="text-lg font-bold tracking-tight">Editar playlist</h2>
<button
type="button"
onclick={() => (editing = false)}
aria-label="Cerrar"
class="grid h-8 w-8 place-items-center rounded-lg text-neutral-400 transition hover:bg-white/5 hover:text-white"
>
<X class="h-4 w-4" />
</button>
</div>
<form
method="POST"
action="?/rename"
enctype="multipart/form-data"
use:enhance={() => {
savingEdit = true;
return async ({ update, result }) => {
await update({ reset: false });
savingEdit = false;
if (result.type === 'success') editing = false;
};
}}
class="mt-4 w-full max-w-4xl space-y-4"
>
<div class="flex items-stretch gap-8">
<div class="flex flex-col">
<span class="mb-3 block text-sm font-medium text-neutral-300">
Portada <span class="text-neutral-500">(Opcional)</span>
</span>
<label
class="relative grid h-52 w-52 cursor-pointer place-items-center overflow-hidden rounded-lg border border-transparent bg-[var(--mf-surface)] transition-colors hover:border-[var(--mf-accent)]/50"
>
<input
type="file"
name="cover"
accept="image/*"
onchange={onEditCoverInput}
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar portada"
/>
{#if editCoverPreview !== ''}
<img src={editCoverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else if !editCoverFailed}
<img
src="/api/playlists/{playlist.id}/cover?size=medium"
alt="Portada actual"
loading="lazy"
onerror={() => (editCoverFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
{/if}
</label>
</div>
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm
action="?/rename"
initialName={playlist.name}
initialDescription={playlist.description}
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(playlist.updatedAt)}"
formMessage={form?.message}
submitLabel="Guardar"
submittingLabel="Guardando…"
onCancel={() => (editing = false)}
onSuccess={() => (editing = false)}
/>
</Modal>
<div class="flex flex-1 flex-col gap-4">
<div>
<label for="edit-name" class="mb-2 block text-sm font-medium text-neutral-300">Nombre</label>
<input
id="edit-name"
name="name"
type="text"
maxlength={100}
required
value={playlist.name}
class="w-full rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
/>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<label for="edit-desc" class="mb-2 block text-sm font-medium text-neutral-300">
Descripción <span class="text-neutral-500">(Opcional)</span>
</label>
<textarea
id="edit-desc"
name="description"
rows="3"
maxlength={300}
value={playlist.description}
class="w-full min-h-0 flex-1 resize-none rounded-lg border border-transparent bg-[var(--mf-surface)] px-4 py-3 text-sm text-neutral-100 focus:border-[var(--mf-accent)]/50 focus:outline-none"
></textarea>
</div>
</div>
</div>
{#if form?.message}
<p class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{form.message}
</p>
{/if}
<div class="flex items-center justify-end gap-3">
<button
type="button"
onclick={() => (editing = false)}
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
>
Cancelar
</button>
<button
type="submit"
disabled={savingEdit}
class="rounded-lg bg-[var(--mf-accent)] px-6 py-2.5 text-sm font-semibold text-neutral-800 transition disabled:opacity-40"
>
{savingEdit ? 'Guardando…' : 'Guardar'}
</button>
</div>
</form>
</div>
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
<h2 class="text-lg font-bold tracking-tight text-fg">
¿Eliminar «{playlist.name}»?
</h2>
<p class="mt-2 text-base text-fg-3">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
dispositivos donde la tengas guardada.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<Button variant="secondary" onclick={() => (confirmingDelete = false)}>Cancelar</Button>
<form method="POST" action="?/delete">
<Button type="submit" variant="danger">Eliminar</Button>
</form>
</div>
{/if}
{#if confirmingDelete}
<div
class="animate-fade fixed inset-0 z-[60] grid place-items-center bg-black/70 p-4 backdrop-blur-sm"
role="button"
tabindex="0"
onclick={(e) => {
if (e.target === e.currentTarget) confirmingDelete = false;
}}
onkeydown={(e) => {
if (e.key === 'Escape') confirmingDelete = false;
}}
>
<div class="animate-pop w-full max-w-sm rounded-2xl border border-white/10 bg-neutral-900 p-6 shadow-2xl">
<h2 class="font-display text-lg font-bold tracking-tight">¿Eliminar «{playlist.name}»?</h2>
<p class="mt-2 text-sm text-neutral-400">
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los dispositivos donde la tengas guardada.
</p>
<div class="mt-6 flex items-center justify-end gap-3">
<button
type="button"
onclick={() => (confirmingDelete = false)}
class="rounded-lg border border-white/15 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/5"
>
Cancelar
</button>
<form method="POST" action="?/delete">
<button
type="submit"
class="rounded-lg bg-red-500 px-6 py-2.5 text-sm font-semibold text-white transition hover:bg-red-400"
>
Eliminar
</button>
</form>
</div>
</div>
</div>
{/if}
</Modal>
@@ -0,0 +1,77 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Play from '@lucide/svelte/icons/play';
import Pause from '@lucide/svelte/icons/pause';
import SquarePencil from '@lucide/svelte/icons/square-pen';
import Trash from '@lucide/svelte/icons/trash';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { hueFor } from '$lib/theme/color';
interface Props {
id: string;
name: string;
description?: string;
trackIds: (string | number)[];
updatedAt: string;
playing: boolean;
onPlayAll: () => void;
onEdit: () => void;
onDelete: () => void;
}
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
$props();
</script>
<a
href="/playlists"
class="group inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
>
<span class="grid h-8 w-8 place-items-center transition-colors group-hover:text-accent-soft">
<ArrowLeft class="h-4 w-4" />
</span>
Volver a tus listas
</a>
<div class="mt-6 flex flex-col gap-6 sm:flex-row sm:items-end">
<PlaylistArt
playlistId={id}
{trackIds}
hue={hueFor(id)}
size="large"
version={updatedAt}
class="h-44 w-44 flex-shrink-0 rounded-art-lg shadow-art-lg"
/>
<div class="min-w-0 flex-1">
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
<h1 class="mt-1.5 text-5xl font-bold tracking-tight text-fg sm:text-7xl">{name}</h1>
{#if description}
<p class="mt-3 max-w-2xl text-fg-2">{description}</p>
{/if}
<p class="mt-3 text-sm text-muted">
{trackIds.length}
{trackIds.length === 1 ? 'canción' : 'canciones'}
</p>
</div>
</div>
<div class="mt-7 flex items-center gap-2.5">
<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>
@@ -0,0 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
import X from '@lucide/svelte/icons/x';
import {
player,
isPendingYouTubeTrack,
queueIdForTrack,
toQueueItems,
youTubeThumbnailUrl,
type ApiTrackLike
} from '$lib/player/player.svelte';
import { hueFor, fmtTime, fmtDate } from '$lib/theme/color';
import Badge from '$lib/components/ui/Badge.svelte';
import IconButton from '$lib/components/ui/IconButton.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';
type PlaylistTrack = ApiTrackLike & { createdAt: string; duration: number | string };
interface Props {
tracks: PlaylistTrack[];
}
let { tracks }: Props = $props();
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
<TrackTable columns="32px 1fr 140px 64px 36px" class="mt-8">
{#snippet headers()}
<span class="text-center">#</span>
<span>Título</span>
<span class="text-center">Añadida</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#each tracks as track, i (track.id)}
<TrackRow>
<TrackIndexCell
index={i}
active={player.current.id === queueIdForTrack(track)}
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
artist={track.artist}
coverSrc={isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: undefined}
hue={hueFor(track.id)}
explicit={track.isExplicit}
onClick={() => playFrom(i)}
>
{#snippet badge()}
{#if track.source === 'YouTube' && track.audioStatus === 'Failed'}
<Badge tone="danger">Error</Badge>
{:else if isPendingYouTubeTrack(track)}
<Badge tone="accent">Descargando</Badge>
{/if}
{/snippet}
</TrackTitleCell>
<TrackMeta>{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form method="POST" action="?/removeTrack" use:enhance={() => async ({ update }) => update()}>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label="Quitar de la playlist" size="sm" revealOnHover>
<X class="h-3.5 w-3.5" />
</IconButton>
</form>
</TrackRow>
{/each}
</TrackTable>
@@ -0,0 +1,151 @@
<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 { hueFor, fmtTime } from '$lib/theme/color';
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,
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-12">
<SectionHeading title="Sugerencias" />
<TrackTable columns="1fr 64px 36px">
{#snippet headers()}
<span>Título</span>
<span class="text-center">Duración</span>
<span></span>
{/snippet}
{#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}
hue={hueFor(idFor(item))}
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}
@@ -1,42 +1,14 @@
import type { Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { putPresigned } from '$lib/server/upload';
import { createApiClient, requireAccessTokenAction, unwrapOrFail } from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
const MAX_TITLE = 100;
const AUDIO_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
wav: 'audio/wav',
ogg: 'audio/ogg',
opus: 'audio/opus'
};
const IMAGE_TYPES: Record<string, string> = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp'
};
function extOf(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function contentTypeOf(file: File, table: Record<string, string>): string {
if (file.type) return file.type;
return table[extOf(file.name)] ?? 'application/octet-stream';
}
export const actions: Actions = {
default: async ({ request, locals, fetch }) => {
if (!locals.accessToken) {
return fail(401, { message: 'Inicia sesión para subir música.' });
}
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
@@ -55,9 +27,9 @@ export const actions: Actions = {
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const api = createApiClient({ fetch, accessToken });
const { data: urls, error: urlsError } = await api.POST('/tracks/upload-urls', {
const urlsResult = await api.POST('/tracks/upload-urls', {
body: {
pictureFileType: extOf(cover.name) || 'jpg',
pictureContentType,
@@ -68,9 +40,9 @@ export const actions: Actions = {
}
});
if (urlsError || !urls) {
return fail(502, { message: 'No se pudieron reservar las URLs de subida.' });
}
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
if (urlsFailure) return urlsFailure;
const urls = urlsResult.data!;
try {
const [audioBuffer, coverBuffer] = await Promise.all([
@@ -88,7 +60,7 @@ export const actions: Actions = {
});
}
const { data: track, error: createError } = await api.POST('/tracks', {
const trackResult = await api.POST('/tracks', {
body: {
title,
pictureIntentId: urls.pictureIntentId,
@@ -96,9 +68,9 @@ export const actions: Actions = {
}
});
if (createError || !track) {
return fail(502, { message: 'No se pudo crear la pista tras la subida.' });
}
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
if (trackFailure) return trackFailure;
const track = trackResult.data!;
return { success: true, trackId: track.id, title: track.title };
}
+75 -120
View File
@@ -4,7 +4,14 @@
import Music from '@lucide/svelte/icons/music';
import ImageIcon from '@lucide/svelte/icons/image';
import Check from '@lucide/svelte/icons/check';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import Surface from '$lib/components/ui/Surface.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import Field from '$lib/components/ui/Field.svelte';
import Input from '$lib/components/ui/Input.svelte';
import Checkbox from '$lib/components/ui/Checkbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done';
@@ -41,15 +48,13 @@
});
const dropClass = $derived(
dragging || audioName !== ''
? 'border-emerald-500/60'
: 'border-white/15 hover:border-emerald-500/60'
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
);
function stepClass(s: StepState) {
if (s === 'active') return 'border-emerald-500/40 bg-emerald-500/5';
if (s === 'done') return 'border-emerald-500/40';
return 'border-white/15';
if (s === 'active') return 'border-accent/40 bg-accent/5';
if (s === 'done') return 'border-accent/40';
return '';
}
function formatSize(bytes: number) {
@@ -101,71 +106,51 @@
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
</svelte:head>
<section class="px-8 pt-8 pb-16 animate-[fade-up_0.45s_cubic-bezier(0.16,1,0.3,1)]">
<a
href="/library"
class="group inline-flex items-center gap-2 text-sm font-medium text-neutral-400 transition-colors hover:text-white"
>
<span
class="grid h-8 w-8 place-items-center rounded-lg border border-white/15 transition-all duration-200 group-hover:-translate-x-0.5 group-hover:border-emerald-500/60 group-hover:text-emerald-400"
>
<ArrowLeft class="h-4 w-4" />
</span>
Volver a la biblioteca
</a>
<h1 class="mt-6 font-display text-4xl font-bold tracking-tight sm:text-5xl">Subir música</h1>
<p class="mt-4 text-lg text-neutral-400">
Añade una canción con su portada y título. Nosotros la procesamos para streaming.
</p>
<Page>
<PageHeader
title="Subir música"
subtitle="Añade una canción con su portada y título. Nosotros la procesamos para streaming."
/>
<ol class="mt-10 grid gap-4 sm:grid-cols-3">
{#each steps as step, i (step.key)}
<li class="rounded-xl border p-5 transition {stepClass(stepStates[i])}">
<div class="flex items-center gap-3">
<span
class="grid h-8 w-8 place-items-center rounded-full bg-[var(--mf-accent)] text-sm font-bold text-neutral-950"
>
{#if stepStates[i] === 'done'}
<Check class="h-4 w-4" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
</span>
<span class="text-base font-semibold">{step.label}</span>
</div>
<p class="mt-3 text-sm text-neutral-500">{step.hint}</p>
<li>
<Surface 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"
>
{#if stepStates[i] === 'done'}
<Check class="h-4 w-4" strokeWidth={2.5} />
{:else}
{i + 1}
{/if}
</span>
<span class="text-base font-semibold text-fg">{step.label}</span>
</div>
<p class="mt-3 text-base text-muted">{step.hint}</p>
</Surface>
</li>
{/each}
</ol>
{#if status === 'done'}
<div class="mt-10 rounded-xl border border-emerald-500/30 bg-emerald-500/10 p-12 text-center">
<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-[var(--mf-accent)] text-neutral-950"
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} />
</span>
<h2 class="mt-6 font-display text-2xl font-bold tracking-tight">¡Subida!</h2>
<p class="mt-3 text-lg text-neutral-300">
<h2 class="mt-6 text-2xl font-bold tracking-tight text-fg">¡Subida!</h2>
<p class="mt-3 text-fg-2">
«{publishedTitle}» se está procesando. Aparecerá lista para reproducir en tu biblioteca en
unos momentos.
</p>
<div class="mt-8 flex items-center justify-center gap-4">
<a
href="/library"
class="rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 transition hover:brightness-110"
>
Ir a la biblioteca
</a>
<button
type="button"
onclick={reset}
class="rounded-lg border border-white/10 px-8 py-3.5 text-base text-white/65 transition hover:bg-white/2.5"
>
Subir otra
</button>
<Button href="/library" size="lg">Ir a la biblioteca</Button>
<Button variant="secondary" size="lg" onclick={reset}>Subir otra</Button>
</div>
</div>
</Surface>
{:else}
<form
method="POST"
@@ -197,7 +182,7 @@
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
class="relative flex min-h-72 flex-col items-center justify-center rounded-xl border border-dashed bg-neutral-950 p-10 text-center transition {dropClass}"
class="relative flex min-h-72 flex-col items-center justify-center rounded-panel border border-dashed bg-surface p-10 text-center transition {dropClass}"
>
<input
bind:this={audioInput}
@@ -208,7 +193,7 @@
class="absolute inset-0 cursor-pointer opacity-0"
aria-label="Seleccionar archivo de audio"
/>
<span class="grid h-20 w-20 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950">
<span class="grid h-20 w-20 place-items-center rounded-full bg-accent-soft text-on-accent">
{#if audioName === ''}
<Headphones class="h-9 w-9" />
{:else}
@@ -216,21 +201,20 @@
{/if}
</span>
{#if audioName === ''}
<p class="mt-5 text-lg font-medium text-neutral-200">
<p class="mt-5 text-lg font-medium text-fg">
Arrastra tu audio aquí o haz clic para elegir
</p>
<p class="mt-2 text-sm text-neutral-500">MP3, FLAC, WAV…</p>
<p class="mt-2 text-sm text-muted">MP3, FLAC, WAV…</p>
{:else}
<p class="mt-5 max-w-full truncate text-lg font-medium text-neutral-100">{audioName}</p>
<p class="mt-2 text-sm text-neutral-500">{formatSize(audioSize)} · Listo para subir</p>
<p class="mt-5 max-w-full truncate text-lg font-medium text-fg">{audioName}</p>
<p class="mt-2 text-sm text-muted">{formatSize(audioSize)} · Listo para subir</p>
{/if}
</div>
<div class="grid items-start gap-8 sm:grid-cols-[auto_1fr]">
<div>
<span class="mb-3 block text-sm font-medium text-neutral-300">Portada</span>
<Field label="Portada">
<label
class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-xl border border-white/15 bg-neutral-950 transition hover:border-emerald-500/40"
class="relative grid h-44 w-44 cursor-pointer place-items-center overflow-hidden rounded-control border border-line bg-surface transition hover:border-accent/50"
>
<input
type="file"
@@ -243,86 +227,57 @@
{#if coverPreview !== ''}
<img src={coverPreview} alt="Portada" class="h-full w-full object-cover" />
{:else}
<ImageIcon class="h-12 w-12 text-neutral-500" />
<ImageIcon class="h-12 w-12 text-muted" />
{/if}
</label>
</div>
</Field>
<div class="flex flex-col">
<label for="title" class="mb-3 block text-sm font-medium text-neutral-300">Título</label>
<input
<Field label="Título" for="title">
<Input
id="title"
name="title"
type="text"
bind:value={title}
maxlength={MAX_TITLE}
placeholder="Nombre de la canción"
class="w-full rounded-lg border border-white/15 bg-neutral-950 px-5 py-4 text-base text-neutral-100 placeholder:text-neutral-500 focus:border-emerald-500/60 focus:outline-none"
/>
<div class="mt-3 flex items-center justify-between gap-3 text-sm text-neutral-500">
{#snippet hint()}
<p>Se usa como nombre público de la pista en el catálogo.</p>
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
</div>
</div>
{/snippet}
</Field>
</div>
<label class="flex items-center gap-3 text-sm text-neutral-300">
<span class="relative inline-grid h-5 w-5 shrink-0 place-items-center">
<input
type="checkbox"
bind:checked={acceptedTerms}
class="peer h-5 w-5 cursor-pointer appearance-none rounded border border-white/15 bg-neutral-950 transition checked:border-emerald-500 checked:bg-[var(--mf-accent)] focus-visible:border-emerald-500/60 focus-visible:outline-none"
/>
<Check
class="pointer-events-none absolute h-3.5 w-3.5 text-neutral-950 opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span>
Acepto los
<a
href="/terms"
class="text-emerald-400 underline underline-offset-2 transition hover:text-emerald-300"
>
términos y condiciones
</a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</span>
</label>
<Checkbox bind:checked={acceptedTerms}>
Acepto los
<a
href="/terms"
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
>
términos y condiciones
</a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</Checkbox>
{#if status === 'uploading'}
<div class="rounded-xl border border-white/15 bg-neutral-950 p-6">
<Surface>
<div class="flex items-center justify-between text-base">
<span class="font-medium text-neutral-200">Subiendo y creando la pista…</span>
<span class="text-neutral-500">un momento</span>
<span class="font-medium text-fg">Subiendo y creando la pista…</span>
<span class="text-muted">un momento</span>
</div>
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-white/10">
<div class="h-full w-full animate-pulse rounded-full bg-[var(--mf-accent)]"></div>
<div class="mt-4 h-2.5 overflow-hidden rounded-full bg-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
</div>
</div>
</Surface>
{/if}
{#if status === 'error'}
<div class="rounded-xl border border-red-500/40 bg-red-500/10 p-5 text-sm text-red-200">
{errorMsg}
</div>
<Alert tone="danger">{errorMsg}</Alert>
{/if}
<div class="flex items-center justify-end gap-4">
<a
href="/explore"
class="rounded-lg border border-white/10 px-8 py-3.5 text-base text-white/65 transition hover:bg-white/2.5"
>
Cancelar
</a>
<button
type="submit"
disabled={!canPublish}
class="cursor-pointer rounded-lg bg-[var(--mf-accent)] px-8 py-3.5 text-base font-semibold text-neutral-950 backdrop-blur-md transition hover:brightness-110 hover:shadow-[0_10px_28px_-8px] active:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:shadow-none"
>
Publicar
</button>
<Button href="/explore" variant="secondary" size="lg">Cancelar</Button>
<Button type="submit" size="lg" disabled={!canPublish}>Publicar</Button>
</div>
</form>
{/if}
</section>
</Page>
+9 -6
View File
@@ -1,12 +1,12 @@
<script lang="ts">
import '@fontsource-variable/manrope/index.css';
import '@fontsource-variable/sora/index.css';
import '@fontsource-variable/inter/index.css';
import '$lib/theme/theme.css';
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import PlayerBar from '$lib/components/player/PlayerBar.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import { player } from '$lib/player/player.svelte';
import { ACCENT_LIGHTNESS, ACCENT_CHROMA, ACCENT_HUE } from '$lib/theme/color';
import { page } from '$app/state';
let { children, data } = $props();
@@ -23,7 +23,7 @@
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
}
let displayed = { c: 0.17, h: 145 };
let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
let rafId = 0;
$effect(() => {
@@ -45,7 +45,10 @@
const c = from.c + (target.c - from.c) * e;
const h = (((from.h + dh * e) % 360) + 360) % 360;
displayed = { c, h };
document.documentElement.style.setProperty('--mf-accent', `oklch(72% ${c.toFixed(3)} ${h.toFixed(1)})`);
document.documentElement.style.setProperty(
'--mf-accent',
`oklch(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
);
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
@@ -55,11 +58,11 @@
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{#if isAuthPage}
<div class="min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased">
<div class="min-h-screen bg-bg text-fg antialiased">
{@render children()}
</div>
{:else}
<div class="flex min-h-screen bg-[var(--mf-bg)] text-[var(--mf-text)] antialiased">
<div class="flex min-h-screen bg-bg text-fg antialiased">
<Sidebar user={data.user!} />
<div class="flex min-w-0 flex-1 flex-col">
<main class="flex-1 transition-[padding] {hasTrack ? 'pb-[92px]' : 'pb-0'}">
+21 -12
View File
@@ -1,11 +1,14 @@
import type { PageServerLoad } from './$types';
import { createApiClient } from '$lib/server/api';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, playlistCoverTrackIds } from '$lib/server/api';
import { fetchYoutubeFiller, shuffle } from '$lib/server/youtube';
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
import { HOME_LATEST_PAGE_SIZE, YOUTUBE_FILLER_LIMIT } from '$lib/config';
export const load: PageServerLoad = async ({ locals, fetch }) => {
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const latestPromise = api.GET('/tracks', {
params: { query: { pageNumber: 1, pageSize: 12 } }
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
});
const playlistsPromise = locals.user
? api.GET('/playlists/users/{userId}', {
@@ -22,20 +25,26 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
recentlyPlayedPromise
]);
const playlistItems = playlists.data?.items ?? [];
const trackIds = await playlistCoverTrackIds(
api,
playlistItems.map((p) => p.id)
);
const covers = await Promise.all(
playlistItems.map(async (playlist) => {
const { data: tracks } = await api.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 4 } }
});
return [playlist.id, tracks?.items.map((t) => t.id) ?? []] as const;
})
const novedades = shuffle(
(latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))
);
return {
latest: latest.data?.items ?? [],
novedades,
novedadesHasNext: Boolean(latest.data?.hasNextPage),
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
playlists: playlistItems,
trackIds: Object.fromEntries(covers) as Record<string, string[]>,
trackIds,
recentlyPlayed: recentlyPlayed.data ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
+237 -107
View File
@@ -1,32 +1,192 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
import { HUES } from '$lib/theme/color';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.svelte';
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
import { HOME_LATEST_PAGE_SIZE } from '$lib/config';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import TrackTile from '$lib/components/TrackTile.svelte';
import TrackContextMenu, {
contextMenuStateFor,
type ContextMenuTarget,
type ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections.js';
let { data } = $props();
const latest = $derived(data.latest);
let youtubeFillerItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytQuery = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
type LocalNovedad = (typeof data.novedades)[number];
let localNovedades = $state<LocalNovedad[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let loadingMore = $state(false);
$effect(() => {
youtubeFillerItems = [];
ytContinuation = '';
ytQuery = '';
localNovedades = data.novedades;
localPage = 1;
localHasNext = data.novedadesHasNext;
data.youtubeFiller.then((filler) => {
youtubeFillerItems = filler.items;
ytContinuation = filler.continuationToken;
ytQuery = filler.query;
});
});
const novedades = $derived([
...localNovedades,
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMoreNovedades = $derived(localHasNext || ytContinuation !== '');
async function loadMoreNovedades() {
if (loadingMore) return;
loadingMore = true;
try {
if (localHasNext) {
const params = new URLSearchParams({
pageNumber: String(localPage + 1),
pageSize: String(HOME_LATEST_PAGE_SIZE)
});
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = await res.json();
localNovedades = appendUnique(
localNovedades,
next.items.map((track: LocalNovedad['track']) => ({
kind: 'local' as const,
track
})),
item => item.track.id
);
localPage = Number(next.pageNumber);
localHasNext = Boolean(next.hasNextPage);
} else if (ytContinuation && ytQuery) {
const params = new URLSearchParams({ query: ytQuery, continuation: ytContinuation });
const res = await fetch(`/api/youtube/search?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
youtubeFillerItems = appendUnique(
youtubeFillerItems,
next.items,
item => item.videoId
);
ytContinuation = next.continuationToken;
}
} catch {
if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally {
loadingMore = false;
}
}
const playlists = $derived(data.playlists);
const trackIds = $derived(data.trackIds);
interface RecentItem {
id: string;
title: string;
artist?: string;
source: 'local' | 'youtube';
coverUrl?: string;
function novedadId(item: (typeof novedades)[number]) {
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
}
function novedadTitle(item: (typeof novedades)[number]) {
return item.kind === 'youtube' ? item.song.title : item.track.title;
}
function novedadArtist(item: (typeof novedades)[number]) {
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
}
function novedadExplicit(item: (typeof novedades)[number]) {
return item.kind === 'youtube' ? item.song.isExplicit : false;
}
function novedadQueueItem(item: ContextMenuTarget): 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
}
: toQueueItems([item.track])[0];
}
function playNovedad(index: number) {
player.playOrToggle(novedades.map(novedadQueueItem), index);
}
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(novedadQueueItem(contextMenu));
closeContextMenu();
}
function contextMenuTargetFor(item: QueueItem): ContextMenuTarget {
return item.source === 'youtube'
? {
kind: 'youtube',
song: {
videoId: String(item.id),
title: item.title,
artist: item.artist ?? '',
album: '',
durationSeconds: 0,
thumbnailUrl: item.coverUrl ?? '',
isExplicit: item.explicit ?? false
}
}
: {
kind: 'local',
track: {
id: item.id,
title: item.title,
artist: item.artist,
source: 'Local',
isExplicit: item.explicit
}
};
}
const recentlyPlayed = $derived.by(() => {
const seen = new Set<string>();
const merged: RecentItem[] = [];
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 });
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);
@@ -37,7 +197,8 @@
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl
coverUrl: item.coverUrl,
explicit: item.explicit
});
}
return merged.slice(0, 15);
@@ -61,25 +222,8 @@
return /[.!?]$/.test(pick) ? pick : `${pick}.`;
})();
const listasIsFirstSection = $derived(recentlyPlayed.length === 0 && playlists.length > 0);
const novedadesIsFirstSection = $derived(recentlyPlayed.length === 0 && playlists.length === 0);
function hueFor(id: string) {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function playLatest(index: number) {
const track = latest[index];
if (player.current.id === queueIdForTrack(track)) player.toggle();
else player.playQueue(toQueueItems(latest), index);
}
function playRecent(index: number) {
const track = recentlyPlayed[index];
if (player.current.id === track.id) player.toggle();
else player.playQueue(recentlyPlayed, index);
player.playOrToggle(recentlyPlayed, index);
}
</script>
@@ -88,28 +232,32 @@
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
<section class="relative overflow-hidden px-8 pb-16 pt-11">
<section class="page-x relative overflow-hidden pt-11 pb-16">
<div
class="pointer-events-none absolute inset-0 blur-[70px] saturate-150"
style="background:linear-gradient(135deg, var(--mf-accent), color-mix(in oklch, var(--mf-accent), black 55%));animation:breathe 9s ease-in-out infinite"
></div>
<div class="animate-enter relative flex flex-col gap-2.5">
<p class="text-[13px] uppercase tracking-[0.14em] text-[var(--mf-text-3)]">{greeting}</p>
<h1 class="max-w-[640px] font-display text-[42px] font-extrabold leading-[1.05] tracking-tight">
<p class="tracking-[0.14em] text-fg-2 uppercase">{greeting}</p>
<h1 class="max-w-160 text-5xl leading-[1.05] font-extrabold text-fg">
Tu música. Sin límites.
</h1>
</div>
</section>
{#if recentlyPlayed.length > 0}
<section class="px-8 pb-3 pt-6">
<h2 class="mb-4 font-display text-xl font-bold">Escuchado recientemente</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(260px,1fr))] gap-3.5">
<section class="page-x pt-6 pb-3">
<SectionHeading title="Escuchado recientemente" />
<MediaGrid min="300px">
{#each recentlyPlayed as track, i (track.id)}
<button
type="button"
onclick={() => playRecent(i)}
class="flex items-center gap-3.5 rounded-xl bg-[var(--mf-surface)] p-2 text-left transition hover:bg-[var(--mf-surface-hover)]"
oncontextmenu={(e) => openContextMenu(e, contextMenuTargetFor(track))}
aria-label="{player.current.id === track.id && player.playing
? 'Pausar'
: 'Reproducir'} {track.title}"
class="flex min-w-0 items-center gap-3 rounded-control bg-surface p-2 text-left transition hover:bg-surface-hover"
>
<Cover
trackId={track.id}
@@ -117,95 +265,77 @@
hue={hueFor(track.id)}
size="small"
alt={track.title}
class="h-14 w-14 flex-shrink-0 rounded-lg"
class="h-14 w-14 flex-shrink-0 rounded-control"
>
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
{/if}
</Cover>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-[var(--mf-text)]">{track.title}</div>
<div class="flex min-w-0 items-center gap-1.5">
{#if track.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-fg">{track.title}</span>
</div>
{#if track.artist}
<div class="truncate text-xs text-neutral-500">{track.artist}</div>
<div class="truncate text-sm text-muted">{track.artist}</div>
{/if}
</div>
</button>
{/each}
</div>
</MediaGrid>
</section>
{/if}
{#if playlists.length > 0}
<section class="px-8 pb-3 {listasIsFirstSection ? 'pt-6' : 'pt-3'}">
<h2 class="mb-4 font-display text-xl font-bold">Mis listas</h2>
<div class="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
<section class="page-x pt-6 pb-3">
<SectionHeading title="Mis listas" />
<MediaGrid>
{#each playlists as playlist, i (playlist.id)}
<a
href="/playlists/{playlist.id}"
class="group animate-enter text-left"
style="animation-delay:{i * 45}ms"
>
<PlaylistArt
playlistId={playlist.id}
trackIds={trackIds[playlist.id] ?? []}
hue={hueFor(playlist.id)}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)] transition group-hover:shadow-[0_16px_36px_-10px_rgba(0,0,0,0.75)]"
/>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">
{playlist.name}
</div>
</a>
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={trackIds[playlist.id] ?? []}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</div>
</section>
</MediaGrid>
</section>
{/if}
<section class="px-8 pb-8 {novedadesIsFirstSection ? 'pt-6' : 'pt-3'}">
<h2 class="mb-4 font-display text-xl font-bold">Novedades</h2>
{#if latest.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
{#each latest as track, i (track.id)}
<button
type="button"
onclick={() => playLatest(i)}
class="group animate-enter text-left"
style="animation-delay:{i * 45}ms"
>
<Cover
trackId={track.id}
hue={hueFor(track.id)}
size="large"
alt={track.title}
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
>
<span
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
class:!opacity-100={player.current.id === track.id}
class:!translate-y-0={player.current.id === track.id}
>
{#if player.current.id === track.id && player.playing}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M8 5v14l11-7z" />
</svg>
{/if}
</span>
</Cover>
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]">{track.title}</div>
</button>
<section class="page-x pt-6 pb-10">
<SectionHeading title="Novedades" />
{#if novedades.length > 0}
<MediaGrid as="ul">
{#each novedades as item, i (novedadId(item))}
<TrackTile
id={novedadId(item)}
title={novedadTitle(item)}
artist={novedadArtist(item)}
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
hue={hueFor(novedadId(item))}
explicit={novedadExplicit(item)}
active={player.current.id === novedadId(item)}
playing={player.isPlaying}
index={i}
onClick={() => playNovedad(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</div>
</MediaGrid>
<InfiniteScroll
onLoadMore={loadMoreNovedades}
hasMore={hasMoreNovedades}
loading={loadingMore}
/>
{:else}
<div class="max-w-[1400px] rounded-2xl border border-[var(--mf-border)] px-8 py-12 text-center">
<Music class="mx-auto h-9 w-9 text-[var(--mf-text-4)]" />
<h3 class="mt-4 font-display text-xl font-bold">Todavía no hay música</h3>
<p class="mt-2 text-sm text-[var(--mf-text-2)]">
<a href="/upload" class="text-emerald-400 underline underline-offset-2">Sube una canción</a>
y empieza a escuchar.
</p>
</div>
<EmptyState icon={Music} title="Todavía no hay música" />
{/if}
</section>
{#if contextMenu}
<TrackContextMenu menu={contextMenu} {playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
{/if}
@@ -1,25 +1,6 @@
import type { RequestHandler } from './$types';
import { apiConfig } from '$lib/server/config';
const ALLOWED = new Set(['small', 'medium', 'large']);
import { proxyCoverImage } from '$lib/server/image';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
const size = url.searchParams.get('size') ?? 'medium';
const safeSize = ALLOWED.has(size) ? size : 'medium';
const upstream = await fetch(
`${apiConfig.baseUrl}/playlists/${params.id}/cover?size=${safeSize}`
);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
return proxyCoverImage(fetch, 'playlists', params.id, url.searchParams.get('size'));
};
+15
View File
@@ -0,0 +1,15 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', { params: { query: { name, pageNumber, pageSize } } });
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
};
@@ -1,25 +1,6 @@
import type { RequestHandler } from './$types';
import { apiConfig } from '$lib/server/config';
const ALLOWED = new Set(['small', 'medium', 'large']);
import { proxyCoverImage } from '$lib/server/image';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
const size = url.searchParams.get('size') ?? 'medium';
const safeSize = ALLOWED.has(size) ? size : 'medium';
const upstream = await fetch(
`${apiConfig.baseUrl}/tracks/${params.id}/cover?size=${safeSize}`
);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
return proxyCoverImage(fetch, 'tracks', params.id, url.searchParams.get('size'));
};
@@ -1,20 +1,14 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { json } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
if (!locals.accessToken) {
error(401, 'Inicia sesión para reproducir.');
}
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { data, error: err, response } = await api.GET('/tracks/{id}/stream', {
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/tracks/{id}/stream', {
params: { path: { id: params.id } }
});
if (err || !data) {
error(response?.status ?? 502, 'No se pudo obtener el stream de la pista.');
}
return json(data);
return json(unwrapOrError(result, 'No se pudo obtener el stream de la pista.'));
};
@@ -1,11 +1,9 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
if (!locals.accessToken) {
error(401, 'Inicia sesión para buscar.');
}
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
@@ -14,14 +12,10 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { data, error: err, response } = await api.GET('/youtube/search', {
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/search', {
params: { query: { query, continuation } }
});
if (err || !data) {
error(response?.status ?? 502, 'No se pudo buscar en YouTube Music.');
}
return json(data);
return json(unwrapOrError(result, 'No se pudo buscar en YouTube Music.'));
};
@@ -1,20 +1,14 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient } from '$lib/server/api';
import { json } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
if (!locals.accessToken) {
error(401, 'Inicia sesión para reproducir.');
}
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
const api = createApiClient({ fetch, accessToken: locals.accessToken });
const { data, error: err, response } = await api.GET('/youtube/tracks/{videoId}/stream', {
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/tracks/{videoId}/stream', {
params: { path: { videoId: params.videoId } }
});
if (err || !data) {
error(response?.status ?? 502, 'No se pudo obtener el stream de YouTube.');
}
return json(data);
return json(unwrapOrError(result, 'No se pudo obtener el stream de YouTube.'));
};
+33 -2
View File
@@ -1,9 +1,40 @@
@import 'tailwindcss';
@theme {
--font-sans: 'Manrope Variable', ui-sans-serif, system-ui, sans-serif;
--font-display: 'Sora Variable', ui-sans-serif, system-ui, sans-serif;
--font-sans: 'Inter Variable', ui-sans-serif, system-ui, sans-serif;
--color-neutral-950: #08080a;
--color-bg: var(--mf-bg);
--color-elevated: var(--mf-elevated);
--color-surface: var(--mf-surface);
--color-surface-2: var(--mf-surface-2);
--color-surface-hover: var(--mf-surface-hover);
--color-hover: rgba(255, 255, 255, 0.05);
--color-line: var(--mf-border);
--color-line-strong: var(--mf-border-strong);
--color-track: var(--mf-track);
--color-fg: var(--mf-text);
--color-fg-2: var(--mf-text-2);
--color-fg-3: var(--mf-text-3);
--color-muted: var(--mf-text-4);
--color-accent: var(--mf-accent);
--color-accent-soft: var(--mf-accent-60);
--color-on-accent: #ffffff;
--color-danger: oklch(63.7% 0.237 25.331);
--color-danger-fg: oklch(88.5% 0.062 18.334);
--radius-control: 0.625rem;
--radius-panel: 1rem;
--radius-art: 0.75rem;
--radius-art-lg: 1rem;
--shadow-art: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
--shadow-art-lg: 0 20px 45px -15px rgba(0, 0, 0, 0.7);
--shadow-menu: 0 12px 28px -10px rgba(0, 0, 0, 0.6);
}
@utility page-x {
padding-inline: --spacing(8);
}
@utility scrollbar-stable {
+14 -15
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
let { data } = $props();
const returnParam = $derived(`returnTo=${encodeURIComponent(data.returnTo)}`);
@@ -9,31 +11,28 @@
<meta name="description" content="Inicia sesión o crea una cuenta en Musify." />
</svelte:head>
<div class="grid min-h-screen place-items-center bg-black px-6 text-[var(--mf-text)]">
<div class="grid min-h-screen place-items-center bg-bg px-6 text-fg">
<div class="flex w-full max-w-[440px] flex-col items-center gap-9 text-center">
<div class="font-display text-3xl font-extrabold tracking-tight">Musify</div>
<div class="text-3xl font-extrabold tracking-tight">Musify</div>
<div>
<h1 class="font-display text-[36px] font-extrabold tracking-tight">Tu música. Sin límites.</h1>
<p class="mt-3 text-base text-[var(--mf-text-2)]">Inicia sesión para continuar.</p>
<h1 class="text-4xl font-extrabold tracking-tight">Tu música. Sin límites.</h1>
<p class="mt-3 text-base text-fg-2">Inicia sesión para continuar.</p>
</div>
<div class="flex w-full flex-col gap-5">
<a
href={`/auth/login?${returnParam}`}
data-sveltekit-reload
class="rounded-lg px-6 py-3.5 text-base font-semibold transition hover:brightness-110"
style="background:var(--mf-accent);color:#000"
>
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
Iniciar Sesión
</a>
<a
</Button>
<Button
href={`/auth/login?mode=register&${returnParam}`}
data-sveltekit-reload
class="rounded-lg bg-[var(--mf-surface-2)] px-6 py-3.5 text-base font-semibold transition hover:bg-[var(--mf-surface-hover)]"
reload
variant="subtle"
size="lg"
class="w-full"
>
Crear Cuenta
</a>
</Button>
</div>
</div>
</div>