diff --git a/web-player/src/lib/data/albums.ts b/web-player/src/lib/data/albums.ts new file mode 100644 index 0000000..9d85015 --- /dev/null +++ b/web-player/src/lib/data/albums.ts @@ -0,0 +1,8 @@ +import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; + +export async function fetchAlbumQueueItems(albumId: string): Promise { + const res = await fetch(`/api/albums/${albumId}/tracks`); + if (!res.ok) return []; + const data = (await res.json()) as { items: ApiTrackLike[] }; + return toQueueItems(data.items ?? []); +} diff --git a/web-player/src/lib/data/collections.ts b/web-player/src/lib/data/collections.ts new file mode 100644 index 0000000..4a6fd65 --- /dev/null +++ b/web-player/src/lib/data/collections.ts @@ -0,0 +1,24 @@ +export function shuffle(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 function appendUnique( + 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; +} diff --git a/web-player/src/lib/data/genres.ts b/web-player/src/lib/data/genres.ts new file mode 100644 index 0000000..8dbc9f2 --- /dev/null +++ b/web-player/src/lib/data/genres.ts @@ -0,0 +1,22 @@ +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/data/mixes.ts b/web-player/src/lib/data/mixes.ts new file mode 100644 index 0000000..e08ab79 --- /dev/null +++ b/web-player/src/lib/data/mixes.ts @@ -0,0 +1,18 @@ +import type { components } from '$lib/api/schema'; +import type { ApiTrackLike } from '$lib/player/player.svelte'; + +export type Mix = components['schemas']['MixApplicationResponse']; +export type MixItem = components['schemas']['MixItemApplicationResponse']; + +export function mixItemKey(item: MixItem): string { + return `mf:${item.trackId}`; +} + +export function mixItemTrack(item: MixItem): ApiTrackLike { + return { + id: item.trackId, + title: item.title, + artist: item.artist, + listensCount: item.listensCount + }; +} diff --git a/web-player/src/lib/data/recentlyPlayed.ts b/web-player/src/lib/data/recentlyPlayed.ts new file mode 100644 index 0000000..9b110d6 --- /dev/null +++ b/web-player/src/lib/data/recentlyPlayed.ts @@ -0,0 +1,22 @@ +import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte'; + +export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] { + const seen = new Set(); + const merged: QueueItem[] = []; + + for (const t of player.recentlyPlayed) { + const id = String(t.id); + if (seen.has(id)) continue; + seen.add(id); + merged.push({ ...t, id }); + } + + for (const item of toQueueItems(history)) { + const id = String(item.id); + if (seen.has(id)) continue; + seen.add(id); + merged.push({ ...item, id }); + } + + return merged.slice(0, limit); +} diff --git a/web-player/src/lib/data/search.ts b/web-player/src/lib/data/search.ts new file mode 100644 index 0000000..fda4ac8 --- /dev/null +++ b/web-player/src/lib/data/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/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 6000680..3ffb930 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -1,7 +1,7 @@ import { browser } from '$app/environment'; import { DEFAULT_ACCENT } from '$lib/theme/color'; import { extractAccent, type Accent } from '$lib/theme/palette'; -import { shuffle } from '$lib/collections'; +import { shuffle } from '$lib/data/collections'; export interface PlayerTrack { id: string | number; diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 3e896c2..8cfb94b 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -1,7 +1,7 @@