Web Player Refactor #2
@@ -0,0 +1,8 @@
|
|||||||
|
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
||||||
|
|
||||||
|
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
||||||
|
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||||
|
return toQueueItems(data.items ?? []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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 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,22 @@
|
|||||||
|
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
|
||||||
|
}));
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<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({ ...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);
|
||||||
|
}
|
||||||
@@ -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,7 +1,7 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
import { DEFAULT_ACCENT } from '$lib/theme/color';
|
||||||
import { extractAccent, type Accent } from '$lib/theme/palette';
|
import { extractAccent, type Accent } from '$lib/theme/palette';
|
||||||
import { shuffle } from '$lib/collections';
|
import { shuffle } from '$lib/data/collections';
|
||||||
|
|
||||||
export interface PlayerTrack {
|
export interface PlayerTrack {
|
||||||
id: string | number;
|
id: string | number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItem } from '$lib/player/player.svelte';
|
import { player, toQueueItem } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumQueueItems } from '$lib/albums';
|
import { fetchAlbumQueueItems } from '$lib/data/albums';
|
||||||
import { fmtTime, fmtPlays, plural } from '$lib/format';
|
import { fmtTime, fmtPlays, plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||||
import { appendUnique } from '$lib/collections';
|
import { appendUnique } from '$lib/data/collections';
|
||||||
import { genreTiles } from '$lib/genres';
|
import { genreTiles } from '$lib/data/genres';
|
||||||
import {
|
import {
|
||||||
type SearchFilter,
|
type SearchFilter,
|
||||||
showGroup,
|
showGroup,
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
searchCounts,
|
searchCounts,
|
||||||
searchChips,
|
searchChips,
|
||||||
findTopResult
|
findTopResult
|
||||||
} from '$lib/search';
|
} from '$lib/data/search';
|
||||||
|
|
||||||
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
type AlbumMenuState = { x: number; y: number; openLeft: boolean; albumId: string };
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
|
||||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
import { createTrackMenu } from '$lib/state/menus.svelte';
|
||||||
import { mixItemTrack } from '$lib/mixes';
|
import { mixItemTrack } from '$lib/data/mixes';
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,8 @@
|
|||||||
type ApiTrackLike
|
type ApiTrackLike
|
||||||
} from '$lib/player/player.svelte';
|
} from '$lib/player/player.svelte';
|
||||||
import { pressable } from '$lib/actions/pressable';
|
import { pressable } from '$lib/actions/pressable';
|
||||||
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||||
import { mixItemTrack } from '$lib/mixes';
|
import { mixItemTrack } from '$lib/data/mixes';
|
||||||
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
|
||||||
import Page from '$lib/components/ui/Page.svelte';
|
import Page from '$lib/components/ui/Page.svelte';
|
||||||
import Artwork from '$lib/components/ui/Artwork.svelte';
|
import Artwork from '$lib/components/ui/Artwork.svelte';
|
||||||
|
|||||||
Reference in New Issue
Block a user