From 1320b12699189292e0292a7dfc14407322e662ec Mon Sep 17 00:00:00 2001 From: XShine555 Date: Sun, 20 Sep 2026 23:01:13 +0200 Subject: [PATCH] web-player: TypeScript helpers (partial) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/lib/actions/pressable.ts and clickOutside.ts (Svelte actions; role/tabindex stay static in markup for SSR accessibility, the action only adds click + Enter/Space) and src/lib/toggle.svelte.ts (createToggle), used by playlists.svelte.ts and queuePanel.svelte.ts. format.ts: adds plural(n, one, many); migrates the repo's 15 manual pluralizations, unifying them to lowercase (visible diff in Playlists, Home, liked, Explore). player.svelte.ts: adds toQueueItem, isQueueCurrent, playAllOrToggle, playShuffled; renames addToQueue -> playNextItem (the old name was misleading: it inserts next, not at the end). Migrates the 5 places that repeated the isCurrentQueue/playAll pattern (album, playlist, liked, mix, Home). Simplifies liked.svelte.ts and recentlyPlayed.ts with object spread instead of rebuilding every field by hand. navigation.svelte.ts: adds searchHref (migrates the duplicated buildHref from TopBar and Explore) and appNavLinks (added; its adoption in Sidebar/TabsBar is left for a later pass, as noted there). menus.svelte.ts: createTrackMenu() replaces the manual context-menu state (contextMenu + contextMenuStateFor + addToQueue) in Home, Explore and Mix. Deliberately left pending: removing tracks.ts's TrackTarget wrapper and its 8 accessors is deferred — it's the riskiest change in the plan, touches types across Home/Explore/Mix/menus, and couldn't be visually verified without Docker this session. Verified with npm run check/build. --- web-player/docs/frontend-refactor-plan.md | 2 +- web-player/src/lib/actions/clickOutside.ts | 17 +++++ web-player/src/lib/actions/pressable.ts | 25 +++++++ web-player/src/lib/components/TopBar.svelte | 7 +- .../src/lib/components/player/Queue.svelte | 8 +-- .../src/lib/components/ui/AccountMenu.svelte | 10 +-- .../src/lib/components/ui/AlbumCard.svelte | 5 +- .../src/lib/components/ui/PlaylistCard.svelte | 3 +- .../lib/components/ui/TrackTitleCell.svelte | 17 ++--- web-player/src/lib/format.ts | 6 +- web-player/src/lib/menus.svelte.ts | 27 ++++++++ web-player/src/lib/navigation.svelte.ts | 45 +++++++++++++ web-player/src/lib/player/liked.svelte.ts | 23 ++----- web-player/src/lib/player/player.svelte.ts | 22 ++++++- .../src/lib/player/queuePanel.svelte.ts | 17 +---- web-player/src/lib/playlists.svelte.ts | 14 +--- web-player/src/lib/recentlyPlayed.ts | 18 +---- web-player/src/lib/toggle.svelte.ts | 18 +++++ .../src/routes/(app)/albums/[id]/+page.svelte | 8 +-- .../albums/[id]/components/AlbumHeader.svelte | 6 +- .../src/routes/(app)/explore/+page.svelte | 52 +++++---------- .../src/routes/(app)/liked/+page.svelte | 18 ++--- .../src/routes/(app)/mixes/[id]/+page.svelte | 50 +++++--------- .../routes/(app)/playlists/[id]/+page.svelte | 8 +-- web-player/src/routes/+page.svelte | 65 ++++++------------- 25 files changed, 250 insertions(+), 241 deletions(-) create mode 100644 web-player/src/lib/actions/clickOutside.ts create mode 100644 web-player/src/lib/actions/pressable.ts create mode 100644 web-player/src/lib/menus.svelte.ts create mode 100644 web-player/src/lib/toggle.svelte.ts diff --git a/web-player/docs/frontend-refactor-plan.md b/web-player/docs/frontend-refactor-plan.md index ec32700..1943501 100644 --- a/web-player/docs/frontend-refactor-plan.md +++ b/web-player/docs/frontend-refactor-plan.md @@ -777,7 +777,7 @@ Tareas: | P2 | ✅ | (pendiente) | `` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`\`}` | | P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) | | P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker | -| P5 | ⬜ | | | +| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas | | P6 | ⬜ | | | | P7 | ⬜ | | | | P8 | ⬜ | | | diff --git a/web-player/src/lib/actions/clickOutside.ts b/web-player/src/lib/actions/clickOutside.ts new file mode 100644 index 0000000..6c94074 --- /dev/null +++ b/web-player/src/lib/actions/clickOutside.ts @@ -0,0 +1,17 @@ +import type { Action } from 'svelte/action'; + +export const clickOutside: Action void> = (node, fn) => { + function onClick(event: MouseEvent) { + if (!node.contains(event.target as Node)) fn(); + } + document.addEventListener('click', onClick, true); + + return { + update(next) { + fn = next; + }, + destroy() { + document.removeEventListener('click', onClick, true); + } + }; +}; diff --git a/web-player/src/lib/actions/pressable.ts b/web-player/src/lib/actions/pressable.ts new file mode 100644 index 0000000..6538c34 --- /dev/null +++ b/web-player/src/lib/actions/pressable.ts @@ -0,0 +1,25 @@ +import type { Action } from 'svelte/action'; + +export const pressable: Action void> = (node, fn) => { + function onClick() { + fn(); + } + function onKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + fn(); + } + + node.addEventListener('click', onClick); + node.addEventListener('keydown', onKeydown); + + return { + update(next) { + fn = next; + }, + destroy() { + node.removeEventListener('click', onClick); + node.removeEventListener('keydown', onKeydown); + } + }; +}; diff --git a/web-player/src/lib/components/TopBar.svelte b/web-player/src/lib/components/TopBar.svelte index b5241e7..fcc7777 100644 --- a/web-player/src/lib/components/TopBar.svelte +++ b/web-player/src/lib/components/TopBar.svelte @@ -3,6 +3,7 @@ import { page } from '$app/state'; import type { SessionUser } from '$lib/types'; import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config'; + import { searchHref } from '$lib/navigation.svelte'; import Search from '@lucide/svelte/icons/search'; import X from '@lucide/svelte/icons/x'; import ChevronLeft from '@lucide/svelte/icons/chevron-left'; @@ -24,13 +25,9 @@ let searchInput: HTMLInputElement | undefined = $state(); let searchTimeout: ReturnType | undefined; - function buildHref(term: string) { - return term ? `/explore?q=${encodeURIComponent(term)}` : '/explore'; - } - function runSearch(term: string) { if (term.length > 0 && term.length < SEARCH_MIN_LENGTH) return; - goto(buildHref(term), { + goto(searchHref(term), { replaceState: page.url.pathname === '/explore', keepFocus: true, noScroll: true diff --git a/web-player/src/lib/components/player/Queue.svelte b/web-player/src/lib/components/player/Queue.svelte index fa16085..c19b263 100644 --- a/web-player/src/lib/components/player/Queue.svelte +++ b/web-player/src/lib/components/player/Queue.svelte @@ -4,6 +4,7 @@ import Artwork from '$lib/components/ui/Artwork.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte'; import IconButton from '$lib/components/ui/IconButton.svelte'; + import { pressable } from '$lib/actions/pressable'; import { fmtTime } from '$lib/format'; import X from '@lucide/svelte/icons/x'; import { fly } from 'svelte/transition'; @@ -71,12 +72,7 @@
player.playQueueIndex(index)} - onkeydown={(e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - player.playQueueIndex(index); - }} + use:pressable={() => player.playQueueIndex(index)} class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover" > diff --git a/web-player/src/lib/components/ui/AccountMenu.svelte b/web-player/src/lib/components/ui/AccountMenu.svelte index cb0b2b4..2752c37 100644 --- a/web-player/src/lib/components/ui/AccountMenu.svelte +++ b/web-player/src/lib/components/ui/AccountMenu.svelte @@ -9,6 +9,7 @@ import MenuItem from './MenuItem.svelte'; import GlassMenu from './GlassMenu.svelte'; import { themeMode } from '$lib/theme/mode.svelte'; + import { clickOutside } from '$lib/actions/clickOutside'; interface Props { user: SessionUser; @@ -27,7 +28,6 @@ }: Props = $props(); let open = $state(false); - let menuRef: HTMLDivElement | undefined = $state(); function toggle() { open = !open; @@ -36,13 +36,9 @@ function close() { open = false; } - - function onDocumentClick(event: MouseEvent) { - if (menuRef && !menuRef.contains(event.target as Node)) open = false; - } -
+
{@render trigger({ toggle, open })} {#if open} @@ -69,5 +65,3 @@ {/if}
- - diff --git a/web-player/src/lib/components/ui/AlbumCard.svelte b/web-player/src/lib/components/ui/AlbumCard.svelte index f3ac70c..ca37ffa 100644 --- a/web-player/src/lib/components/ui/AlbumCard.svelte +++ b/web-player/src/lib/components/ui/AlbumCard.svelte @@ -1,6 +1,7 @@ diff --git a/web-player/src/lib/components/ui/TrackTitleCell.svelte b/web-player/src/lib/components/ui/TrackTitleCell.svelte index 14dd3d8..221375f 100644 --- a/web-player/src/lib/components/ui/TrackTitleCell.svelte +++ b/web-player/src/lib/components/ui/TrackTitleCell.svelte @@ -3,6 +3,7 @@ import Artwork from './Artwork.svelte'; import ExplicitBadge from './ExplicitBadge.svelte'; import ArtistLink from './ArtistLink.svelte'; + import { pressable } from '$lib/actions/pressable'; interface Props { trackId: string | number; @@ -33,24 +34,14 @@ overlay, badge }: Props = $props(); - - function onKeydown(event: KeyboardEvent) { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - onClick(); - } - - function onclick(event: MouseEvent) { - event.stopPropagation(); - onClick(); - } +
event.stopPropagation()} class="flex min-w-0 items-center gap-3.5 text-left" > diff --git a/web-player/src/lib/format.ts b/web-player/src/lib/format.ts index 5b59a5e..477832a 100644 --- a/web-player/src/lib/format.ts +++ b/web-player/src/lib/format.ts @@ -18,7 +18,11 @@ export function fmtDate(value: string | number | Date): string { return dateFormatter.format(new Date(value)); } +export function plural(n: number, one: string, many: string): string { + return `${n} ${n === 1 ? one : many}`; +} + export function fmtPlays(count: number | string | undefined): string { const value = Math.max(0, Math.floor(Number(count ?? 0))); - return `${value} ${value === 1 ? 'Reproducción' : 'Reproducciones'}`; + return plural(value, 'reproducción', 'reproducciones'); } diff --git a/web-player/src/lib/menus.svelte.ts b/web-player/src/lib/menus.svelte.ts new file mode 100644 index 0000000..176272a --- /dev/null +++ b/web-player/src/lib/menus.svelte.ts @@ -0,0 +1,27 @@ +import { + contextMenuStateFor, + type ContextMenuState +} from '$lib/components/TrackContextMenu.svelte'; +import { player } from './player/player.svelte'; +import { queueItemForTarget, type TrackTarget } from './tracks'; + +export function createTrackMenu() { + let state = $state(null); + + return { + get state() { + return state; + }, + open(event: MouseEvent, target: TrackTarget) { + state = contextMenuStateFor(event, target); + }, + close() { + state = null; + }, + playNext() { + if (!state) return; + player.playNextItem(queueItemForTarget(state)); + state = null; + } + }; +} diff --git a/web-player/src/lib/navigation.svelte.ts b/web-player/src/lib/navigation.svelte.ts index ad456db..d5bb42d 100644 --- a/web-player/src/lib/navigation.svelte.ts +++ b/web-player/src/lib/navigation.svelte.ts @@ -1,4 +1,13 @@ import { afterNavigate } from '$app/navigation'; +import type { LucideIcon } from '@lucide/svelte'; +import Home from '@lucide/svelte/icons/house'; +import Compass from '@lucide/svelte/icons/compass'; +import ListMusic from '@lucide/svelte/icons/list-music'; +import Heart from '@lucide/svelte/icons/heart'; +import Disc from '@lucide/svelte/icons/disc-2'; +import Folder from '@lucide/svelte/icons/folder'; +import Upload from '@lucide/svelte/icons/upload'; +import type { SessionUser } from './types'; const HISTORY_INDEX = 'sveltekit:history'; @@ -54,3 +63,39 @@ export function isSectionActive(href: string, pathname: string, section?: string if (section !== undefined) return section === href; return href === '/' ? pathname === '/' : pathname.startsWith(href); } + +export function searchHref(query: string): string { + return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; +} + +export interface AppNavLink { + href: string; + label: string; + icon: LucideIcon; + count?: number; + primary: boolean; +} + +export interface AppNavCounts { + playlists?: number; + liked?: number; +} + +export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] { + if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }]; + return [ + { href: '/', label: 'Inicio', icon: Home, primary: true }, + { href: '/explore', label: 'Descubrir', icon: Compass, primary: true }, + { + href: '/playlists', + label: 'Playlists', + icon: ListMusic, + count: counts.playlists, + primary: true + }, + { href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true }, + { href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false }, + { href: '/library', label: 'Canciones subidas', icon: Folder, primary: false }, + { href: '/upload', label: 'Subir música', icon: Upload, primary: false } + ]; +} diff --git a/web-player/src/lib/player/liked.svelte.ts b/web-player/src/lib/player/liked.svelte.ts index 3485624..54a2f00 100644 --- a/web-player/src/lib/player/liked.svelte.ts +++ b/web-player/src/lib/player/liked.svelte.ts @@ -34,13 +34,10 @@ class LikedStore { const items = toQueueItems(tracks); const next: Record = {}; items.forEach((item, i) => { - next[String(item.id)] = { - id: String(item.id), - title: item.title, - artist: item.artist, - explicit: item.explicit, - ownerUserId: item.ownerUserId, - listensCount: item.listensCount, + const id = String(item.id); + next[id] = { + ...item, + id, duration: tracks[i].duration, likedAt: new Date(tracks[i].createdAt).getTime() }; @@ -58,17 +55,7 @@ class LikedStore { const wasLiked = id in before; const next = { ...before }; if (wasLiked) delete next[id]; - else { - next[id] = { - id, - title: track.title, - artist: track.artist, - explicit: track.explicit, - ownerUserId: track.ownerUserId, - duration: track.duration, - likedAt: Date.now() - }; - } + else next[id] = { ...track, id, likedAt: Date.now() }; this.entries = next; try { diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index 1772b16..8f7ccb4 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -1,6 +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'; export interface PlayerTrack { id: string | number; @@ -47,6 +48,25 @@ export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] { })); } +export function toQueueItem(track: ApiTrackLike): QueueItem { + return toQueueItems([track])[0]; +} + +export function isQueueCurrent(items: { id: string | number }[]): boolean { + return items.some((item) => item.id === player.current.id); +} + +export function playAllOrToggle(items: QueueItem[]) { + if (items.length === 0) return; + if (isQueueCurrent(items)) player.toggle(); + else player.playQueue(items, 0); +} + +export function playShuffled(items: QueueItem[]) { + if (items.length === 0) return; + player.playQueue(shuffle(items), 0); +} + async function readErrorMessage(res: Response): Promise { try { const body = (await res.json()) as { message?: string }; @@ -291,7 +311,7 @@ class PlayerState { else this.playQueue(list, index); } - addToQueue(item: QueueItem) { + playNextItem(item: QueueItem) { this.playNext([item]); } diff --git a/web-player/src/lib/player/queuePanel.svelte.ts b/web-player/src/lib/player/queuePanel.svelte.ts index f8f5ef7..78da1f2 100644 --- a/web-player/src/lib/player/queuePanel.svelte.ts +++ b/web-player/src/lib/player/queuePanel.svelte.ts @@ -1,16 +1,3 @@ -let open = $state(false); +import { createToggle } from '../toggle.svelte'; -export const queuePanel = { - get open() { - return open; - }, - toggle() { - open = !open; - }, - close() { - open = false; - }, - show() { - open = true; - } -}; +export const queuePanel = createToggle(); diff --git a/web-player/src/lib/playlists.svelte.ts b/web-player/src/lib/playlists.svelte.ts index b2aa6c4..ef78ebd 100644 --- a/web-player/src/lib/playlists.svelte.ts +++ b/web-player/src/lib/playlists.svelte.ts @@ -1,13 +1,3 @@ -let open = $state(false); +import { createToggle } from './toggle.svelte'; -export const createPlaylistModal = { - get open() { - return open; - }, - show() { - open = true; - }, - close() { - open = false; - } -}; +export const createPlaylistModal = createToggle(); diff --git a/web-player/src/lib/recentlyPlayed.ts b/web-player/src/lib/recentlyPlayed.ts index 4144b9c..1fee45a 100644 --- a/web-player/src/lib/recentlyPlayed.ts +++ b/web-player/src/lib/recentlyPlayed.ts @@ -12,28 +12,14 @@ export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): Que const id = String(t.id); if (seen.has(id)) continue; seen.add(id); - merged.push({ - id, - title: t.title, - artist: t.artist, - explicit: t.explicit, - ownerUserId: t.ownerUserId, - listensCount: t.listensCount - }); + 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({ - id, - title: item.title, - artist: item.artist, - explicit: item.explicit, - ownerUserId: item.ownerUserId, - listensCount: item.listensCount - }); + merged.push({ ...item, id }); } return merged.slice(0, limit); diff --git a/web-player/src/lib/toggle.svelte.ts b/web-player/src/lib/toggle.svelte.ts new file mode 100644 index 0000000..bee2e96 --- /dev/null +++ b/web-player/src/lib/toggle.svelte.ts @@ -0,0 +1,18 @@ +export function createToggle(initial = false) { + let open = $state(initial); + + return { + get open() { + return open; + }, + show() { + open = true; + }, + close() { + open = false; + }, + toggle() { + open = !open; + } + }; +} diff --git a/web-player/src/routes/(app)/albums/[id]/+page.svelte b/web-player/src/routes/(app)/albums/[id]/+page.svelte index b154179..52b0713 100644 --- a/web-player/src/routes/(app)/albums/[id]/+page.svelte +++ b/web-player/src/routes/(app)/albums/[id]/+page.svelte @@ -1,5 +1,5 @@ diff --git a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte index 445afd2..d8f806a 100644 --- a/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte +++ b/web-player/src/routes/(app)/albums/[id]/components/AlbumHeader.svelte @@ -7,6 +7,7 @@ import BackLink from '$lib/components/ui/BackLink.svelte'; import Button from '$lib/components/ui/Button.svelte'; import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte'; + import { plural } from '$lib/format'; interface Props { title: string; @@ -35,10 +36,7 @@ }: Props = $props(); const meta = $derived( - [ - releaseYear ? String(releaseYear) : undefined, - `${trackIds.length} ${trackIds.length === 1 ? 'canción' : 'canciones'}` - ] + [releaseYear ? String(releaseYear) : undefined, plural(trackIds.length, 'canción', 'canciones')] .filter((part) => part !== undefined) .join(' · ') ); diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 5329697..664e9ec 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -2,7 +2,7 @@ import Music from '@lucide/svelte/icons/music'; import { player } from '$lib/player/player.svelte'; import { fetchAlbumQueueItems } from '$lib/albums'; - import { fmtTime, fmtPlays } from '$lib/format'; + import { fmtTime, fmtPlays, plural } from '$lib/format'; import Page from '$lib/components/ui/Page.svelte'; import PageHeader from '$lib/components/ui/PageHeader.svelte'; import Alert from '$lib/components/ui/Alert.svelte'; @@ -15,10 +15,9 @@ import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte'; import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import { EXPLORE_PAGE_SIZE } from '$lib/config'; - import TrackContextMenu, { - contextMenuStateFor, - type ContextMenuState - } from '$lib/components/TrackContextMenu.svelte'; + import { searchHref } from '$lib/navigation.svelte'; + import { createTrackMenu } from '$lib/menus.svelte'; + import TrackContextMenu from '$lib/components/TrackContextMenu.svelte'; import AlbumContextMenu, { albumContextMenuStateFor, type AlbumMenuState @@ -73,7 +72,7 @@ type SearchFilter = (typeof SEARCH_FILTERS)[number]; const SEARCH_GROUP_PREVIEW = 4; - let contextMenu = $state(null); + const trackMenu = createTrackMenu(); let albumMenu = $state(null); let sfilter = $state('Todo'); @@ -85,7 +84,7 @@ let loadingMore = $state(false); $effect(() => { - contextMenu = null; + trackMenu.close(); albumMenu = null; sfilter = 'Todo'; localItems = data.tracks.items.map((item) => item.track); @@ -170,10 +169,6 @@ return sfilter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list; } - function buildHref(query: string) { - return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; - } - function playTopResult() { if (topResult?.kind !== 'track') return; player.playOrToggle([queueItemForTarget(topResult.target)], 0); @@ -191,7 +186,7 @@ } function openContextMenu(event: MouseEvent, target: TrackTarget) { - contextMenu = contextMenuStateFor(event, target); + trackMenu.open(event, target); } function openAlbumMenu(event: MouseEvent, albumId: string) { @@ -212,12 +207,6 @@ player.appendToQueue(await fetchAlbumQueueItems(albumId)); } - function addToQueue() { - if (!contextMenu) return; - player.addToQueue(queueItemForTarget(contextMenu)); - contextMenu = null; - } - async function loadMore() { if (loadingMore) return; loadingMore = true; @@ -270,8 +259,7 @@ Resultados para «{data.query}»

- {totalHits} - {totalHits === 1 ? 'Coincidencia' : 'Coincidencias'} en canciones, álbumes, playlists y usuarios + {plural(totalHits, 'coincidencia', 'coincidencias')} en canciones, álbumes, playlists y usuarios

@@ -377,7 +365,7 @@
{#each genreTiles.slice(0, 4) as genre (genre.query)} {genre.label} @@ -392,7 +380,7 @@ - {searchCounts.Canciones} - {searchCounts.Canciones === 1 ? 'Resultado' : 'Resultados'} + {plural(searchCounts.Canciones, 'resultado', 'resultados')} {/snippet} @@ -466,8 +453,7 @@ {#snippet actions()}
- {searchCounts.Álbumes} - {searchCounts.Álbumes === 1 ? 'Resultado' : 'Resultados'} + {plural(searchCounts.Álbumes, 'resultado', 'resultados')} {/snippet} @@ -496,8 +482,7 @@ {#snippet actions()}
- {searchCounts.Usuarios} - {searchCounts.Usuarios === 1 ? 'Resultado' : 'Resultados'} + {plural(searchCounts.Usuarios, 'resultado', 'resultados')} {/snippet} @@ -521,8 +506,7 @@ {#snippet actions()}
- {searchCounts.Playlists} - {searchCounts.Playlists === 1 ? 'Resultado' : 'Resultados'} + {plural(searchCounts.Playlists, 'resultado', 'resultados')} {/snippet} @@ -554,12 +538,12 @@ {/if} -{#if contextMenu} +{#if trackMenu.state} (contextMenu = null)} - onAddToQueue={addToQueue} + onClose={() => trackMenu.close()} + onAddToQueue={() => trackMenu.playNext()} /> {/if} diff --git a/web-player/src/routes/(app)/liked/+page.svelte b/web-player/src/routes/(app)/liked/+page.svelte index 4e3eed1..7f7dd1e 100644 --- a/web-player/src/routes/(app)/liked/+page.svelte +++ b/web-player/src/routes/(app)/liked/+page.svelte @@ -1,7 +1,7 @@ diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 3600082..ef9dfdd 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -2,9 +2,8 @@ import Play from '@lucide/svelte/icons/play'; import Pause from '@lucide/svelte/icons/pause'; import Shuffle from '@lucide/svelte/icons/shuffle'; - import { player } from '$lib/player/player.svelte'; + import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte'; import { fmtTime } from '$lib/format'; - import { shuffle } from '$lib/collections'; import Page from '$lib/components/ui/Page.svelte'; import BackLink from '$lib/components/ui/BackLink.svelte'; import Button from '$lib/components/ui/Button.svelte'; @@ -16,10 +15,8 @@ import TrackMeta from '$lib/components/ui/TrackMeta.svelte'; import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte'; import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte'; - import TrackContextMenu, { - contextMenuStateFor, - type ContextMenuState - } from '$lib/components/TrackContextMenu.svelte'; + import TrackContextMenu from '$lib/components/TrackContextMenu.svelte'; + import { createTrackMenu } from '$lib/menus.svelte'; import { mixItemKey, mixItemTarget } from '$lib/mixes'; import { isTargetCurrent, @@ -40,24 +37,13 @@ const totalSeconds = $derived( items.reduce((total, item) => total + Number(item.durationSeconds), 0) ); - const isCurrentQueue = $derived(targets.some(isTargetCurrent)); + const isCurrentQueue = $derived(isQueueCurrent(queue)); - let contextMenu = $state(null); + const trackMenu = createTrackMenu(); function playFrom(index: number) { player.playOrToggle(queue, index); } - - function playAll() { - if (queue.length === 0) return; - if (isCurrentQueue) player.toggle(); - else player.playQueue(queue, 0); - } - - function playShuffled() { - if (queue.length === 0) return; - player.playQueue(shuffle(queue), 0); - } @@ -83,7 +69,7 @@ /> {/snippet} {#snippet actions()} - - @@ -106,7 +97,7 @@ {#each items as item, i (mixItemKey(item))} {@const active = isTargetCurrent(targets[i])} - (contextMenu = contextMenuStateFor(e, targets[i]))}> + trackMenu.open(e, targets[i])}> playFrom(i)} /> -{#if contextMenu} +{#if trackMenu.state} (contextMenu = null)} - onAddToQueue={() => { - const menu = contextMenu; - if (menu) { - const index = targets.findIndex((target) => targetId(target) === targetId(menu)); - if (index >= 0) player.addToQueue(queue[index]); - } - contextMenu = null; - }} + onClose={() => trackMenu.close()} + onAddToQueue={() => trackMenu.playNext()} /> {/if} diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index abb8846..a37d6ce 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -1,6 +1,6 @@ diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 29f110b..a15c2f3 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -3,9 +3,9 @@ import History from '@lucide/svelte/icons/history'; import Shuffle from '@lucide/svelte/icons/shuffle'; import ListMusic from '@lucide/svelte/icons/list-music'; - import { player } from '$lib/player/player.svelte'; + import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte'; + import { pressable } from '$lib/actions/pressable'; import { mergeRecentlyPlayed } from '$lib/recentlyPlayed'; - import { shuffle as shuffleList } from '$lib/collections'; import { fmtTime, fmtDurationLong, fmtPlays } from '$lib/format'; import { isTargetCurrent, @@ -27,15 +27,13 @@ import SectionHeading from '$lib/components/ui/SectionHeading.svelte'; import EmptyState from '$lib/components/ui/EmptyState.svelte'; import Button from '$lib/components/ui/Button.svelte'; - import TrackContextMenu, { - contextMenuStateFor, - type ContextMenuState - } from '$lib/components/TrackContextMenu.svelte'; + import TrackContextMenu from '$lib/components/TrackContextMenu.svelte'; + import { createTrackMenu } from '$lib/menus.svelte'; import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config'; let { data } = $props(); - let contextMenu = $state(null); + const trackMenu = createTrackMenu(); type SpotlightRow = { target: TrackTarget; seconds: number }; let spotlightRows = $state([]); @@ -87,9 +85,7 @@ ); const spotlightQueue = $derived(spotlightItems.map(queueItemForTarget)); - const spotlightIsCurrent = $derived( - spotlightQueue.length > 0 && spotlightQueue.some((item) => item.id === player.current.id) - ); + const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue)); const heroPlaying = $derived(spotlightIsCurrent && player.playing); function playContinue(index: number) { @@ -102,28 +98,14 @@ player.playOrToggle(spotlightQueue, index); } function playSpotlight() { - if (spotlightQueue.length === 0) return; - if (spotlightIsCurrent) player.toggle(); - else player.playQueue(spotlightQueue, 0); + playAllOrToggle(spotlightQueue); } function shuffleSpotlight() { - if (spotlightQueue.length === 0) return; - player.playQueue(shuffleList(spotlightQueue), 0); - } - - function onRowKeydown(event: KeyboardEvent, action: () => void) { - if (event.key !== 'Enter' && event.key !== ' ') return; - event.preventDefault(); - action(); + playShuffled(spotlightQueue); } function openContextMenu(event: MouseEvent, target: TrackTarget) { - contextMenu = contextMenuStateFor(event, target); - } - function addToQueue() { - if (!contextMenu) return; - player.addToQueue(queueItemForTarget(contextMenu)); - contextMenu = null; + trackMenu.open(event, target); } @@ -196,12 +178,11 @@
playContinue(i)} class="animate-enter rounded-art p-2.25 pr-3.5 transition-colors {isCurrent ? 'bg-accent-tint' : 'hover:bg-hover'}" - style="animation-delay:{Math.min(i, 10) * 40}ms" - onclick={() => playContinue(i)} - onkeydown={(e) => onRowKeydown(e, () => playContinue(i))} + style="--i:{i}" oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))} > goto(`/playlists/${spotlight.id}`)} class="flex w-fit items-center gap-5 self-start sm:gap-6" - onclick={() => goto(`/playlists/${spotlight.id}`)} - onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${spotlight.id}`))} > playSpotlightTrack(i)} - onkeydown={(e) => { - if (e.key !== 'Enter' && e.key !== ' ') return; - e.preventDefault(); - playSpotlightTrack(i); - }} + use:pressable={() => playSpotlightTrack(i)} class="flex items-center gap-4 rounded-control p-2 text-left transition-colors {isTargetCurrent( target ) @@ -341,11 +316,10 @@
playPopular(i)} class="flex items-center gap-3.5 rounded-control p-2 transition-colors {isCurrent ? 'bg-accent-tint' : 'hover:bg-hover'}" - onclick={() => playPopular(i)} - onkeydown={(e) => onRowKeydown(e, () => playPopular(i))} oncontextmenu={(e) => openContextMenu(e, target)} > @@ -405,10 +379,9 @@
goto(`/playlists/${playlist.id}`)} class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover" style="--i:{i}" - onclick={() => goto(`/playlists/${playlist.id}`)} - onkeydown={(e) => onRowKeydown(e, () => goto(`/playlists/${playlist.id}`))} >