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:
@@ -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
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user