web-player: big pages (Explore, Home) and tracks.ts cleanup

This pass also tackles the task deferred earlier: removing
tracks.ts's TrackTarget wrapper and its 8 accessors
(targetId/targetTitle/targetArtist/targetOwnerUserId/targetExplicit/
targetListensCount/isTargetCurrent/queueItemForTarget/
targetForQueueItem). Explore, Home, Mix, menus.svelte.ts and mixes.ts
now use track objects directly, with
toQueueItem/trackFromQueueItem (new, in player.svelte.ts) as the only
converters. Along the way, TypeScript revealed that isExplicit
doesn't exist in the real /tracks schema: Explore's "explicit" badge
was already a no-op before this refactor (always undefined ??
false); the binding is removed instead of pretending support that
never existed.

Explore: GENRE_INFO/genreTiles are extracted to src/lib/genres.ts;
the search logic
(showGroup/capped/matchPlaylists/searchCounts/searchChips/findTopResult)
is extracted to src/lib/search.ts as pure functions parameterized by
filter/query instead of closing over component state. "Top result"
unifies the duplicated button/link with <svelte:element>. Decision
made without blocking: the 4 group blocks (Tracks/Albums/Users/
Playlists) are not collapsed into a single {#each groups} — each
group has genuinely different snippets, context menu and ListRow
props, and a generic config would have added more indirection than it
removed.

Home: uses Page instead of a hand-rolled page-x; the 3 stats become
an {#each}; the hero/spotlight backgrounds (inline style= with CSS
variables) become the hero-surface/spotlight-surface utilities, and
gap-11.5 is replaced by the --spacing-section token (2.875rem) via
gap-section.

Verified with npm run check (0 errors) and npm run build. /explore
without a session redirects to /login because +layout.server.ts
requires allowAnonymousListening from the backend (unreachable
without Docker this session) — pre-existing behavior, not a
regression. No authenticated visual QA; smoke-tested /login with no
console errors.
This commit is contained in:
2026-09-21 14:51:43 +02:00
parent 5fa901c2e6
commit b60a87adb5
11 changed files with 458 additions and 443 deletions
+24
View File
@@ -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<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
+5 -6
View File
@@ -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<TrackMenuState | null>(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;
}
};
+6 -8
View File
@@ -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
};
}
@@ -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);
}
+74
View File
@@ -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<SearchFilter, 'Todo'>) {
return filter === 'Todo' || filter === label;
}
export function capped<T>(filter: SearchFilter, list: T[]) {
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
}
export function matchPlaylists<T extends { name: string }>(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<typeof searchCounts>) {
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<Track, Album, Playlist, User> =
| { 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<Track, Album, Playlist, User> | 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;
}
-48
View File
@@ -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
}
};
}