Design Prot Merge #1
@@ -4,48 +4,59 @@ import { createApiClient } from '$lib/server/api';
|
|||||||
|
|
||||||
const PAGE_SIZE = 24;
|
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 }) => {
|
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
||||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
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 page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
||||||
|
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
|
||||||
if (src === 'yt') {
|
const wantsLocal = src !== 'yt';
|
||||||
const emptyResults = { items: [], continuationToken: '' };
|
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}', {
|
? api.GET('/playlists/users/{userId}', {
|
||||||
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
params: { path: { userId: locals.user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||||
})
|
})
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
const searchPromise =
|
const [tracksRes, searchRes, playlistsRes] = await Promise.all([
|
||||||
query && locals.accessToken
|
tracksPromise,
|
||||||
? api.GET('/youtube/search', { params: { query: { query } } })
|
searchPromise,
|
||||||
: Promise.resolve(null);
|
playlistsPromise
|
||||||
|
]);
|
||||||
|
|
||||||
const [playlistsRes, searchRes] = await Promise.all([playlistsPromise, searchPromise]);
|
if (wantsLocal && (tracksRes?.error || !tracksRes?.data)) {
|
||||||
|
|
||||||
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) {
|
|
||||||
error(502, 'No se pudieron cargar las canciones.');
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@
|
|||||||
thumbnailUrl: string;
|
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<typeof setTimeout> | undefined;
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
@@ -31,47 +31,44 @@
|
|||||||
let addedVideoIds = $state<Set<string>>(new Set());
|
let addedVideoIds = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (data.src === 'yt') {
|
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
|
||||||
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
|
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
addedVideoIds = new Set();
|
||||||
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) {
|
function onSearchInput(event: Event) {
|
||||||
const value = (event.currentTarget as HTMLInputElement).value;
|
const value = (event.currentTarget as HTMLInputElement).value;
|
||||||
clearTimeout(searchTimeout);
|
clearTimeout(searchTimeout);
|
||||||
searchTimeout = setTimeout(() => {
|
searchTimeout = setTimeout(() => {
|
||||||
const params = new URLSearchParams();
|
goto(buildHref(data.src, 1, value.trim()), {
|
||||||
if (value.trim()) params.set('q', value.trim());
|
keepFocus: true,
|
||||||
if (data.src === 'yt') params.set('src', 'yt');
|
replaceState: true,
|
||||||
const qs = params.toString();
|
noScroll: true
|
||||||
goto(qs ? `/explore?${qs}` : '/explore', { keepFocus: true, replaceState: true, noScroll: true });
|
});
|
||||||
}, 350);
|
}, 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) {
|
function hueFor(id: string) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
|
||||||
return HUES[hash % HUES.length];
|
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) {
|
function togglePlayLocal(index: number) {
|
||||||
if (data.src !== 'local') return;
|
if (!data.tracks) return;
|
||||||
const track = data.tracks.items[index];
|
const track = data.tracks.items[index];
|
||||||
if (player.current?.id === queueIdForTrack(track)) player.toggle();
|
if (player.current?.id === queueIdForTrack(track)) player.toggle();
|
||||||
else player.playQueue(toQueueItems(data.tracks.items), index);
|
else player.playQueue(toQueueItems(data.tracks.items), index);
|
||||||
@@ -112,13 +109,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageHref(page: number) {
|
const tabs: { src: Source; label: string }[] = [
|
||||||
const params = new URLSearchParams();
|
{ src: 'all', label: 'Todo' },
|
||||||
if (data.query) params.set('q', data.query);
|
{ src: 'local', label: 'Mi música' },
|
||||||
if (page > 1) params.set('page', String(page));
|
{ src: 'yt', label: 'YouTube Music' }
|
||||||
const qs = params.toString();
|
];
|
||||||
return qs ? `/explore?${qs}` : '/explore';
|
|
||||||
}
|
const placeholders: Record<Source, string> = {
|
||||||
|
all: 'Buscar en tu música y en YouTube Music...',
|
||||||
|
local: 'Buscar por nombre...',
|
||||||
|
yt: 'Buscar en YouTube Music...'
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -128,27 +129,20 @@
|
|||||||
|
|
||||||
<section class="px-8 pt-12 pb-8">
|
<section class="px-8 pt-12 pb-8">
|
||||||
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
||||||
<p class="mt-2 text-neutral-400">Descubre canciones, busca por nombre o encuentra música en YouTube Music.</p>
|
<p class="mt-2 text-neutral-400">Busca a la vez en tu música y en YouTube Music, o filtra por fuente.</p>
|
||||||
|
|
||||||
<div class="mt-8 flex items-center gap-2">
|
<div class="mt-8 flex items-center gap-2">
|
||||||
<a
|
{#each tabs as tab (tab.src)}
|
||||||
href={tabHref('local')}
|
<a
|
||||||
data-sveltekit-noscroll
|
href={buildHref(tab.src)}
|
||||||
class="rounded-full px-4 py-1.5 text-sm transition {data.src === 'local'
|
data-sveltekit-noscroll
|
||||||
? 'bg-emerald-500 font-semibold text-neutral-950'
|
class="rounded-full px-4 py-1.5 text-sm transition {data.src === tab.src
|
||||||
: 'border border-white/15 text-neutral-300 hover:bg-white/5'}"
|
? 'bg-emerald-500 font-semibold text-neutral-950'
|
||||||
>
|
: 'border border-white/15 text-neutral-300 hover:bg-white/5'}"
|
||||||
Mi música
|
>
|
||||||
</a>
|
{tab.label}
|
||||||
<a
|
</a>
|
||||||
href={tabHref('yt')}
|
{/each}
|
||||||
data-sveltekit-noscroll
|
|
||||||
class="rounded-full px-4 py-1.5 text-sm transition {data.src === 'yt'
|
|
||||||
? 'bg-emerald-500 font-semibold text-neutral-950'
|
|
||||||
: 'border border-white/15 text-neutral-300 hover:bg-white/5'}"
|
|
||||||
>
|
|
||||||
YouTube Music
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative mt-6">
|
<div class="relative mt-6">
|
||||||
@@ -159,7 +153,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
value={data.query}
|
value={data.query}
|
||||||
oninput={onSearchInput}
|
oninput={onSearchInput}
|
||||||
placeholder={data.src === 'yt' ? 'Buscar en YouTube Music...' : 'Buscar por nombre...'}
|
placeholder={placeholders[data.src]}
|
||||||
autocomplete="off"
|
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"
|
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 @@
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.src === 'yt'}
|
{#if showLocal && data.tracks}
|
||||||
{#if data.needsAuth}
|
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
|
||||||
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
|
||||||
<p class="mt-4 text-neutral-300">Inicia sesión para buscar en YouTube Music.</p>
|
|
||||||
</div>
|
|
||||||
{:else if !data.query}
|
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
|
||||||
<Search class="mx-auto h-10 w-10 text-neutral-500" />
|
|
||||||
<p class="mt-4 text-neutral-300">Escribe algo para buscar canciones en YouTube Music.</p>
|
|
||||||
</div>
|
|
||||||
{:else if data.ytError}
|
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
|
||||||
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
|
||||||
<p class="mt-4 text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p>
|
|
||||||
</div>
|
|
||||||
{:else if ytItems.length === 0}
|
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
|
||||||
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
|
||||||
<p class="mt-4 text-neutral-300">No hay resultados para «{data.query}».</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<ul class="mt-8 space-y-2">
|
|
||||||
{#each ytItems as song, i (song.videoId)}
|
|
||||||
<li
|
|
||||||
class="group flex items-center gap-4 rounded-2xl border border-white/5 bg-neutral-900/40 p-3 transition hover:border-emerald-500/30 hover:bg-neutral-900/60"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => togglePlayYouTube(i)}
|
|
||||||
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg"
|
|
||||||
>
|
|
||||||
<img src={song.thumbnailUrl} alt={song.title} class="h-full w-full object-cover" loading="lazy" />
|
|
||||||
<span
|
|
||||||
class="absolute inset-0 grid place-items-center bg-neutral-950/60 opacity-0 transition group-hover:opacity-100"
|
|
||||||
class:!opacity-100={player.current?.id === song.videoId}
|
|
||||||
>
|
|
||||||
{#if player.current?.id === song.videoId && player.isPlaying}
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 text-emerald-400">
|
|
||||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5 text-white">
|
|
||||||
<path d="M8 5v14l11-7z" />
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button type="button" onclick={() => togglePlayYouTube(i)} class="min-w-0 flex-1 text-left">
|
|
||||||
<div class="truncate text-sm font-semibold text-[var(--mf-text)]" title={song.title}>
|
|
||||||
{song.title}
|
|
||||||
</div>
|
|
||||||
<div class="mt-0.5 truncate text-xs text-neutral-400">
|
|
||||||
{song.artist}{song.album ? ` · ${song.album}` : ''}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span class="shrink-0 text-xs tabular-nums text-neutral-500">
|
|
||||||
{formatDuration(song.durationSeconds)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{#if data.playlists.length > 0}
|
|
||||||
<details class="relative shrink-0">
|
|
||||||
<summary
|
|
||||||
class="grid h-9 w-9 cursor-pointer list-none place-items-center rounded-full border border-white/15 text-neutral-300 transition hover:bg-white/5 [&::-webkit-details-marker]:hidden"
|
|
||||||
title="Añadir a playlist"
|
|
||||||
>
|
|
||||||
{#if addedVideoIds.has(song.videoId)}
|
|
||||||
<Check class="h-4 w-4 text-emerald-400" />
|
|
||||||
{:else}
|
|
||||||
<ListPlus class="h-4 w-4" />
|
|
||||||
{/if}
|
|
||||||
</summary>
|
|
||||||
<div
|
|
||||||
class="absolute right-0 z-20 mt-2 w-56 rounded-2xl border border-white/10 bg-neutral-900 p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.8)]"
|
|
||||||
>
|
|
||||||
<p class="px-3 py-1.5 text-xs font-semibold text-neutral-500">Añadir a playlist</p>
|
|
||||||
{#each data.playlists as playlist (playlist.id)}
|
|
||||||
<form
|
|
||||||
method="POST"
|
|
||||||
action="?/addYouTubeToPlaylist"
|
|
||||||
use:enhance={() =>
|
|
||||||
({ result, update }) => {
|
|
||||||
if (result.type === 'success') {
|
|
||||||
addedVideoIds = new Set([...addedVideoIds, song.videoId]);
|
|
||||||
}
|
|
||||||
return update({ reset: false });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
|
||||||
<input type="hidden" name="videoId" value={song.videoId} />
|
|
||||||
<input type="hidden" name="title" value={song.title} />
|
|
||||||
<input type="hidden" name="artist" value={song.artist} />
|
|
||||||
<input type="hidden" name="durationSeconds" value={song.durationSeconds} />
|
|
||||||
<input type="hidden" name="thumbnailUrl" value={song.thumbnailUrl} />
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="w-full truncate rounded-lg px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5"
|
|
||||||
>
|
|
||||||
{playlist.name}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if ytContinuation}
|
|
||||||
<div class="mt-8 flex justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={loadMoreYouTube}
|
|
||||||
disabled={ytLoadingMore}
|
|
||||||
class="flex items-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{#if ytLoadingMore}
|
|
||||||
<LoaderCircle class="h-4 w-4 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
Cargar más
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
{@const tracks = data.tracks}
|
{@const tracks = data.tracks}
|
||||||
{@const currentPage = Number(tracks.pageNumber)}
|
{@const currentPage = Number(tracks.pageNumber)}
|
||||||
{@const total = Number(tracks.totalItemCount)}
|
{@const total = Number(tracks.totalItemCount)}
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-neutral-500">
|
{#if showHeadings}
|
||||||
|
<h2 class="mt-10 font-display text-lg font-bold tracking-tight">Mi música</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="{showHeadings ? 'mt-1' : 'mt-6'} text-sm text-neutral-500">
|
||||||
{#if data.query}
|
{#if data.query}
|
||||||
{total}
|
{total}
|
||||||
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
||||||
@@ -314,18 +185,18 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if tracks.items.length === 0}
|
{#if tracks.items.length === 0}
|
||||||
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
|
||||||
<Music class="mx-auto h-10 w-10 text-neutral-500" />
|
<Music class="mx-auto h-8 w-8 text-neutral-500" />
|
||||||
<p class="mt-4 text-neutral-300">
|
<p class="mt-3 text-sm text-neutral-300">
|
||||||
{#if data.query}
|
{#if data.query}
|
||||||
No hay canciones que coincidan con «{data.query}».
|
No hay canciones tuyas que coincidan con «{data.query}».
|
||||||
{:else}
|
{:else}
|
||||||
Todavía no hay canciones. ¡Sé el primero en subir una!
|
Todavía no hay canciones. ¡Sé el primero en subir una!
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<ul class="mt-8 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
{#each tracks.items as track, i (track.id)}
|
{#each tracks.items as track, i (track.id)}
|
||||||
<li class="group animate-enter" style="animation-delay:{i * 40}ms">
|
<li class="group animate-enter" style="animation-delay:{i * 40}ms">
|
||||||
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
|
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
|
||||||
@@ -338,10 +209,10 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
||||||
class:!opacity-100={player.current?.id === track.id}
|
class:!opacity-100={player.current?.id === queueIdForTrack(track)}
|
||||||
class:!translate-y-0={player.current?.id === track.id}
|
class:!translate-y-0={player.current?.id === queueIdForTrack(track)}
|
||||||
>
|
>
|
||||||
{#if player.current?.id === track.id && player.isPlaying}
|
{#if player.current?.id === queueIdForTrack(track) && player.isPlaying}
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -355,23 +226,19 @@
|
|||||||
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
|
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={track.title}>
|
||||||
{track.title}
|
{track.title}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 truncate text-xs text-neutral-500">
|
{#if track.artist}
|
||||||
{#if track.artist}
|
<div class="mt-0.5 truncate text-xs text-neutral-500">{track.artist}</div>
|
||||||
{track.artist}
|
{/if}
|
||||||
{:else}
|
|
||||||
{dateFormatter.format(new Date(track.createdAt))}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
||||||
<nav class="mt-10 flex items-center justify-center gap-4">
|
<nav class="mt-8 flex items-center justify-center gap-4">
|
||||||
{#if tracks.hasPreviousPage}
|
{#if tracks.hasPreviousPage}
|
||||||
<a
|
<a
|
||||||
href={pageHref(currentPage - 1)}
|
href={buildHref(data.src, currentPage - 1)}
|
||||||
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||||
>
|
>
|
||||||
Anterior
|
Anterior
|
||||||
@@ -380,7 +247,7 @@
|
|||||||
<span class="text-sm text-neutral-500">Página {currentPage}</span>
|
<span class="text-sm text-neutral-500">Página {currentPage}</span>
|
||||||
{#if tracks.hasNextPage}
|
{#if tracks.hasNextPage}
|
||||||
<a
|
<a
|
||||||
href={pageHref(currentPage + 1)}
|
href={buildHref(data.src, currentPage + 1)}
|
||||||
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||||
>
|
>
|
||||||
Siguiente
|
Siguiente
|
||||||
@@ -390,4 +257,139 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.src !== 'local'}
|
||||||
|
{#if showHeadings}
|
||||||
|
<h2 class="mt-12 font-display text-lg font-bold tracking-tight">YouTube Music</h2>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.needsAuth}
|
||||||
|
{#if data.query || data.src === 'yt'}
|
||||||
|
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
|
||||||
|
<Music class="mx-auto h-8 w-8 text-neutral-500" />
|
||||||
|
<p class="mt-3 text-sm text-neutral-300">Inicia sesión para buscar en YouTube Music.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if !data.query}
|
||||||
|
{#if data.src === 'yt'}
|
||||||
|
<div class="mt-16 rounded-2xl border border-white/10 bg-neutral-900/40 p-12 text-center backdrop-blur-md">
|
||||||
|
<Search class="mx-auto h-10 w-10 text-neutral-500" />
|
||||||
|
<p class="mt-4 text-neutral-300">Escribe algo para buscar canciones en YouTube Music.</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if data.ytError}
|
||||||
|
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
|
||||||
|
<Music class="mx-auto h-8 w-8 text-neutral-500" />
|
||||||
|
<p class="mt-3 text-sm text-neutral-300">YouTube Music no está disponible ahora mismo. Inténtalo de nuevo.</p>
|
||||||
|
</div>
|
||||||
|
{:else if showYouTube}
|
||||||
|
{#if ytItems.length === 0}
|
||||||
|
<div class="mt-6 rounded-2xl border border-white/10 bg-neutral-900/40 p-10 text-center backdrop-blur-md">
|
||||||
|
<Music class="mx-auto h-8 w-8 text-neutral-500" />
|
||||||
|
<p class="mt-3 text-sm text-neutral-300">No hay resultados en YouTube Music para «{data.query}».</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||||
|
{#each ytItems as song, i (song.videoId)}
|
||||||
|
<li class="group animate-enter relative" style="animation-delay:{i * 40}ms">
|
||||||
|
<button type="button" onclick={() => togglePlayYouTube(i)} class="block w-full text-left">
|
||||||
|
<Cover
|
||||||
|
trackId={song.videoId}
|
||||||
|
src={song.thumbnailUrl}
|
||||||
|
hue={hueFor(song.videoId)}
|
||||||
|
alt={song.title}
|
||||||
|
class="aspect-square w-full rounded-xl shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute right-2.5 bottom-2.5 grid h-11 w-11 translate-y-2 place-items-center rounded-full bg-[var(--mf-accent)] text-neutral-950 opacity-0 shadow-lg transition-all group-hover:translate-y-0 group-hover:opacity-100"
|
||||||
|
class:!opacity-100={player.current?.id === song.videoId}
|
||||||
|
class:!translate-y-0={player.current?.id === song.videoId}
|
||||||
|
>
|
||||||
|
{#if player.current?.id === song.videoId && player.isPlaying}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</Cover>
|
||||||
|
<div class="mt-2.5 truncate text-sm font-semibold text-[var(--mf-text)]" title={song.title}>
|
||||||
|
{song.title}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate text-xs text-neutral-500" title={song.artist}>
|
||||||
|
{song.artist}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if data.playlists.length > 0}
|
||||||
|
<details class="absolute right-2.5 top-2.5 z-10">
|
||||||
|
<summary
|
||||||
|
class="grid h-9 w-9 cursor-pointer list-none place-items-center rounded-full bg-neutral-950/70 text-white opacity-0 shadow-lg backdrop-blur-sm transition group-hover:opacity-100 [&::-webkit-details-marker]:hidden"
|
||||||
|
class:!opacity-100={addedVideoIds.has(song.videoId)}
|
||||||
|
title="Añadir a playlist"
|
||||||
|
>
|
||||||
|
{#if addedVideoIds.has(song.videoId)}
|
||||||
|
<Check class="h-4 w-4 text-emerald-400" />
|
||||||
|
{:else}
|
||||||
|
<ListPlus class="h-4 w-4" />
|
||||||
|
{/if}
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
class="absolute right-0 z-20 mt-2 w-56 rounded-2xl border border-white/10 bg-neutral-900 p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.8)]"
|
||||||
|
>
|
||||||
|
<p class="px-3 py-1.5 text-xs font-semibold text-neutral-500">Añadir a playlist</p>
|
||||||
|
{#each data.playlists as playlist (playlist.id)}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/addYouTubeToPlaylist"
|
||||||
|
use:enhance={() =>
|
||||||
|
({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
addedVideoIds = new Set([...addedVideoIds, song.videoId]);
|
||||||
|
}
|
||||||
|
return update({ reset: false });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||||
|
<input type="hidden" name="videoId" value={song.videoId} />
|
||||||
|
<input type="hidden" name="title" value={song.title} />
|
||||||
|
<input type="hidden" name="artist" value={song.artist} />
|
||||||
|
<input type="hidden" name="durationSeconds" value={song.durationSeconds} />
|
||||||
|
<input type="hidden" name="thumbnailUrl" value={song.thumbnailUrl} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full truncate rounded-lg px-3 py-2 text-left text-sm text-neutral-200 transition hover:bg-white/5"
|
||||||
|
>
|
||||||
|
{playlist.name}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if ytContinuation}
|
||||||
|
<div class="mt-8 flex justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={loadMoreYouTube}
|
||||||
|
disabled={ytLoadingMore}
|
||||||
|
class="flex items-center gap-2 rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white transition hover:bg-white/5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{#if ytLoadingMore}
|
||||||
|
<LoaderCircle class="h-4 w-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Cargar más
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user