From f90d76fad96ee8a8a34769879f02fccc80d47366 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 16 Jul 2026 06:02:57 +0200 Subject: [PATCH] feat(frontend): unified explore search across local and YouTube Music Explore now has Todo/Mi musica/YouTube Music filters (searching both by default), YouTube results share the local grid card style, and cards show the artist instead of the upload date. --- .../src/routes/(app)/explore/+page.server.ts | 63 +-- .../src/routes/(app)/explore/+page.svelte | 404 +++++++++--------- 2 files changed, 240 insertions(+), 227 deletions(-) diff --git a/frontend/src/routes/(app)/explore/+page.server.ts b/frontend/src/routes/(app)/explore/+page.server.ts index 3b8459d..a58ba50 100644 --- a/frontend/src/routes/(app)/explore/+page.server.ts +++ b/frontend/src/routes/(app)/explore/+page.server.ts @@ -4,48 +4,59 @@ import { createApiClient } from '$lib/server/api'; const PAGE_SIZE = 24; +type Source = 'all' | 'local' | 'yt'; + +function parseSource(value: string | null): Source { + return value === 'local' || value === 'yt' ? value : 'all'; +} + export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; - const src = url.searchParams.get('src') === 'yt' ? 'yt' : ('local' as const); + const src = parseSource(url.searchParams.get('src')); const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - if (src === 'yt') { - const emptyResults = { items: [], continuationToken: '' }; + const wantsLocal = src !== 'yt'; + const wantsYouTube = src !== 'local'; - const playlistsPromise = locals.user + const tracksPromise = wantsLocal + ? api.GET('/tracks', { + params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } + }) + : Promise.resolve(null); + + const searchPromise = + wantsYouTube && query && locals.accessToken + ? api.GET('/youtube/search', { params: { query: { query } } }) + : Promise.resolve(null); + + const playlistsPromise = + wantsYouTube && locals.user ? api.GET('/playlists/users/{userId}', { params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } } }) : Promise.resolve(null); - const searchPromise = - query && locals.accessToken - ? api.GET('/youtube/search', { params: { query: { query } } }) - : Promise.resolve(null); + const [tracksRes, searchRes, playlistsRes] = await Promise.all([ + tracksPromise, + searchPromise, + playlistsPromise + ]); - const [playlistsRes, searchRes] = await Promise.all([playlistsPromise, searchPromise]); - - return { - src: 'yt' as const, - query, - ytResults: searchRes?.data ?? emptyResults, - ytError: Boolean(searchRes?.error), - playlists: playlistsRes?.data?.items ?? [], - needsAuth: !locals.user - }; - } - - const { data, error: err } = await api.GET('/tracks', { - params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } - }); - - if (err || !data) { + if (wantsLocal && (tracksRes?.error || !tracksRes?.data)) { error(502, 'No se pudieron cargar las canciones.'); } - return { src: 'local' as const, query, tracks: data }; + return { + src, + query, + tracks: tracksRes?.data ?? null, + ytResults: searchRes?.data ?? null, + ytError: Boolean(searchRes?.error), + playlists: playlistsRes?.data?.items ?? [], + needsAuth: wantsYouTube && !locals.user + }; }; export const actions: Actions = { diff --git a/frontend/src/routes/(app)/explore/+page.svelte b/frontend/src/routes/(app)/explore/+page.svelte index 647ce58..d4d9dcd 100644 --- a/frontend/src/routes/(app)/explore/+page.svelte +++ b/frontend/src/routes/(app)/explore/+page.svelte @@ -19,9 +19,9 @@ thumbnailUrl: string; } - let { data, form } = $props(); + type Source = 'all' | 'local' | 'yt'; - const dateFormatter = new Intl.DateTimeFormat('es', { dateStyle: 'medium' }); + let { data, form } = $props(); let searchTimeout: ReturnType | undefined; @@ -31,47 +31,44 @@ let addedVideoIds = $state>(new Set()); $effect(() => { - if (data.src === 'yt') { - ytItems = (data.ytResults?.items ?? []) as YouTubeSong[]; - ytContinuation = data.ytResults?.continuationToken ?? ''; - addedVideoIds = new Set(); - } + ytItems = (data.ytResults?.items ?? []) as YouTubeSong[]; + ytContinuation = data.ytResults?.continuationToken ?? ''; + addedVideoIds = new Set(); }); + const showLocal = $derived(data.tracks !== null); + const showYouTube = $derived(data.src !== 'local' && !!data.query); + const showHeadings = $derived(data.src === 'all' && !!data.query); + + function buildHref(src: Source, page = 1, query = data.query) { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (src !== 'all') params.set('src', src); + if (page > 1) params.set('page', String(page)); + const qs = params.toString(); + return qs ? `/explore?${qs}` : '/explore'; + } + function onSearchInput(event: Event) { const value = (event.currentTarget as HTMLInputElement).value; clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { - const params = new URLSearchParams(); - if (value.trim()) params.set('q', value.trim()); - if (data.src === 'yt') params.set('src', 'yt'); - const qs = params.toString(); - goto(qs ? `/explore?${qs}` : '/explore', { keepFocus: true, replaceState: true, noScroll: true }); + goto(buildHref(data.src, 1, value.trim()), { + keepFocus: true, + replaceState: true, + noScroll: true + }); }, 350); } - function tabHref(src: 'local' | 'yt') { - const params = new URLSearchParams(); - if (data.query) params.set('q', data.query); - if (src === 'yt') params.set('src', 'yt'); - const qs = params.toString(); - return qs ? `/explore?${qs}` : '/explore'; - } - function hueFor(id: string) { let hash = 0; for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0; return HUES[hash % HUES.length]; } - function formatDuration(seconds: number) { - const m = Math.floor(seconds / 60); - const s = Math.floor(seconds % 60); - return `${m}:${String(s).padStart(2, '0')}`; - } - function togglePlayLocal(index: number) { - if (data.src !== 'local') return; + if (!data.tracks) return; const track = data.tracks.items[index]; if (player.current?.id === queueIdForTrack(track)) player.toggle(); else player.playQueue(toQueueItems(data.tracks.items), index); @@ -112,13 +109,17 @@ } } - function pageHref(page: number) { - const params = new URLSearchParams(); - if (data.query) params.set('q', data.query); - if (page > 1) params.set('page', String(page)); - const qs = params.toString(); - return qs ? `/explore?${qs}` : '/explore'; - } + const tabs: { src: Source; label: string }[] = [ + { src: 'all', label: 'Todo' }, + { src: 'local', label: 'Mi música' }, + { src: 'yt', label: 'YouTube Music' } + ]; + + const placeholders: Record = { + all: 'Buscar en tu música y en YouTube Music...', + local: 'Buscar por nombre...', + yt: 'Buscar en YouTube Music...' + }; @@ -128,27 +129,20 @@

Explorar

-

Descubre canciones, busca por nombre o encuentra música en YouTube Music.

+

Busca a la vez en tu música y en YouTube Music, o filtra por fuente.

- - Mi música - - - YouTube Music - + {#each tabs as tab (tab.src)} + + {tab.label} + + {/each}
@@ -159,7 +153,7 @@ type="search" value={data.query} oninput={onSearchInput} - placeholder={data.src === 'yt' ? 'Buscar en YouTube Music...' : 'Buscar por nombre...'} + placeholder={placeholders[data.src]} autocomplete="off" class="w-full rounded-lg border border-white/15 bg-neutral-950 py-3 pl-11 pr-4 text-sm text-neutral-100 placeholder:text-neutral-500 focus:border-[var(--mf-accent)]/50 focus:outline-none" /> @@ -171,139 +165,16 @@

{/if} - {#if data.src === 'yt'} - {#if data.needsAuth} -
- -

Inicia sesión para buscar en YouTube Music.

-
- {:else if !data.query} -
- -

Escribe algo para buscar canciones en YouTube Music.

-
- {:else if data.ytError} -
- -

YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.

-
- {:else if ytItems.length === 0} -
- -

No hay resultados para «{data.query}».

-
- {:else} -
    - {#each ytItems as song, i (song.videoId)} -
  • - - - - - - {formatDuration(song.durationSeconds)} - - - {#if data.playlists.length > 0} -
    - - {#if addedVideoIds.has(song.videoId)} - - {:else} - - {/if} - -
    -

    Añadir a playlist

    - {#each data.playlists as playlist (playlist.id)} -
    - ({ result, update }) => { - if (result.type === 'success') { - addedVideoIds = new Set([...addedVideoIds, song.videoId]); - } - return update({ reset: false }); - }} - > - - - - - - - -
    - {/each} -
    -
    - {/if} -
  • - {/each} -
- - {#if ytContinuation} -
- -
- {/if} - {/if} - {:else} + {#if showLocal && data.tracks} {@const tracks = data.tracks} {@const currentPage = Number(tracks.pageNumber)} {@const total = Number(tracks.totalItemCount)} -

+ {#if showHeadings} +

Mi música

+ {/if} + +

{#if data.query} {total} {total === 1 ? 'resultado' : 'resultados'} para «{data.query}» @@ -314,18 +185,18 @@

{#if tracks.items.length === 0} -
- -

+

+ +

{#if data.query} - No hay canciones que coincidan con «{data.query}». + No hay canciones tuyas que coincidan con «{data.query}». {:else} Todavía no hay canciones. ¡Sé el primero en subir una! {/if}

{:else} -