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;
- }
-