diff --git a/web-player/docs/frontend-refactor-plan.md b/web-player/docs/frontend-refactor-plan.md index 596f1d0..698308f 100644 --- a/web-player/docs/frontend-refactor-plan.md +++ b/web-player/docs/frontend-refactor-plan.md @@ -785,7 +785,7 @@ Tareas: | P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) | | P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) | | P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola | -| P13 | ⬜ | | | +| P13 | ✅ | (pendiente) | Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio `TrackTarget` de `tracks.ts` y sus 8 accesores (`targetId`/`targetTitle`/`targetArtist`/`targetOwnerUserId`/`targetExplicit`/`targetListensCount`/`isTargetCurrent`/`queueItemForTarget`/`targetForQueueItem`); Explorar, Inicio, Mezcla, `menus.svelte.ts` y `mixes.ts` pasan a usar los objetos de pista (`ApiTrackLike`/`LocalTrack`) directamente, con `toQueueItem`/`trackFromQueueItem` (nuevo, en `player.svelte.ts`) como únicos conversores. TypeScript reveló que el campo `isExplicit` no existe en el schema real de `/tracks` — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre `undefined ?? false`); se retira el binding en vez de fingir soporte. Explorar: `GENRE_INFO`/`genreTiles` → `src/lib/genres.ts`; lógica de búsqueda (`showGroup`/`capped`/`matchPlaylists`/`searchCounts`/`searchChips`/`findTopResult`) → `src/lib/search.ts` (funciones puras, parametrizadas por `sfilter`/`query` en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con ``. Decisión tomada sin bloquear: **no** se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único `{#each groups}` — cada grupo tiene snippets, contextmenu y props de `ListRow` genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa `Page` (antes `page-x pt-3 sm:pt-4` a mano); stats ×3 → `{#each}`; fondos de hero/spotlight (`style=` con `var(--mf-hero-bg)`/`var(--mf-spotlight-bg)`) → utilities `hero-surface`/`spotlight-surface` (nuevas, en `layout.css`) y token `--spacing-section: 2.875rem` → `gap-section` sustituye `gap-11.5`. Sin QA visual autenticada (Docker no disponible); `/explore` sin sesión redirige a `/login` porque `+layout.server.ts` exige `allowAnonymousListening` desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de `/login` sin errores de consola | | P14 | ⬜ | | | Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada): diff --git a/web-player/src/lib/genres.ts b/web-player/src/lib/genres.ts new file mode 100644 index 0000000..64c10d0 --- /dev/null +++ b/web-player/src/lib/genres.ts @@ -0,0 +1,24 @@ +// No hay sistema de tags/géneros todavía: esta lista es un placeholder de +// cliente y cada tile enlaza a una búsqueda normal por ese término. +export const GENRE_INFO: Record = { + pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, + rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, + reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, + 'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }, + indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, + salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' }, + 'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' }, + electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' }, + jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, + baladas: { label: 'Baladas', tagline: 'Para sentir con calma' }, + trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' }, + cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' }, + 'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' }, + 'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } +}; + +export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({ + query, + hue: Math.round((i * 360) / all.length), + ...info +})); diff --git a/web-player/src/lib/menus.svelte.ts b/web-player/src/lib/menus.svelte.ts index 838530c..864dcec 100644 --- a/web-player/src/lib/menus.svelte.ts +++ b/web-player/src/lib/menus.svelte.ts @@ -1,8 +1,7 @@ import { contextMenuPosition, type MenuPosition } from '$lib/components/ui/ContextMenu.svelte'; -import { player } from './player/player.svelte'; -import { queueItemForTarget, type TrackTarget } from './tracks'; +import { player, toQueueItem, type ApiTrackLike } from './player/player.svelte'; -export type TrackMenuState = TrackTarget & MenuPosition; +export type TrackMenuState = { track: ApiTrackLike } & MenuPosition; export function createTrackMenu() { let state = $state(null); @@ -11,15 +10,15 @@ export function createTrackMenu() { get state() { return state; }, - open(event: MouseEvent, target: TrackTarget) { - state = { ...target, ...contextMenuPosition(event) }; + open(event: MouseEvent, track: ApiTrackLike) { + state = { track, ...contextMenuPosition(event) }; }, close() { state = null; }, playNext() { if (!state) return; - player.playNextItem(queueItemForTarget(state)); + player.playNextItem(toQueueItem(state.track)); state = null; } }; diff --git a/web-player/src/lib/mixes.ts b/web-player/src/lib/mixes.ts index 225fbb0..e08ab79 100644 --- a/web-player/src/lib/mixes.ts +++ b/web-player/src/lib/mixes.ts @@ -1,5 +1,5 @@ import type { components } from '$lib/api/schema'; -import type { TrackTarget } from '$lib/tracks'; +import type { ApiTrackLike } from '$lib/player/player.svelte'; export type Mix = components['schemas']['MixApplicationResponse']; export type MixItem = components['schemas']['MixItemApplicationResponse']; @@ -8,13 +8,11 @@ export function mixItemKey(item: MixItem): string { return `mf:${item.trackId}`; } -export function mixItemTarget(item: MixItem): TrackTarget { +export function mixItemTrack(item: MixItem): ApiTrackLike { return { - track: { - id: item.trackId, - title: item.title, - artist: item.artist, - listensCount: item.listensCount - } + id: item.trackId, + title: item.title, + artist: item.artist, + listensCount: item.listensCount }; } diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 8f7ccb4..6000680 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -52,6 +52,17 @@ export function toQueueItem(track: ApiTrackLike): QueueItem { return toQueueItems([track])[0]; } +export function trackFromQueueItem(item: QueueItem): ApiTrackLike { + return { + id: item.id, + title: item.title, + artist: item.artist, + isExplicit: item.explicit, + ownerUserId: item.ownerUserId, + listensCount: item.listensCount + }; +} + export function isQueueCurrent(items: { id: string | number }[]): boolean { return items.some((item) => item.id === player.current.id); } diff --git a/web-player/src/lib/search.ts b/web-player/src/lib/search.ts new file mode 100644 index 0000000..fda4ac8 --- /dev/null +++ b/web-player/src/lib/search.ts @@ -0,0 +1,74 @@ +export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const; +export type SearchFilter = (typeof SEARCH_FILTERS)[number]; +export const SEARCH_GROUP_PREVIEW = 4; + +export function showGroup(filter: SearchFilter, label: Exclude) { + return filter === 'Todo' || filter === label; +} + +export function capped(filter: SearchFilter, list: T[]) { + return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list; +} + +export function matchPlaylists(playlists: T[], query: string): T[] { + return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : []; +} + +export function searchCounts(counts: { + tracks: number; + albums: number; + playlists: number; + users: number; +}) { + return { + Canciones: counts.tracks, + Álbumes: counts.albums, + Playlists: counts.playlists, + Usuarios: counts.users + }; +} + +export function searchChips(counts: ReturnType) { + const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios; + return SEARCH_FILTERS.map((label) => ({ + label, + count: label === 'Todo' ? total : counts[label] + })); +} + +export type TopResult = + | { kind: 'track'; track: Track } + | { kind: 'album'; album: Album } + | { kind: 'playlist'; playlist: Playlist } + | { kind: 'user'; user: User }; + +export function findTopResult< + Track extends { title: string }, + Album extends { title: string }, + Playlist extends { name: string }, + User extends { name: string } +>(params: { + query: string; + tracks: Track[]; + albums: Album[]; + playlists: Playlist[]; + users: User[]; +}): TopResult | null { + const q = params.query.trim().toLowerCase(); + const startsWithQuery = (value: string) => value.toLowerCase().startsWith(q); + + const userHit = params.users.find((u) => startsWithQuery(u.name)); + if (userHit) return { kind: 'user', user: userHit }; + const trackHit = params.tracks.find((track) => startsWithQuery(track.title)); + if (trackHit) return { kind: 'track', track: trackHit }; + const albumHit = params.albums.find((album) => startsWithQuery(album.title)); + if (albumHit) return { kind: 'album', album: albumHit }; + const playlistHit = params.playlists.find((p) => startsWithQuery(p.name)); + if (playlistHit) return { kind: 'playlist', playlist: playlistHit }; + + if (params.users.length) return { kind: 'user', user: params.users[0] }; + if (params.tracks.length) return { kind: 'track', track: params.tracks[0] }; + if (params.albums.length) return { kind: 'album', album: params.albums[0] }; + if (params.playlists.length) return { kind: 'playlist', playlist: params.playlists[0] }; + return null; +} diff --git a/web-player/src/lib/tracks.ts b/web-player/src/lib/tracks.ts deleted file mode 100644 index 8dd564f..0000000 --- a/web-player/src/lib/tracks.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; - -export type TrackTarget = { track: ApiTrackLike }; - -export function targetId(target: TrackTarget): string | number { - return target.track.id; -} - -export function targetTitle(target: TrackTarget): string { - return target.track.title; -} - -export function targetArtist(target: TrackTarget): string | undefined { - return target.track.artist ?? undefined; -} - -export function targetOwnerUserId(target: TrackTarget): string | number | undefined { - return target.track.ownerUserId ?? undefined; -} - -export function targetExplicit(target: TrackTarget): boolean { - return target.track.isExplicit ?? false; -} - -export function targetListensCount(target: TrackTarget): number | string | undefined { - return target.track.listensCount; -} - -export function isTargetCurrent(target: TrackTarget): boolean { - return player.current.id === target.track.id; -} - -export function queueItemForTarget(target: TrackTarget): QueueItem { - return toQueueItems([target.track])[0]; -} - -export function targetForQueueItem(item: QueueItem): TrackTarget { - return { - track: { - id: item.id, - title: item.title, - artist: item.artist, - isExplicit: item.explicit, - ownerUserId: item.ownerUserId, - listensCount: item.listensCount - } - }; -} diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 7cc6de5..72e36d6 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -1,6 +1,6 @@