refactor: rename frontend/ to web-player/ and format with prettier

Updates the compose build context, the Dockerfile header, the package
name and the docs. The compose service and container keep the name
frontend/musify-frontend, since nginx routes to http://frontend:3000.

The source files also carry the first prettier pass, so the rename and
the reformat land together for them.
This commit is contained in:
2026-07-20 07:35:01 +02:00
parent 05514a1c6e
commit 97daa13ebd
104 changed files with 134 additions and 2457 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "musify-web",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173,
"autoPort": true
}
]
}
+23
View File
@@ -0,0 +1,23 @@
# Zitadel OIDC (server-side auth)
ZITADEL_ISSUER=http://host.docker.internal:8080
ZITADEL_CLIENT_ID=your-web-app-client-id
ZITADEL_CLIENT_SECRET=your-web-app-client-secret
# Where Zitadel redirects back after login (must be registered in the app)
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
AUTH_POST_LOGOUT_URI=http://localhost:5173/
# Secret used to derive the session cookie encryption key (any long random string)
SESSION_SECRET=change-me-to-a-long-random-string
# Optional session tuning (defaults shown)
# OIDC_SCOPE=openid profile email offline_access
# SESSION_TTL_SECONDS=604800
# SESSION_REFRESH_THRESHOLD_SECONDS=30
# AUTH_FLOW_TTL_SECONDS=600
# Dev already allows a plain-HTTP issuer. Set this only to allow it in a production build (never with a public issuer).
# AUTH_ALLOW_INSECURE_HTTP=true
# Backend API base URL (used from later parts)
API_BASE_URL=http://localhost:5111
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+47
View File
@@ -0,0 +1,47 @@
# Musify — web-player
Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**.
## Stack
SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`).
No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`).
## Comandos
```sh
npm run dev # servidor de desarrollo (puerto 5173)
npm run build
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write .
npm run lint # prettier --check . && eslint .
```
## Reglas
- **Sin comentarios** en el código salvo que sean imprescindibles.
- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio.
- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave.
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`.
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
## Auth
`hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
## Backend API
Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server:
```ts
import { createApiClient } from '$lib/server/api';
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data, error: err } = await api.GET('/tracks');
```
Devuelve `{ data, error }` (estilo ErrorOr), no lanza. Los tipos viven en `src/lib/api/schema.d.ts` y se regeneran con `npm run gen:api` (requiere la API dev levantada en `API_BASE_URL`, que expone `/openapi/v1.json` solo en Development).
+17
View File
@@ -0,0 +1,17 @@
# Build context: web-player/
FROM node:22-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /src/build ./build
COPY --from=build /src/node_modules ./node_modules
COPY --from=build /src/package.json ./package.json
EXPOSE 3000
ENV PORT=3000
CMD ["node", "build/index.js"]
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.2 create --template minimal --types ts --add tailwindcss="plugins:none" --no-download-check --install npm web-player
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+159
View File
@@ -0,0 +1,159 @@
# Musify — Guía de páginas
Documento vivo de qué debe contener cada página del frontend web. Sirve como mapa
para priorizar features. El sistema de diseño (tokens, recetas, componentes) vive en
el skill `musify-web`; aquí describimos **contenido y estructura**, no estilos.
Leyenda de estado: ✅ hecho · 🟡 parcial / maqueta · ⬜ pendiente.
> **Nota (rediseño app):** se está portando el prototipo `Musify.dc.html` (app: sidebar +
> vistas + player persistente con acento dinámico por pista). La landing de marketing se
> eliminó. Las secciones de abajo describen el diseño anterior y quedan pendientes de
> actualizar a las vistas de la app (Inicio, Buscar, Biblioteca, Subir, Importar, Descargas,
> Playlists). El **player** ya está hecho por componentes en `$lib/components/player/` con el
> tema en `$lib/theme/` (`theme.css` paleta + `color.ts` acento/gradiente).
## Inicio (app) `/` 🟡
- **Hero**: saludo + "Tu música. Sin límites." con color-wash del hue de la pista actual.
- **Acceso rápido = escuchado recientemente** (`player.recentlyPlayed`, hoy en cliente).
Estado vacío con icono + CTA «Explorar».
- **Tus playlists**: tarjetas mosaico de las playlists (hoy datos de muestra). Estado vacío.
### Backend pendiente (ASP.NET) para Inicio
- **Escuchado recientemente**: registrar reproducciones por usuario (tabla de historial:
`userId`, `trackId`, `playedAt`) y exponer `GET /me/recently-played?limit=` (o
`/users/{id}/recently-played`) devolviendo pistas ordenadas por `playedAt` desc, sin
duplicados. El front lo cargaría en un `load` y lo pasaría al store en vez de `recentlyPlayed`.
- **Playlists por última escucha**: añadir `lastPlayedAt` a la playlist (hoy el schema solo
tiene `createdAt`/`updatedAt`) y permitir ordenar `GET /playlists/users/{userId}` por ese
campo (p. ej. `?sort=lastPlayed`), para mostrar «tus últimas escuchadas».
- Las pistas necesitarán **artista** y un **color/hue** (o portada real de la que extraerlo)
para el arte y el acento dinámico; hoy `TrackApplicationResponse` no trae artista ni color.
## Estructura de rutas
Rutas agrupadas para separar lo público de lo autenticado (los grupos no cambian la URL):
```
src/routes/
(marketing)/ Público, sin sesión obligatoria
+page.svelte / Landing ✅
(app)/ Experiencia de la app
explore/ /explore Explorar / buscar ✅
upload/ /upload Subir música 🟡 maqueta
library/ /library Tu biblioteca ⬜
playlists/ /playlists Tus playlists ⬜
[id]/ /playlists/:id Detalle playlist ⬜
login logout auth/callback Auth (endpoints) ✅
api/tracks/[id]/stream Proxy de stream ✅
```
Componentes globales (en todas las páginas): **Navbar**, **Footer** y **PlayerBar**
(barra de reproducción fija, aparece al reproducir).
---
## 1. Landing `/` ✅ (marketing)
Página de entrada para no autenticados. Vender el producto y llevar a registro/explorar.
- **Hero**: badge de estado (beta), título, subtítulo, CTAs primario (Empezar gratis →
registro) y secundario (Explorar canciones).
- **Features**: 3 cards (escuchar, playlists, subir).
- **Pendiente sugerido**: sección de capturas/demo, prueba social, FAQ corta.
## 2. Login / Auth ✅ (endpoints)
No es una página visual propia: `/login` inicia el flujo OIDC de Zitadel y redirige.
`?mode=register` arranca en registro; `?returnTo=` vuelve a la ruta pedida tras entrar.
`/logout` cierra sesión. `/auth/callback` intercambia el código y crea la cookie
cifrada `mf_session`. La UI de credenciales la sirve Zitadel, no Musify.
## 3. Explorar / Buscar `/explore` ✅ (app)
Descubrimiento y búsqueda de canciones del catálogo.
- **Buscador**: input de texto (`?q=`) que filtra por nombre en el backend.
- **Contador de resultados** y **grid de tracks** (2→4 columnas): portada, título, fecha,
botón play/pausa en hover que encola y reproduce vía el player global.
- **Paginación** anterior/siguiente (`?page=`, 24 por página).
- **Estado vacío**: sin resultados / catálogo vacío.
- **Pendiente sugerido**: filtros (más recientes, por usuario), skeletons de carga,
portada real del track (hoy la API de listado no devuelve la key de imagen).
## 4. Subir música `/upload` 🟡 maqueta (app)
Alta de una canción nueva. Flujo real de backend (para cuando se cablee):
1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s.
2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`.
4. El backend procesa el audio (transcodifica a DASH) de forma asíncrona.
Contenido de la página:
- **Zona de audio**: drag & drop o selector; muestra nombre y tamaño del archivo, validación
de tipo (audio) y tamaño.
- **Portada**: selector de imagen con vista previa cuadrada.
- **Metadatos**: título (obligatorio). Espacio para futuros campos (descripción, etc.).
- **Pipeline visible**: pasos Subir → Procesar → Publicar con su estado.
- **Barra de progreso** de subida y estado de procesado.
- **Estados**: vacío, archivo seleccionado, subiendo, procesando, publicado, error.
- **Requiere sesión** (al cablear: redirigir a `/login?returnTo=/upload`).
## 5. Tu biblioteca `/library` ⬜ (app)
Panel personal del usuario autenticado: lo suyo en un sitio.
- **Tabs / secciones**: «Tus canciones» (`GET /tracks/users/{userId}`) y «Tus playlists»
(`GET /playlists/users/{userId}`).
- **Tus canciones**: lista con play, y acciones de gestión (borrar `DELETE /tracks/{id}`,
añadir a playlist).
- **Tus playlists**: grid de portadas + botón «Crear playlist».
- **Estado vacío** con CTA a subir / crear.
- **Requiere sesión**.
## 6. Playlists `/playlists` ⬜ (app)
Listado de las playlists del usuario.
- **Grid** de playlists (portada, nombre, nº de tracks) → enlaza al detalle.
- **Crear playlist**: modal/página con nombre, descripción y portada
(`POST /playlists`, portada vía `POST /playlists/upload-picture`).
- **Estado vacío** con CTA.
- **Requiere sesión**.
## 7. Detalle de playlist `/playlists/[id]` ⬜ (app)
- **Cabecera**: portada grande, nombre, descripción, nº de tracks, botón «Reproducir todo».
- **Lista de tracks** (`GET /playlists/{id}/tracks`, paginada): índice, título, fecha,
play, quitar de la playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
- **Acciones del dueño**: editar (`PUT`), borrar (`DELETE`) la playlist, reordenar (futuro).
- **Añadir tracks**: desde explorar/biblioteca (`POST /playlists/{id}/tracks/{trackId}`).
## 8. Perfil de usuario `/users/[id]` ⬜ (futuro)
Vista pública de un usuario: avatar, nombre, sus canciones y playlists públicas
(`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
---
## Player global (PlayerBar) ✅
Barra fija inferior visible al reproducir. Info de la pista, transporte
(anterior/play-pausa/siguiente), barra de progreso y control de volumen. Encola listas
desde cualquier grid (`player.playQueue`). Reproduce DASH vía el proxy
`/api/tracks/:id/stream`, que pide manifiesto + ticket firmado al backend.
## Backend disponible (resumen)
| Recurso | Endpoints |
| --------- | ----------------------------------------------------------------------- |
| Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
| Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
| Users | listar/buscar, detalle, crear |
Cliente tipado en `$lib/server/api.ts` (`openapi-fetch`, estilo ErrorOr). Consumir en
`load`/actions server, nunca desde cliente. Tipos en `src/lib/api/schema.d.ts`.
+12
View File
@@ -0,0 +1,12 @@
import type { SessionUser } from '$lib/types';
declare global {
namespace App {
interface Locals {
user: SessionUser | null;
accessToken: string | null;
}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en" class="scrollbar-stable">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+44
View File
@@ -0,0 +1,44 @@
import type { Handle } from '@sveltejs/kit';
import {
decodeSession,
encodeSession,
refreshSession,
sessionCookieOptions,
SESSION_COOKIE
} from '$lib/server/auth';
import { authConfig } from '$lib/server/config';
export const handle: Handle = async ({ event, resolve }) => {
let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
if (
session &&
session.expiresAt - authConfig.refreshThresholdSeconds <= Math.floor(Date.now() / 1000)
) {
session = await refreshSession(session);
if (session) {
event.cookies.set(
SESSION_COOKIE,
await encodeSession(session),
sessionCookieOptions(event.url)
);
} else {
event.cookies.delete(SESSION_COOKIE, { path: '/' });
}
}
if (session) {
event.locals.user = {
sub: session.sub,
name: session.name,
email: session.email,
picture: session.picture
};
event.locals.accessToken = session.accessToken;
} else {
event.locals.user = null;
event.locals.accessToken = null;
}
return resolve(event);
};
+1526
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+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;
}
@@ -0,0 +1,146 @@
<script lang="ts">
import { page } from '$app/state';
import type { SessionUser } from '$lib/types';
import Home from '@lucide/svelte/icons/house';
import Search from '@lucide/svelte/icons/search';
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';
interface Props {
user: SessionUser;
}
let { user }: Props = $props();
let menuOpen = $state(false);
let menuRef: HTMLDivElement | undefined = $state();
let navBox: HTMLElement | undefined = $state();
let pillTop = $state(0);
let pillHeight = $state(0);
let pillVisible = $state(false);
let animate = $state(false);
function onDocumentClick(event: MouseEvent) {
if (menuRef && !menuRef.contains(event.target as Node)) menuOpen = false;
}
const mainLinks = [
{ href: '/', label: 'Inicio', icon: Home },
{ href: '/explore', label: 'Buscar', icon: Search },
{ href: '/playlists', label: 'Listas', icon: Music }
];
const secondaryLinks = [
{ href: '/library', label: 'Canciones subidas', icon: Folder },
{ href: '/upload', label: 'Subir música', icon: Upload }
];
function isActive(href: string) {
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
$effect(() => {
void page.url.pathname;
const box = navBox;
if (!box) return;
const active = box.querySelector<HTMLElement>('[data-active="true"]');
if (!active) {
pillVisible = false;
return;
}
const boxRect = box.getBoundingClientRect();
const rect = active.getBoundingClientRect();
pillTop = rect.top - boxRect.top;
pillHeight = rect.height;
pillVisible = true;
if (!animate) requestAnimationFrame(() => (animate = true));
});
</script>
{#snippet navLink(link: { href: string; label: string; icon: typeof Home })}
{@const active = isActive(link.href)}
<a
href={link.href}
data-active={active}
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-6 w-6 shrink-0" style={active ? 'color:var(--mf-accent-60)' : ''} />
{link.label}
</a>
{/snippet}
<aside
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 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-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"
style="background:var(--mf-accent-60)"
></span>
</div>
<nav class="relative z-10 flex flex-col gap-1">
{#each mainLinks as link (link.href)}
{@render navLink(link)}
{/each}
</nav>
<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)}
{@render navLink(link)}
{/each}
</nav>
</div>
<div class="relative mt-auto" bind:this={menuRef}>
{#if menuOpen}
<div
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-base font-semibold text-fg-2 transition hover:bg-hover"
>
<LogOut class="h-[18px] w-[18px]" strokeWidth={2} />
Salir
</button>
</form>
</div>
{/if}
<button
type="button"
onclick={() => (menuOpen = !menuOpen)}
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-surface-2 text-sm font-semibold uppercase"
>
{user.name.charAt(0)}
</span>
{/if}
<span class="truncate text-base leading-[1.35] font-semibold">{user.name}</span>
</button>
</div>
</aside>
<svelte:window onclick={onDocumentClick} />
@@ -0,0 +1,121 @@
<script lang="ts" module>
import type { TrackTarget } from '$lib/tracks';
export type ContextMenuState = TrackTarget & { x: number; y: number; openLeft: boolean };
const MENU_WIDTH = 208;
const SUBMENU_WIDTH = 224;
export function contextMenuStateFor(event: MouseEvent, target: TrackTarget): 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}</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>
@@ -0,0 +1,23 @@
<script lang="ts">
import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte';
</script>
<div
class="animate-enter fixed right-0 bottom-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"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
>
<TrackInfo />
<div class="flex flex-col items-center gap-2">
<TransportControls />
<SeekBar />
</div>
<VolumeControl />
</div>
</div>
@@ -0,0 +1,33 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/theme/color';
function seek(event: MouseEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
player.seekFraction((event.clientX - rect.left) / rect.width);
}
</script>
<div class="flex w-full max-w-172 items-center gap-3">
<span class="text-right text-xs text-muted tabular-nums">
{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-track transition-[height] hover:h-2"
>
<div
class="absolute inset-y-0 left-0 rounded-full bg-accent/60 group-hover:brightness-110"
style="width:{player.progressPercent}%"
></div>
<span
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-opacity duration-75 group-hover:opacity-100"
style="left:{player.progressPercent}%"
></span>
</button>
<span class="text-xs text-(--mf-text-4) tabular-nums">
{fmtTime(player.current.duration)}
</span>
</div>
@@ -0,0 +1,32 @@
<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">
{#if player.current.id}
<Cover
trackId={player.current.id}
src={player.current.coverUrl}
hue={player.current.hue}
size="small"
alt={player.current.title}
class="h-13 w-13 shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
/>
{:else}
<div
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="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-sm text-muted">{player.current.artist || '—'}</div>
</div>
</div>
@@ -0,0 +1,47 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
</script>
<div class="flex items-center gap-5">
<button
type="button"
onclick={() => player.previous()}
aria-label="Anterior"
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" />
<path d="M15 0 5 7l10 7z" />
</svg>
</button>
<button
type="button"
onclick={() => player.toggle()}
aria-label={player.playing ? 'Pausar' : 'Reproducir'}
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">
<rect x="1.5" y="0" width="3.5" height="12" rx="1" />
<rect x="7" y="0" width="3.5" height="12" rx="1" />
</svg>
{:else}
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M2.5 0 12 6l-9.5 6z" />
</svg>
{/if}
</button>
<button
type="button"
onclick={() => player.next()}
aria-label="Siguiente"
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" />
<rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg>
</button>
</div>
@@ -0,0 +1,51 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
function handleVolumeChange(event: Event) {
const target = event.currentTarget as HTMLInputElement;
const newVolume = Number(target.value);
player.setVolume(newVolume);
}
</script>
<div class="flex items-center justify-end gap-3.5">
<input
type="range"
min="0"
max="100"
value={player.volume}
oninput={handleVolumeChange}
aria-label="Volumen"
class="mf-volume"
style="background:linear-gradient(to right, var(--mf-accent-60) {player.volume}%, var(--mf-track) {player.volume}%)"
/>
</div>
<style>
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
border-radius: 100px;
outline: none;
cursor: pointer;
}
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
border: none;
cursor: pointer;
}
</style>
@@ -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,52 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { gradientForHue } from '$lib/theme/color';
interface Props {
trackId: string | number;
hue: number;
size?: 'small' | 'medium' | 'large';
src?: string;
alt?: string;
class?: string;
children?: Snippet;
}
let {
trackId,
hue,
size = 'medium',
src,
alt = '',
class: klass = '',
children
}: Props = $props();
let failed = $state(false);
let loaded = $state(false);
const imageSrc = $derived(src ?? `/api/tracks/${trackId}/cover?size=${size}`);
$effect(() => {
imageSrc;
failed = false;
loaded = false;
});
</script>
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
>
{#if !failed}
<img
src={imageSrc}
{alt}
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -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,49 @@
<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,25 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
{#each bars as bar, i (i)}
<span
class="w-[3px] origin-bottom rounded-full bg-white"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -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>
@@ -0,0 +1,44 @@
<script lang="ts">
import Cover from './Cover.svelte';
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', version, class: klass = '' }: Props = $props();
let coverFailed = $state(false);
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
</script>
{#if playlistId && !coverFailed}
<div class="relative overflow-hidden {klass}" style="background:{gradientForHue(hue)}">
<img
src="/api/playlists/{playlistId}/cover?size={size}{version
? `&v=${encodeURIComponent(version)}`
: ''}"
alt=""
loading="lazy"
onerror={() => (coverFailed = true)}
class="h-full w-full object-cover"
/>
</div>
{:else if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}
@@ -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,96 @@
<script lang="ts">
import { untrack } from 'svelte';
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(untrack(() => initialName));
let description = $state(untrack(() => 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="min-h-0 w-full 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 text-muted tabular-nums {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}
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;
+420
View File
@@ -0,0 +1,420 @@
import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue, hueFor } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export type TrackSourceKind = 'local' | 'youtube';
export interface PlayerTrack {
id: string | number;
title: string;
artist: string;
duration: number;
hue: number;
source: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface Playlist {
id: string | number;
name: string;
trackIds: (string | number)[];
}
export interface QueueItem {
id: string | number;
title: string;
artist?: string;
source?: TrackSourceKind;
coverUrl?: string;
explicit?: boolean;
}
export interface ApiTrackLike {
id: string | number;
title: string;
artist?: string;
source?: 'Local' | 'YouTube';
externalId?: string;
audioStatus?: 'Pending' | 'Processing' | 'Completed' | 'Failed';
isExplicit?: boolean;
}
export function isYouTubeTrack(track: ApiTrackLike): boolean {
return track.source === 'YouTube';
}
export function isPendingYouTubeTrack(track: ApiTrackLike): boolean {
return isYouTubeTrack(track) && track.audioStatus !== 'Completed';
}
export function youTubeThumbnailUrl(videoId: string): string {
return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
}
export function queueIdForTrack(track: ApiTrackLike): string | number {
return isYouTubeTrack(track) && track.externalId ? track.externalId : track.id;
}
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
return tracks.map((track) => {
const youTube = isYouTubeTrack(track);
return {
id: queueIdForTrack(track),
title: track.title,
artist: track.artist,
source: youTube ? 'youtube' : 'local',
coverUrl: youTube
? isPendingYouTubeTrack(track) && track.externalId
? youTubeThumbnailUrl(track.externalId)
: `/api/tracks/${track.id}/cover?size=small`
: undefined,
explicit: track.isExplicit
};
});
}
const RECENT_LIMIT = 15;
const EMPTY: PlayerTrack = {
id: '',
title: '',
artist: '',
duration: 0,
hue: HUES[0],
source: 'local'
};
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
title: item.title,
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id),
explicit: item.explicit,
source: item.source ?? 'local',
coverUrl: item.coverUrl
};
}
class PlayerState {
tracks = $state<PlayerTrack[]>([]);
currentId = $state<string | number | null>(null);
playing = $state(false);
progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
loading = $state(false);
error = $state('');
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
accentColor = $state<string | null>(null);
gradientColor = $state<string | null>(null);
#audio: HTMLAudioElement | null = null;
#loadToken = 0;
#retriedId: string | number | null = null;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? accentForHue(this.current.hue));
artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue));
progressPercent = $derived(
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
);
get isPlaying() {
return this.playing;
}
#audioEl(): HTMLAudioElement | null {
if (!browser) return null;
if (this.#audio) return this.#audio;
const audio = new Audio();
audio.preload = 'auto';
audio.volume = this.volume / 100;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('play', () => {
this.playing = true;
this.#startProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('pause', () => {
this.playing = false;
this.#stopProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('ended', () => {
this.#stopProgressLoop();
this.next();
});
audio.addEventListener('error', () => {
this.#stopProgressLoop();
if (
this.currentId !== null &&
this.current.source === 'youtube' &&
this.#retriedId !== this.currentId
) {
this.#retriedId = this.currentId;
this.#loadCurrent();
return;
}
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
});
this.#audio = audio;
this.#setupMediaSession(audio);
return audio;
}
#setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
}
#syncMediaSessionPlaybackState() {
if (!browser || !('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
}
#syncMediaSessionMetadata(track: PlayerTrack) {
if (!browser || !('mediaSession' in navigator)) return;
if (!track.id) {
navigator.mediaSession.metadata = null;
return;
}
const artwork = track.coverUrl ?? `/api/tracks/${track.id}/cover?size=small`;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
artwork: [{ src: artwork, sizes: '256x256' }]
});
}
#tickProgress = () => {
const audio = this.#audio;
if (!audio) return;
this.progress = audio.currentTime;
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
#startProgressLoop() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tickProgress);
}
#stopProgressLoop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
#syncDuration(value: number) {
if (!Number.isFinite(value) || value <= 0) return;
const id = this.currentId;
this.tracks = this.tracks.map((t) => (t.id === id ? { ...t, duration: Math.round(value) } : t));
}
#index() {
return this.tracks.findIndex((t) => t.id === this.currentId);
}
#pushRecent(track: PlayerTrack) {
if (!track.id) return;
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
0,
RECENT_LIMIT
);
}
async #applyAccent(id: string | number, coverUrl?: string) {
const key = String(id);
const cached = this.#accentCache.get(key);
if (cached) {
this.accentColor = cached.accent;
this.gradientColor = cached.gradient;
return;
}
const result = await extractAccent(coverUrl ?? `/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(key, result);
this.accentColor = result.accent;
this.gradientColor = result.gradient;
} else {
this.accentColor = null;
this.gradientColor = null;
}
}
async #loadCurrent() {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
const id = this.currentId;
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id, track?.coverUrl);
if (track) this.#syncMediaSessionMetadata(track);
const token = ++this.#loadToken;
this.loading = true;
this.error = '';
this.progress = 0;
try {
const src =
track?.source === 'youtube'
? await this.#resolveYouTubeSrc(String(id))
: await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return;
audio.src = src;
audio.volume = this.volume / 100;
await audio.play();
this.loading = false;
this.#retriedId = null;
this.#pushRecent(this.current);
} catch (exception) {
console.error('playback failed', exception);
if (token !== this.#loadToken) return;
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.';
}
}
async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string;
ticket: string;
};
return this.#withTicket(manifestUrl, ticket);
}
async #resolveYouTubeSrc(videoId: string): Promise<string> {
const res = await fetch(`/api/youtube/tracks/${videoId}/stream`);
if (!res.ok) throw new Error(String(res.status));
const { mode, streamUrl, ticket } = (await res.json()) as {
mode: string;
streamUrl: string;
ticket: string;
};
return mode === 'Server' ? this.#withTicket(streamUrl, ticket) : streamUrl;
}
#withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
}
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids
.map((id) => this.tracks.find((t) => t.id === id))
.filter((t): t is PlayerTrack => Boolean(t));
}
mosaicFor(ids: (string | number)[]): string[] {
const gradients = this.playlistTracks(ids)
.slice(0, 4)
.map((t) => gradientForHue(t.hue));
while (gradients.length < 4) gradients.push('var(--mf-surface)');
return gradients;
}
playQueue(list: QueueItem[], startIndex = 0) {
if (list.length === 0) return;
this.tracks = list.map(toTrack);
const start = this.tracks[startIndex] ?? this.tracks[0];
this.currentId = start.id;
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);
return;
}
const tracks = [...this.tracks];
tracks.splice(this.#index() + 1, 0, toTrack(item));
this.tracks = tracks;
}
playTrack(id: string | number) {
if (this.currentId === id) {
this.toggle();
return;
}
const known =
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
if (known && !this.tracks.some((t) => t.id === id)) {
this.tracks = [known];
}
if (!this.tracks.some((t) => t.id === id)) return;
this.currentId = id;
this.#loadCurrent();
}
playPlaylist(id: string | number) {
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) return;
const items = this.playlistTracks(playlist.trackIds);
if (items.length > 0) this.playQueue(items, 0);
}
toggle() {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
next() {
if (this.tracks.length === 0) return;
const idx = this.#index();
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
this.#loadCurrent();
}
previous() {
if (this.tracks.length === 0) return;
const audio = this.#audioEl();
if (audio && audio.currentTime > 3) {
audio.currentTime = 0;
return;
}
const idx = this.#index();
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
this.#loadCurrent();
}
seekFraction(fraction: number) {
const audio = this.#audioEl();
const clamped = Math.min(1, Math.max(0, fraction));
const target = clamped * this.current.duration;
this.progress = target;
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
}
setVolume(value: number) {
this.volume = Math.min(100, Math.max(0, Math.round(value)));
const audio = this.#audioEl();
if (audio) {
audio.volume = this.volume / 100;
localStorage.setItem('player.volume', String(this.volume));
}
}
}
export const player = new PlayerState();
+69
View File
@@ -0,0 +1,69 @@
import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config';
interface ApiClientOptions {
fetch: typeof fetch;
accessToken?: string;
}
export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
return createClient<paths>({
baseUrl: apiConfig.baseUrl,
fetch,
headers: accessToken ? { Authorization: `Bearer ${accessToken}` } : undefined
});
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user)
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return locals.user;
}
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);
}
+109
View File
@@ -0,0 +1,109 @@
import * as client from 'openid-client';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { dev } from '$app/environment';
import { authConfig } from '$lib/server/config';
import type { SessionUser } from '$lib/types';
export const SESSION_COOKIE = 'mf_session';
export const STATE_COOKIE = 'mf_oidc_state';
export const VERIFIER_COOKIE = 'mf_oidc_verifier';
export const NONCE_COOKIE = 'mf_oidc_nonce';
export const RETURN_COOKIE = 'mf_oidc_return';
export interface Session extends SessionUser {
accessToken: string;
refreshToken: string;
idToken: string;
expiresAt: number;
}
let configPromise: Promise<client.Configuration>;
export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => {
const issuer = new URL(authConfig.issuer);
const insecureAllowed = (dev || authConfig.allowInsecureHttp) && issuer.protocol === 'http:';
const options = insecureAllowed ? { execute: [client.allowInsecureRequests] } : undefined;
const secret = authConfig.clientSecret;
return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})();
return configPromise;
}
let keyPromise: Promise<Uint8Array>;
function sessionKey(): Promise<Uint8Array> {
keyPromise ??= crypto.subtle
.digest('SHA-256', new TextEncoder().encode(authConfig.sessionSecret))
.then((digest) => new Uint8Array(digest));
return keyPromise;
}
export function sessionCookieOptions(url: URL) {
return {
httpOnly: true,
sameSite: 'lax' as const,
secure: url.protocol === 'https:',
path: '/',
maxAge: authConfig.sessionTtlSeconds
};
}
export async function encodeSession(session: Session): Promise<string> {
const now = Math.floor(Date.now() / 1000);
return new EncryptJWT({ ...session })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuedAt(now)
.setExpirationTime(now + authConfig.sessionTtlSeconds)
.encrypt(await sessionKey());
}
export async function decodeSession(token: string | undefined): Promise<Session | null> {
if (!token) return null;
try {
const { payload } = await jwtDecrypt(token, await sessionKey());
return payload as unknown as Session;
} catch {
return null;
}
}
function claimString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
export function toSessionUser(
claims: Record<string, unknown>,
fallback?: SessionUser
): SessionUser {
const sub = claimString(claims.sub) ?? fallback?.sub ?? '';
return {
sub,
name:
claimString(claims.name) ??
claimString(claims.preferred_username) ??
fallback?.name ??
claimString(claims.email) ??
sub,
email: claimString(claims.email) ?? fallback?.email ?? '',
picture: claimString(claims.picture) ?? fallback?.picture ?? ''
};
}
export async function refreshSession(session: Session): Promise<Session | null> {
try {
const config = await getOidcConfig();
const tokens = await client.refreshTokenGrant(config, session.refreshToken);
return {
...toSessionUser(tokens.claims() ?? {}, session),
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? session.refreshToken,
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (exception) {
console.error('Token refresh failed', exception);
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { env } from '$env/dynamic/private';
const DAY_SECONDS = 60 * 60 * 24;
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
function positiveInt(value: string | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback;
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
export const authConfig = {
get issuer() {
return env.ZITADEL_ISSUER;
},
get clientId() {
return env.ZITADEL_CLIENT_ID;
},
get clientSecret(): string | undefined {
const secret = env.ZITADEL_CLIENT_SECRET;
return secret && !secret.startsWith('REPLACE_') ? secret : undefined;
},
get scope() {
return env.OIDC_SCOPE || DEFAULT_OIDC_SCOPE;
},
get redirectUri() {
return env.AUTH_REDIRECT_URI;
},
get postLogoutUri() {
return env.AUTH_POST_LOGOUT_URI;
},
get sessionSecret() {
return env.SESSION_SECRET;
},
get sessionTtlSeconds() {
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
},
get refreshThresholdSeconds() {
return positiveInt(env.SESSION_REFRESH_THRESHOLD_SECONDS, 30);
},
get flowTtlSeconds() {
return positiveInt(env.AUTH_FLOW_TTL_SECONDS, 600);
},
get allowInsecureHttp() {
return env.AUTH_ALLOW_INSECURE_HTTP === 'true';
}
};
export const apiConfig = {
get baseUrl() {
return (env.API_BASE_URL ?? '').replace(/\/+$/, '');
}
};
+24
View File
@@ -0,0 +1,24 @@
const GREETINGS_BY_HOUR: { until: number; pool: string[] }[] = [
{
until: 6,
pool: ['¿Aún despierto?', 'La noche también tiene banda sonora', 'Un ratito más, con música']
},
{
until: 12,
pool: ['Buenos días', 'Que empiece bien la mañana', 'Un nuevo día, una nueva lista']
},
{ until: 14, pool: ['Buenos días', 'El mediodía sabe mejor con música'] },
{
until: 19,
pool: ['Buenas tardes', 'La tarde pide su banda sonora', 'Un respiro, con algo de música']
},
{ until: 23, pool: ['Buenas noches', 'Hora de bajar el ritmo', 'Que la noche te acompañe'] },
{ until: 24, pool: ['¿Aún despierto?', 'La noche también tiene banda sonora'] }
];
export function pickGreeting(hour: number): string {
const pool =
GREETINGS_BY_HOUR.find((slot) => hour < slot.until)?.pool ?? GREETINGS_BY_HOUR[0].pool;
const greeting = pool[Math.floor(Math.random() * pool.length)];
return /[.!?]$/.test(greeting) ? greeting : `${greeting}.`;
}
+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,61 @@
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 };
}
+110
View File
@@ -0,0 +1,110 @@
import http from 'node:http';
import https from 'node:https';
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,
contentType: string
): Promise<void> {
const url = new URL(presignedUrl);
const signed = new Set(
(url.searchParams.get('X-Amz-SignedHeaders') ?? 'host').toLowerCase().split(';')
);
const headers: Record<string, string | number> = {
Host: url.host,
'Content-Length': body.byteLength
};
if (signed.has('content-type')) headers['Content-Type'] = contentType;
if (signed.has('if-none-match')) headers['If-None-Match'] = '*';
const secure = url.protocol === 'https:';
const transport = secure ? https : http;
const port = url.port || (secure ? 443 : 80);
await new Promise<void>((resolve, reject) => {
const request = transport.request(
{
host: CONNECT_HOST,
port,
method: 'PUT',
path: url.pathname + url.search,
headers
},
(response) => {
let raw = '';
response.on('data', (chunk) => (raw += chunk));
response.on('end', () => {
const status = response.statusCode ?? 0;
if (status >= 200 && status < 300) resolve();
else reject(new Error(`S3 PUT ${status}: ${raw.slice(0, 200)}`));
});
}
);
request.on('error', reject);
request.write(body);
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 ?? ''
};
}
+37
View File
@@ -0,0 +1,37 @@
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(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${hue})`;
}
export function accentSoft(hue: number, pct: number): string {
return `color-mix(in srgb, ${accentForHue(hue)} ${pct}%, transparent)`;
}
export function gradientForHue(hue: number): string {
return `linear-gradient(135deg, oklch(70% 0.15 ${hue}), oklch(36% 0.13 ${hue + 28}))`;
}
export function fmtTime(seconds: number): string {
const total = Math.max(0, Math.floor(seconds || 0));
const minutes = Math.floor(total / 60);
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));
}
+114
View File
@@ -0,0 +1,114 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
gradient: string;
}
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
function rgbToOklch(r: number, g: number, b: number): [number, number, number] {
const lr = srgbToLinear(r);
const lg = srgbToLinear(g);
const lb = srgbToLinear(b);
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb;
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb;
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb;
const l_ = Math.cbrt(l);
const m_ = Math.cbrt(m);
const s_ = Math.cbrt(s);
const L = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
const bb = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
const C = Math.sqrt(a * a + bb * bb);
const H = (Math.atan2(bb, a) * 180) / Math.PI;
return [L, C, (H + 360) % 360];
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
}
const BUCKETS = 36;
export async function extractAccent(url: string): Promise<Accent | null> {
if (!browser) return null;
let img: HTMLImageElement;
try {
img = await loadImage(url);
} catch {
return null;
}
const size = 40;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
let pixels: Uint8ClampedArray;
try {
pixels = ctx.getImageData(0, 0, size, size).data;
} catch {
return null;
}
const weight = new Array(BUCKETS).fill(0);
const hueSin = new Array(BUCKETS).fill(0);
const hueCos = new Array(BUCKETS).fill(0);
const chromaSum = new Array(BUCKETS).fill(0);
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] < 128) continue;
const [L, C, H] = rgbToOklch(pixels[i], pixels[i + 1], pixels[i + 2]);
if (L < 0.2 || L > 0.92 || C < 0.03) continue;
const bucket = Math.floor((H / 360) * BUCKETS) % BUCKETS;
const w = C * C;
const rad = (H * Math.PI) / 180;
weight[bucket] += w;
hueSin[bucket] += Math.sin(rad) * w;
hueCos[bucket] += Math.cos(rad) * w;
chromaSum[bucket] += C * w;
}
let best = -1;
let bestWeight = 0;
for (let b = 0; b < BUCKETS; b++) {
if (weight[b] > bestWeight) {
bestWeight = weight[b];
best = b;
}
}
if (best < 0) return null;
const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
const chroma = Math.min(0.19, Math.max(0.1, chromaSum[best] / weight[best]));
const h = hue.toFixed(1);
const c = chroma.toFixed(3);
const c2 = (chroma * 0.85).toFixed(3);
const h2 = ((hue + 25) % 360).toFixed(1);
return {
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
+22
View File
@@ -0,0 +1,22 @@
:root {
--mf-bg: rgb(9, 9, 9);
--mf-elevated: #0b0b0d;
--mf-text: #c7c6c5;
--mf-text-2: #a5a4a3;
--mf-text-3: #8c8b8a;
--mf-text-4: #7f7e7d;
--mf-surface: rgb(18, 18, 18);
--mf-surface-2: rgba(255, 255, 255, 0.06);
--mf-surface-hover: rgba(255, 255, 255, 0.09);
--mf-border: rgba(255, 255, 255, 0.08);
--mf-border-strong: rgba(255, 255, 255, 0.12);
--mf-track: rgba(255, 255, 255, 0.12);
--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;
}
+78
View File
@@ -0,0 +1,78 @@
import {
player,
queueIdForTrack,
toQueueItems,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import type { YouTubeSong } from '$lib/types';
export type TrackTarget =
{ kind: 'youtube'; song: YouTubeSong } | { kind: 'local'; track: ApiTrackLike };
export function targetId(target: TrackTarget): string | number {
return target.kind === 'youtube' ? target.song.videoId : target.track.id;
}
export function targetTitle(target: TrackTarget): string {
return target.kind === 'youtube' ? target.song.title : target.track.title;
}
export function targetArtist(target: TrackTarget): string | undefined {
return target.kind === 'youtube' ? target.song.artist : target.track.artist;
}
export function targetExplicit(target: TrackTarget): boolean {
return target.kind === 'youtube' ? target.song.isExplicit : (target.track.isExplicit ?? false);
}
export function targetCoverSrc(target: TrackTarget): string | undefined {
return target.kind === 'youtube' ? target.song.thumbnailUrl : undefined;
}
export function queueIdForTarget(target: TrackTarget): string | number {
return target.kind === 'youtube' ? target.song.videoId : queueIdForTrack(target.track);
}
export function isTargetCurrent(target: TrackTarget): boolean {
return player.current.id === queueIdForTarget(target);
}
export function queueItemForTarget(target: TrackTarget): QueueItem {
return target.kind === 'youtube'
? {
id: target.song.videoId,
title: target.song.title,
artist: target.song.artist,
source: 'youtube',
coverUrl: target.song.thumbnailUrl,
explicit: target.song.isExplicit
}
: toQueueItems([target.track])[0];
}
export function targetForQueueItem(item: QueueItem): TrackTarget {
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
}
};
}
+16
View File
@@ -0,0 +1,16 @@
export interface SessionUser {
sub: string;
name: string;
email: string;
picture: string;
}
export interface YouTubeSong {
videoId: string;
title: string;
artist: string;
album: string;
durationSeconds: number;
thumbnailUrl: string;
isExplicit: boolean;
}
@@ -0,0 +1,52 @@
import type { PageServerLoad, Actions } from './$types';
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() ?? '';
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const tracksPromise = api.GET('/tracks', {
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
});
const searchPromise =
query && locals.accessToken
? api.GET('/youtube/search', { params: { query: { query } } })
: Promise.resolve(null);
const playlistsPromise = locals.user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null);
const [tracksRes, searchRes, playlistsRes] = await Promise.all([
tracksPromise,
searchPromise,
playlistsPromise
]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
return {
query,
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: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
@@ -0,0 +1,235 @@
<script lang="ts">
import Search from '@lucide/svelte/icons/search';
import Music from '@lucide/svelte/icons/music';
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { player } from '$lib/player/player.svelte';
import 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 ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import { appendUnique } from '$lib/collections';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
let { data, form } = $props();
let searchValue = $state(untrack(() => data.query));
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
let ytItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
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(() => {
contextMenu = null;
localItems = data.tracks.items;
localPage = Number(data.tracks.pageNumber);
localHasNext = Boolean(data.tracks.hasNextPage);
if (data.query) {
ytItems = appendUnique([], (data.ytResults?.items ?? []) as YouTubeSong[], (i) => i.videoId);
ytContinuation = data.ytResults?.continuationToken ?? '';
ytSearchTerm = data.query;
return;
}
ytItems = [];
ytContinuation = '';
ytSearchTerm = '';
let cancelled = false;
data.youtubeFiller?.then((filler) => {
if (cancelled || !filler) return;
ytItems = appendUnique([], filler.items, (i) => i.videoId);
ytContinuation = filler.continuationToken;
ytSearchTerm = filler.query;
});
return () => {
cancelled = true;
};
});
const items = $derived<TrackTarget[]>([
...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 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(queueItemForTarget), index);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(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() {
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, (i) => i.videoId);
ytContinuation = next.continuationToken;
}
async function loadMore() {
if (loadingMore) return;
loadingMore = true;
try {
if (localHasNext) await loadMoreLocal();
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
} catch {
if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally {
loadingMore = false;
}
}
</script>
<svelte:head>
<title>Explorar · Musify</title>
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
</svelte:head>
<Page>
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
<div class="mt-8">
<Input
type="search"
icon={Search}
bind:value={searchValue}
oninput={onSearchInput}
placeholder="Buscar en tu música y en YouTube Music..."
/>
</div>
{#if form?.message}
<Alert class="mt-4">{form.message}</Alert>
{/if}
{#if nothingFound}
<EmptyState
icon={Music}
title="Sin resultados"
description="No hay resultados para «{data.query}»."
/>
{/if}
{#if items.length > 0}
<MediaGrid as="ul" class="mt-6">
{#each items as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => togglePlay(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
{/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}
</Page>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
playlists={data.playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}
@@ -0,0 +1,38 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
});
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
};
export const actions: Actions = {
deleteTrack: async ({ request, locals, fetch }) => {
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 });
const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
if (failure) return failure;
return { deleted: true };
}
};
@@ -0,0 +1,105 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
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));
function togglePlay(index: number) {
player.playOrToggle(toQueueItems(items), index);
}
</script>
<svelte:head>
<title>Tus canciones subidas · Musify</title>
<meta name="description" content="Todas tus canciones subidas a Musify." />
</svelte:head>
<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}
<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>
{/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}
<EmptyState
icon={Music}
title="Todavía no tienes canciones"
description="Sube la primera y aparecerá aquí lista para reproducir."
>
{#snippet actions()}
<Button href="/upload">
<Upload class="h-4 w-4" />
Sube la primera
</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
@@ -0,0 +1,71 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
playlistCoverTrackIds,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
});
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 };
};
export const actions: Actions = {
create: async ({ request, locals, fetch }) => {
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();
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, {
message: 'El nombre es obligatorio (máx. 100 caracteres).',
name,
description
});
}
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
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 });
}
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId }
});
if (err || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`);
}
};
@@ -0,0 +1,99 @@
<script lang="ts">
import Plus from '@lucide/svelte/icons/plus';
import ListMusic from '@lucide/svelte/icons/list-music';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
import PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
import Modal from '$lib/components/ui/Modal.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
import Button from '$lib/components/ui/Button.svelte';
const playlistNames = [
'Mi Playlist',
'Tus favoritos',
'Lo mejor de hoy',
'Para relajarse',
'Buena vibra',
'Modo estudio',
'Road Trip',
'Domingos',
'Late Night',
'Descubriendo música',
'Todo un poco',
'En bucle',
'Mi colección',
'Canciones para repetir',
'Hits del momento'
];
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
let { data, form } = $props();
const items = $derived(data.playlists.items);
const trackIds = $derived(data.trackIds);
let creating = $state(false);
function openCreate() {
creating = true;
}
</script>
<svelte:head>
<title>Mis listas · Musify</title>
<meta name="description" content="Mis listas en Musify." />
</svelte:head>
<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}
<MediaGrid class="mt-9">
{#each items as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={trackIds[playlist.id] ?? []}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
{:else}
<EmptyState
icon={ListMusic}
title="Todavía no tienes listas"
description="Crea la primera y añádele canciones de tu biblioteca."
>
{#snippet actions()}
<Button onclick={openCreate}>
<Plus class="h-4 w-4" strokeWidth={2} />
Crear lista
</Button>
{/snippet}
</EmptyState>
{/if}
</Page>
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
<PlaylistForm
action="?/create"
{namePlaceholder}
formMessage={form?.message}
submitLabel="Crear"
submittingLabel="Creando…"
onCancel={() => (creating = false)}
/>
</Modal>
@@ -0,0 +1,135 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
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 SUGGESTIONS_LIMIT = 20;
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
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: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
}),
api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const inPlaylist = new Set(tracks.map((t) => t.id));
const library = (libraryRes.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
const alreadyLinked = new Set(
tracks.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: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
removeTrack: async ({ request, params, locals, fetch }) => {
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 });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
rename: async ({ request, params, locals, fetch }) => {
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();
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
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 });
}
newPictureIntentId = result.intentId;
}
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: { newName: name, newDescription: description, newPictureIntentId }
});
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
return { renamed: true };
},
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }
});
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
if (failure) return failure;
redirect(303, '/playlists');
}
};
@@ -0,0 +1,95 @@
<script lang="ts">
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();
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const library = $derived(data.library);
let editing = $state(false);
let confirmingDelete = $state(false);
let youtubeSuggestions = $state<YouTubeSong[]>([]);
$effect(() => {
youtubeSuggestions = [];
data.youtube.then((result) => {
youtubeSuggestions = result.items;
});
});
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
function playAll() {
if (tracks.length === 0) return;
if (isCurrentQueue) player.toggle();
else player.playQueue(toQueueItems(tracks), 0);
}
</script>
<svelte:head>
<title>{playlist.name} · Musify</title>
<meta name="description" content="Playlist {playlist.name} en Musify." />
</svelte:head>
<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}
<PlaylistTrackTable {tracks} />
{/if}
{#if library.length > 0 || youtubeSuggestions.length > 0}
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
{/if}
</Page>
<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>
<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>
</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,85 @@
<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,157 @@
<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}
@@ -0,0 +1,86 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrFail
} from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
const MAX_TITLE = 100;
export const load: PageServerLoad = ({ locals, url }) => {
requireUser(locals, url);
};
export const actions: Actions = {
default: async ({ request, locals, fetch }) => {
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();
const audio = form.get('audio');
const cover = form.get('cover');
if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
}
if (!(audio instanceof File) || audio.size === 0) {
return fail(400, { message: 'Selecciona un archivo de audio.' });
}
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'Selecciona una portada.' });
}
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken });
const urlsResult = await api.POST('/tracks/upload-urls', {
body: {
pictureFileType: extOf(cover.name) || 'jpg',
pictureContentType,
audioFileType: extOf(audio.name) || 'mp3',
audioContentType,
expectedPictureSizeBytes: cover.size,
expectedAudioSizeBytes: audio.size
}
});
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([
audio.arrayBuffer(),
cover.arrayBuffer()
]);
await Promise.all([
putPresigned(urls.audioUploadUrl, Buffer.from(audioBuffer), urls.audioContentType),
putPresigned(urls.pictureUploadUrl, Buffer.from(coverBuffer), urls.pictureContentType)
]);
} catch (err) {
return fail(502, {
message: 'Falló la subida del archivo al almacenamiento.',
detail: err instanceof Error ? err.message : undefined
});
}
const trackResult = await api.POST('/tracks', {
body: {
title,
pictureIntentId: urls.pictureIntentId,
audioIntentId: urls.audioIntentId
}
});
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 };
}
};
@@ -0,0 +1,283 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Headphones from '@lucide/svelte/icons/headphones';
import Music from '@lucide/svelte/icons/music';
import ImageIcon from '@lucide/svelte/icons/image';
import Check from '@lucide/svelte/icons/check';
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';
const MAX_TITLE = 100;
let title = $state('');
let audioName = $state('');
let audioSize = $state(0);
let coverPreview = $state('');
let dragging = $state(false);
let status = $state<Status>('idle');
let acceptedTerms = $state(false);
let errorMsg = $state('');
let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>();
const steps = [
{ key: 'upload', label: 'Subir', hint: 'Portada y audio al almacenamiento' },
{ key: 'process', label: 'Procesar', hint: 'Transcodificado para streaming' },
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo' }
];
const busy = $derived(status === 'uploading');
const canPublish = $derived(
title.trim() !== '' && audioName !== '' && coverPreview !== '' && acceptedTerms && !busy
);
const stepStates = $derived.by<StepState[]>(() => {
if (status === 'done') return ['done', 'done', 'done'];
if (status === 'uploading') return ['active', 'idle', 'idle'];
return ['idle', 'idle', 'idle'];
});
const dropClass = $derived(
dragging || audioName !== '' ? 'border-accent/60' : 'border-line hover:border-accent/60'
);
function stepClass(s: StepState) {
if (s === 'active') return 'border-accent/40 bg-accent/5';
if (s === 'done') return 'border-accent/40';
return '';
}
function formatSize(bytes: number) {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
function pickAudio(file: File) {
audioName = file.name;
audioSize = file.size;
if (title.trim() === '') title = file.name.replace(/\.[^.]+$/, '');
}
function onAudioInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) pickAudio(file);
}
function onDrop(event: DragEvent) {
event.preventDefault();
dragging = false;
const file = event.dataTransfer?.files?.[0];
if (!file || !audioInput) return;
const dt = new DataTransfer();
dt.items.add(file);
audioInput.files = dt.files;
pickAudio(file);
}
function onCoverInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (file) coverPreview = URL.createObjectURL(file);
}
function reset() {
title = '';
audioName = '';
audioSize = 0;
coverPreview = '';
status = 'idle';
acceptedTerms = false;
errorMsg = '';
}
</script>
<svelte:head>
<title>Subir música · Musify</title>
<meta name="description" content="Sube tus canciones a Musify: portada, audio y título." />
</svelte:head>
<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>
<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'}
<Surface padding="lg" class="mt-10 border-accent/30 bg-accent/10 text-center">
<span
class="mx-auto grid h-16 w-16 place-items-center rounded-full bg-accent-soft text-on-accent"
>
<Check class="h-8 w-8" strokeWidth={2.5} />
</span>
<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">
<Button href="/library" size="lg">Ir a la biblioteca</Button>
<Button variant="secondary" size="lg" onclick={reset}>Subir otra</Button>
</div>
</Surface>
{:else}
<form
method="POST"
enctype="multipart/form-data"
use:enhance={() => {
status = 'uploading';
errorMsg = '';
return async ({ result }) => {
if (result.type === 'success') {
publishedTitle = title;
status = 'done';
} else if (result.type === 'failure') {
status = 'error';
errorMsg = (result.data?.message as string) ?? 'No se pudo subir la canción.';
} else {
status = 'error';
errorMsg = 'Error inesperado durante la subida.';
}
};
}}
class="mt-10 space-y-8"
>
<div
role="button"
tabindex="0"
ondragover={(e) => {
e.preventDefault();
dragging = true;
}}
ondragleave={() => (dragging = false)}
ondrop={onDrop}
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}
type="file"
name="audio"
accept="audio/*"
onchange={onAudioInput}
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-accent-soft text-on-accent">
{#if audioName === ''}
<Headphones class="h-9 w-9" />
{:else}
<Music class="h-9 w-9" />
{/if}
</span>
{#if audioName === ''}
<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-muted">MP3, FLAC, WAV…</p>
{:else}
<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]">
<Field label="Portada">
<label
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"
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-muted" />
{/if}
</label>
</Field>
<Field label="Título" for="title">
<Input
id="title"
name="title"
bind:value={title}
maxlength={MAX_TITLE}
placeholder="Nombre de la canción"
/>
{#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>
{/snippet}
</Field>
</div>
<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'}
<Surface>
<div class="flex items-center justify-between text-base">
<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-surface-2">
<div class="h-full w-full animate-pulse rounded-full bg-accent-soft"></div>
</div>
</Surface>
{/if}
{#if status === 'error'}
<Alert tone="danger">{errorMsg}</Alert>
{/if}
<div class="flex items-center justify-end gap-4">
<Button href="/explore" variant="secondary" size="lg">Cancelar</Button>
<Button type="submit" size="lg" disabled={!canPublish}>Publicar</Button>
</div>
</form>
{/if}
</Page>
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
const PUBLIC_PATHS = new Set(['/login']);
export const load: LayoutServerLoad = ({ locals, url }) => {
if (!locals.user && !PUBLIC_PATHS.has(url.pathname)) {
redirect(302, `/login?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
return { user: locals.user };
};
+78
View File
@@ -0,0 +1,78 @@
<script lang="ts">
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();
const isAuthPage = $derived(page.url.pathname === '/login');
const hasTrack = $derived(player.currentId !== null);
$effect(() => {
document.documentElement.classList.toggle('home', page.url.pathname === '/');
});
function parseAccent(value: string): { c: number; h: number } | null {
const m = /oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(value);
return m ? { c: parseFloat(m[1]), h: parseFloat(m[2]) } : null;
}
let displayed = { c: ACCENT_CHROMA, h: ACCENT_HUE };
let rafId = 0;
$effect(() => {
const target = parseAccent(player.accent);
if (!target) {
document.documentElement.style.setProperty('--mf-accent', player.accent);
return;
}
cancelAnimationFrame(rafId);
const from = { ...displayed };
let dh = target.h - from.h;
if (dh > 180) dh -= 360;
else if (dh < -180) dh += 360;
const start = performance.now();
const duration = 350;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const e = 1 - Math.pow(1 - t, 3);
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(${ACCENT_LIGHTNESS}% ${c.toFixed(3)} ${h.toFixed(1)})`
);
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
});
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{#if isAuthPage}
<div class="min-h-screen bg-bg text-fg antialiased">
{@render children()}
</div>
{:else}
<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'}">
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
</main>
{#if hasTrack}
<PlayerBar />
{/if}
</div>
</div>
{/if}
+48
View File
@@ -0,0 +1,48 @@
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';
import { pickGreeting } from '$lib/server/greeting';
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: HOME_LATEST_PAGE_SIZE } }
});
const playlistsPromise = locals.user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 12 } }
})
: Promise.resolve({ data: undefined });
const recentlyPlayedPromise = locals.user
? api.GET('/users/{id}/listening-history', { params: { path: { id: locals.user.sub } } })
: Promise.resolve({ data: undefined });
const [latest, playlists, recentlyPlayed] = await Promise.all([
latestPromise,
playlistsPromise,
recentlyPlayedPromise
]);
const playlistItems = playlists.data?.items ?? [];
const trackIds = await playlistCoverTrackIds(
api,
playlistItems.map((p) => p.id)
);
return {
greeting: pickGreeting(new Date().getHours()),
latest: shuffle((latest.data?.items ?? []).map((track) => ({ kind: 'local' as const, track }))),
latestHasNext: Boolean(latest.data?.hasNextPage),
youtubeFiller: fetchYoutubeFiller(api, locals.accessToken, YOUTUBE_FILLER_LIMIT),
playlists: playlistItems,
trackIds,
recentlyPlayed: recentlyPlayed.data ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addYouTubeToPlaylist: addYouTubeToPlaylistAction
};
+278
View File
@@ -0,0 +1,278 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
import {
isTargetCurrent,
queueItemForTarget,
targetArtist,
targetCoverSrc,
targetExplicit,
targetForQueueItem,
targetId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
import { hueFor } from '$lib/theme/color';
import Cover from '$lib/components/ui/Cover.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 ContextMenuState
} from '$lib/components/TrackContextMenu.svelte';
import type { YouTubeSong } from '$lib/types';
import { appendUnique } from '$lib/collections';
let { data } = $props();
let youtubeFillerItems = $state<YouTubeSong[]>([]);
let ytContinuation = $state('');
let ytQuery = $state('');
let contextMenu = $state<ContextMenuState | null>(null);
type LocalLatest = (typeof data.latest)[number];
let localLatest = $state<LocalLatest[]>([]);
let localPage = $state(1);
let localHasNext = $state(false);
let loadingMore = $state(false);
$effect(() => {
youtubeFillerItems = [];
ytContinuation = '';
ytQuery = '';
localLatest = data.latest;
localPage = 1;
localHasNext = data.latestHasNext;
let cancelled = false;
data.youtubeFiller.then((filler) => {
if (cancelled) return;
youtubeFillerItems = filler.items;
ytContinuation = filler.continuationToken;
ytQuery = filler.query;
});
return () => {
cancelled = true;
};
});
const latest = $derived<TrackTarget[]>([
...localLatest,
...youtubeFillerItems.map((song) => ({ kind: 'youtube' as const, song }))
]);
const hasMoreLatest = $derived(localHasNext || ytContinuation !== '');
async function loadMoreLatest() {
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();
localLatest = appendUnique(
localLatest,
next.items.map((track: LocalLatest['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, (i) => i.videoId);
ytContinuation = next.continuationToken;
}
} catch {
if (localHasNext) localHasNext = false;
else ytContinuation = '';
} finally {
loadingMore = false;
}
}
const playlists = $derived(data.playlists);
const trackIds = $derived(data.trackIds);
function playLatest(index: number) {
player.playOrToggle(latest.map(queueItemForTarget), index);
}
function openContextMenu(event: MouseEvent, target: TrackTarget) {
contextMenu = contextMenuStateFor(event, target);
}
function closeContextMenu() {
contextMenu = null;
}
function addToQueue() {
if (!contextMenu) return;
player.addToQueue(queueItemForTarget(contextMenu));
closeContextMenu();
}
const recentlyPlayed = $derived.by(() => {
const seen = new Set<string>();
const merged: QueueItem[] = [];
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: t.title,
artist: t.artist,
source: t.source,
coverUrl: t.coverUrl,
explicit: t.explicit
});
}
for (const item of toQueueItems(data.recentlyPlayed)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({
id,
title: item.title,
artist: item.artist,
source: item.source ?? 'local',
coverUrl: item.coverUrl,
explicit: item.explicit
});
}
return merged.slice(0, 15);
});
function playRecent(index: number) {
player.playOrToggle(recentlyPlayed, index);
}
</script>
<svelte:head>
<title>Musify</title>
<meta name="description" content="Tu música, sin límites." />
</svelte:head>
<section class="relative overflow-hidden page-x 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="tracking-[0.14em] text-fg-2 uppercase">{data.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="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)}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(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}
src={track.coverUrl}
hue={hueFor(track.id)}
size="small"
alt={track.title}
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="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-sm text-muted">{track.artist}</div>
{/if}
</div>
</button>
{/each}
</MediaGrid>
</section>
{/if}
{#if playlists.length > 0}
<section class="page-x pt-6 pb-3">
<SectionHeading title="Mis listas" />
<MediaGrid>
{#each playlists as playlist, i (playlist.id)}
<PlaylistCard
id={playlist.id}
name={playlist.name}
description={playlist.description}
trackIds={trackIds[playlist.id] ?? []}
updatedAt={playlist.updatedAt}
index={i}
/>
{/each}
</MediaGrid>
</section>
{/if}
<section class="page-x pt-6 pb-10">
<SectionHeading title="Novedades" />
{#if latest.length > 0}
<MediaGrid as="ul">
{#each latest as item, i (targetId(item))}
<TrackTile
id={targetId(item)}
title={targetTitle(item)}
artist={targetArtist(item)}
coverSrc={targetCoverSrc(item)}
hue={hueFor(targetId(item))}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
playing={player.isPlaying}
index={i}
onClick={() => playLatest(i)}
onContextMenu={(e) => openContextMenu(e, item)}
/>
{/each}
</MediaGrid>
<InfiniteScroll onLoadMore={loadMoreLatest} hasMore={hasMoreLatest} loading={loadingMore} />
{:else}
<EmptyState icon={Music} title="Todavía no hay música" />
{/if}
</section>
{#if contextMenu}
<TrackContextMenu
menu={contextMenu}
{playlists}
onClose={closeContextMenu}
onAddToQueue={addToQueue}
/>
{/if}
@@ -0,0 +1,6 @@
import type { RequestHandler } from './$types';
import { proxyCoverImage } from '$lib/server/image';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
return proxyCoverImage(fetch, 'playlists', params.id, url.searchParams.get('size'));
};
@@ -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.'));
};
@@ -0,0 +1,6 @@
import type { RequestHandler } from './$types';
import { proxyCoverImage } from '$lib/server/image';
export const GET: RequestHandler = async ({ params, url, fetch }) => {
return proxyCoverImage(fetch, 'tracks', params.id, url.searchParams.get('size'));
};
@@ -0,0 +1,14 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/tracks/{id}/stream', {
params: { path: { id: params.id } }
});
return json(unwrapOrError(result, 'No se pudo obtener el stream de la pista.'));
};
@@ -0,0 +1,21 @@
import type { RequestHandler } from './$types';
import { json, error } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para buscar.');
const query = url.searchParams.get('query')?.trim() ?? '';
if (!query) {
error(400, 'Falta el término de búsqueda.');
}
const continuation = url.searchParams.get('continuation') ?? undefined;
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/search', {
params: { query: { query, continuation } }
});
return json(unwrapOrError(result, 'No se pudo buscar en YouTube Music.'));
};
@@ -0,0 +1,14 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, requireAccessToken, unwrapOrError } from '$lib/server/api';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
const accessToken = requireAccessToken(locals, 'Inicia sesión para reproducir.');
const api = createApiClient({ fetch, accessToken });
const result = await api.GET('/youtube/tracks/{videoId}/stream', {
params: { path: { videoId: params.videoId } }
});
return json(unwrapOrError(result, 'No se pudo obtener el stream de YouTube.'));
};
@@ -0,0 +1,65 @@
import { redirect, error } from '@sveltejs/kit';
import * as client from 'openid-client';
import type { RequestHandler } from './$types';
import {
getOidcConfig,
encodeSession,
sessionCookieOptions,
toSessionUser,
type Session,
SESSION_COOKIE,
STATE_COOKIE,
VERIFIER_COOKIE,
NONCE_COOKIE,
RETURN_COOKIE
} from '$lib/server/auth';
export const GET: RequestHandler = async ({ url, cookies }) => {
const codeVerifier = cookies.get(VERIFIER_COOKIE);
const state = cookies.get(STATE_COOKIE);
const nonce = cookies.get(NONCE_COOKIE);
const returnTo = cookies.get(RETURN_COOKIE) ?? '/';
if (!codeVerifier || !state) {
error(400, 'Estado de autenticación inválido. Vuelve a iniciar sesión.');
}
const config = await getOidcConfig();
let tokens;
try {
tokens = await client.authorizationCodeGrant(config, url, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
expectedNonce: nonce,
idTokenExpected: true
});
} catch (exception) {
console.error('Token exchange failed', exception);
error(401, 'La autenticación ha fallado.');
}
const claims = tokens.claims();
if (!claims || !tokens.id_token) {
error(401, 'El proveedor no devolvió el token de identidad.');
}
if (!tokens.refresh_token) {
error(401, 'El proveedor no devolvió un refresh token; revisa el scope offline_access.');
}
const session: Session = {
...toSessionUser(claims),
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
idToken: tokens.id_token,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
cookies.set(SESSION_COOKIE, await encodeSession(session), sessionCookieOptions(url));
for (const name of [STATE_COOKIE, VERIFIER_COOKIE, NONCE_COOKIE, RETURN_COOKIE]) {
cookies.delete(name, { path: '/' });
}
redirect(302, returnTo);
};
@@ -0,0 +1,49 @@
import { redirect } from '@sveltejs/kit';
import * as client from 'openid-client';
import type { RequestHandler } from './$types';
import {
getOidcConfig,
STATE_COOKIE,
VERIFIER_COOKIE,
NONCE_COOKIE,
RETURN_COOKIE
} from '$lib/server/auth';
import { authConfig } from '$lib/server/config';
export const GET: RequestHandler = async ({ url, cookies }) => {
const config = await getOidcConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
const register = url.searchParams.get('mode') === 'register';
const returnTo = url.searchParams.get('returnTo') ?? '/';
const parameters: Record<string, string> = {
redirect_uri: authConfig.redirectUri,
scope: authConfig.scope,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
nonce
};
if (register) parameters.prompt = 'create';
const authorizationUrl = client.buildAuthorizationUrl(config, parameters);
const cookieOptions = {
httpOnly: true,
sameSite: 'lax' as const,
secure: url.protocol === 'https:',
path: '/',
maxAge: authConfig.flowTtlSeconds
};
cookies.set(VERIFIER_COOKIE, codeVerifier, cookieOptions);
cookies.set(STATE_COOKIE, state, cookieOptions);
cookies.set(NONCE_COOKIE, nonce, cookieOptions);
cookies.set(RETURN_COOKIE, returnTo, cookieOptions);
redirect(302, authorizationUrl.href);
};
+181
View File
@@ -0,0 +1,181 @@
@import 'tailwindcss';
@theme {
--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 {
scrollbar-gutter: stable;
}
:root {
color-scheme: dark;
}
button:not(:disabled),
[role='button'],
label:has(input[type='file']),
summary {
cursor: pointer;
}
button:disabled {
cursor: not-allowed;
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(0.75rem);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes pop-in {
from {
opacity: 0;
transform: scale(0.96) translateY(0.5rem);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes equalize {
0%,
100% {
transform: scaleY(0.35);
}
50% {
transform: scaleY(1);
}
}
@keyframes breathe {
0%,
100% {
opacity: 0.55;
transform: scale(1.2);
}
50% {
opacity: 0.78;
transform: scale(1.32);
}
}
@keyframes sheen {
from {
background-position: 200% 0;
}
to {
background-position: -200% 0;
}
}
.animate-enter {
animation: fade-up 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-pop {
animation: pop-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.animate-fade {
animation: fade-in 0.4s ease both;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-delay: 0ms !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
html {
scroll-behavior: smooth;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
background-color: var(--mf-bg);
}
html.home {
background-image: linear-gradient(to bottom, var(--mf-accent), transparent 340px);
background-repeat: no-repeat;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.18);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.32);
}
::-webkit-scrollbar-track {
background: transparent;
}
@@ -0,0 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = ({ locals, url }) => {
if (locals.user) {
redirect(302, url.searchParams.get('returnTo') ?? '/');
}
return { returnTo: url.searchParams.get('returnTo') ?? '/' };
};
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import Button from '$lib/components/ui/Button.svelte';
let { data } = $props();
const returnParam = $derived(`returnTo=${encodeURIComponent(data.returnTo)}`);
</script>
<svelte:head>
<title>Iniciar sesión · Musify</title>
<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-bg px-6 text-fg">
<div class="flex w-full max-w-[440px] flex-col items-center gap-9 text-center">
<div class="text-3xl font-extrabold tracking-tight">Musify</div>
<div>
<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">
<Button href={`/auth/login?${returnParam}`} reload size="lg" class="w-full">
Iniciar Sesión
</Button>
<Button
href={`/auth/login?mode=register&${returnParam}`}
reload
variant="subtle"
size="lg"
class="w-full"
>
Crear Cuenta
</Button>
</div>
</div>
</div>
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit';
import * as client from 'openid-client';
import type { RequestHandler } from './$types';
import { getOidcConfig, decodeSession, SESSION_COOKIE } from '$lib/server/auth';
import { authConfig } from '$lib/server/config';
const handleLogout: RequestHandler = async ({ cookies }) => {
const session = await decodeSession(cookies.get(SESSION_COOKIE));
cookies.delete(SESSION_COOKIE, { path: '/' });
const config = await getOidcConfig();
const endSessionUrl = client.buildEndSessionUrl(config, {
post_logout_redirect_uri: authConfig.postLogoutUri,
...(session?.idToken ? { id_token_hint: session.idToken } : {})
});
redirect(302, endSessionUrl.href);
};
export const GET = handleLogout;
export const POST = handleLogout;
+3
View File
@@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"rewriteRelativeImportExtensions": true,
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
}
+20
View File
@@ -0,0 +1,20 @@
import tailwindcss from '@tailwindcss/vite';
import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
ssr: {
noExternal: ['@lucide/svelte']
},
plugins: [
tailwindcss(),
sveltekit({
compilerOptions: {
runes: ({ filename }) =>
filename.split(/[/\\]/).includes('node_modules') ? undefined : true
},
adapter: adapter()
})
]
});