Changes
This commit is contained in:
@@ -1,36 +1,25 @@
|
||||
<script lang="ts">
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import Music from '@lucide/svelte/icons/music';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
toQueueItems,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import Cover from '$lib/components/ui/Cover.svelte';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import { HUES } from '$lib/theme/color';
|
||||
|
||||
interface YouTubeSong {
|
||||
videoId: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
durationSeconds: number;
|
||||
thumbnailUrl: string;
|
||||
isExplicit: boolean;
|
||||
}
|
||||
|
||||
type ContextMenuTarget =
|
||||
| { kind: 'youtube'; song: YouTubeSong }
|
||||
| { kind: 'local'; track: ApiTrackLike };
|
||||
import { player, queueIdForTrack, toQueueItems, type QueueItem } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
||||
import MediaGrid from '$lib/components/ui/MediaGrid.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/InfiniteScroll.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { hueFor } from '$lib/theme/color';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import TrackTile from '$lib/components/TrackTile.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuTarget,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
import { appendUnique } from '$lib/collections.js';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
@@ -39,98 +28,80 @@
|
||||
|
||||
let ytItems = $state<YouTubeSong[]>([]);
|
||||
let ytContinuation = $state('');
|
||||
let ytLoadingMore = $state(false);
|
||||
let contextMenu = $state<(ContextMenuTarget & { x: number; y: number; openLeft: boolean }) | null>(
|
||||
null
|
||||
);
|
||||
let ytSearchTerm = $state('');
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
type LocalTrack = (typeof data.tracks.items)[number];
|
||||
|
||||
let localItems = $state<LocalTrack[]>([]);
|
||||
let localPage = $state(1);
|
||||
let localHasNext = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
ytItems = (data.ytResults?.items ?? []) as YouTubeSong[];
|
||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||
contextMenu = null;
|
||||
});
|
||||
localItems = data.tracks.items;
|
||||
localPage = Number(data.tracks.pageNumber);
|
||||
localHasNext = Boolean(data.tracks.hasNextPage);
|
||||
|
||||
const tracks = $derived(data.tracks);
|
||||
const currentPage = $derived(Number(data.tracks.pageNumber));
|
||||
const total = $derived(Number(data.tracks.totalItemCount));
|
||||
|
||||
const localVisible = $derived(!data.query || tracks.items.length > 0);
|
||||
const youtubeVisible = $derived(
|
||||
!!data.query && (data.needsAuth || data.ytError || ytItems.length > 0)
|
||||
);
|
||||
const localHeadingVisible = $derived(!!data.query && localVisible);
|
||||
const youtubeHeadingVisible = $derived(!!data.query && youtubeVisible);
|
||||
const nothingFound = $derived(
|
||||
!!data.query && !localVisible && !youtubeVisible && !data.needsAuth && !data.ytError
|
||||
);
|
||||
|
||||
function buildHref(page = 1, query = data.query) {
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
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(() => {
|
||||
goto(buildHref(1, value.trim()), {
|
||||
keepFocus: true,
|
||||
replaceState: true,
|
||||
noScroll: true
|
||||
});
|
||||
}, 350);
|
||||
}
|
||||
|
||||
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 togglePlayLocal(index: number) {
|
||||
const track = data.tracks.items[index];
|
||||
if (player.current?.id === queueIdForTrack(track)) player.toggle();
|
||||
else player.playQueue(toQueueItems(data.tracks.items), index);
|
||||
}
|
||||
|
||||
function togglePlayYouTube(index: number) {
|
||||
const song = ytItems[index];
|
||||
if (player.current?.id === song.videoId) {
|
||||
player.toggle();
|
||||
if (data.query) {
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
(data.ytResults?.items ?? []) as YouTubeSong[],
|
||||
item => item.videoId
|
||||
);
|
||||
ytContinuation = data.ytResults?.continuationToken ?? '';
|
||||
ytSearchTerm = data.query;
|
||||
return;
|
||||
}
|
||||
player.playQueue(
|
||||
ytItems.map((s) => ({
|
||||
id: s.videoId,
|
||||
title: s.title,
|
||||
artist: s.artist,
|
||||
source: 'youtube' as const,
|
||||
coverUrl: s.thumbnailUrl
|
||||
})),
|
||||
index
|
||||
ytItems = [];
|
||||
ytContinuation = '';
|
||||
ytSearchTerm = '';
|
||||
data.youtubeFiller?.then((filler) => {
|
||||
if (!filler) return;
|
||||
ytItems = appendUnique(
|
||||
[],
|
||||
filler.items,
|
||||
item => item.videoId
|
||||
);
|
||||
ytContinuation = filler.continuationToken;
|
||||
ytSearchTerm = filler.query;
|
||||
});
|
||||
});
|
||||
|
||||
const items = $derived<ContextMenuTarget[]>([
|
||||
...localItems.map((track) => ({ kind: 'local' as const, track })),
|
||||
...ytItems.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
const hasMore = $derived(localHasNext || ytContinuation !== '');
|
||||
|
||||
const nothingFound = $derived(
|
||||
!!data.query && items.length === 0 && !data.needsAuth && !data.ytError
|
||||
);
|
||||
|
||||
function itemId(item: ContextMenuTarget) {
|
||||
return item.kind === 'youtube' ? item.song.videoId : item.track.id;
|
||||
}
|
||||
|
||||
function itemTitle(item: ContextMenuTarget) {
|
||||
return item.kind === 'youtube' ? item.song.title : item.track.title;
|
||||
}
|
||||
|
||||
function itemArtist(item: ContextMenuTarget) {
|
||||
return item.kind === 'youtube' ? item.song.artist : item.track.artist;
|
||||
}
|
||||
|
||||
function itemExplicit(item: ContextMenuTarget) {
|
||||
return item.kind === 'youtube' ? item.song.isExplicit : (item.track.isExplicit ?? false);
|
||||
}
|
||||
|
||||
function itemActive(item: ContextMenuTarget) {
|
||||
return (
|
||||
player.current?.id === (item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track))
|
||||
);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
|
||||
if (data.playlists.length === 0) return;
|
||||
event.preventDefault();
|
||||
const menuWidth = 208;
|
||||
const submenuWidth = 224;
|
||||
contextMenu = {
|
||||
...target,
|
||||
x: Math.min(event.clientX, window.innerWidth - menuWidth - 8),
|
||||
y: Math.min(event.clientY, window.innerHeight - 60),
|
||||
openLeft: event.clientX + menuWidth + submenuWidth + 16 > window.innerWidth
|
||||
};
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function queueItemFor(target: ContextMenuTarget): QueueItem {
|
||||
return target.kind === 'youtube'
|
||||
? {
|
||||
@@ -138,31 +109,88 @@
|
||||
title: target.song.title,
|
||||
artist: target.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: target.song.thumbnailUrl
|
||||
coverUrl: target.song.thumbnailUrl,
|
||||
explicit: target.song.isExplicit
|
||||
}
|
||||
: toQueueItems([target.track])[0];
|
||||
}
|
||||
|
||||
function addToQueue(target: ContextMenuTarget | null) {
|
||||
if (!target) return;
|
||||
player.addToQueue(queueItemFor(target));
|
||||
function buildHref(query: string) {
|
||||
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
|
||||
}
|
||||
|
||||
function onSearchInput(event: Event) {
|
||||
const value = (event.currentTarget as HTMLInputElement).value;
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
goto(buildHref(value.trim()), {
|
||||
keepFocus: true,
|
||||
replaceState: true,
|
||||
noScroll: true
|
||||
});
|
||||
}, 350);
|
||||
}
|
||||
|
||||
function togglePlay(index: number) {
|
||||
player.playOrToggle(items.map(queueItemFor), index);
|
||||
}
|
||||
|
||||
function openContextMenu(event: MouseEvent, target: ContextMenuTarget) {
|
||||
contextMenu = contextMenuStateFor(event, target);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemFor(contextMenu));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
async function loadMoreLocal() {
|
||||
const params = new URLSearchParams({
|
||||
pageNumber: String(localPage + 1),
|
||||
pageSize: String(EXPLORE_PAGE_SIZE)
|
||||
});
|
||||
if (data.query) params.set('name', data.query);
|
||||
const res = await fetch(`/api/tracks?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as typeof data.tracks;
|
||||
localItems = appendUnique(
|
||||
localItems,
|
||||
next.items,
|
||||
track => track.id
|
||||
);
|
||||
localPage = Number(next.pageNumber);
|
||||
localHasNext = Boolean(next.hasNextPage);
|
||||
}
|
||||
|
||||
async function loadMoreYouTube() {
|
||||
if (!ytContinuation || ytLoadingMore) return;
|
||||
ytLoadingMore = true;
|
||||
const params = new URLSearchParams({ query: ytSearchTerm, continuation: ytContinuation });
|
||||
const res = await fetch(`/api/youtube/search?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||
ytItems = appendUnique(
|
||||
ytItems,
|
||||
next.items,
|
||||
item => item.videoId
|
||||
);
|
||||
ytContinuation = next.continuationToken;
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
loadingMore = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ query: data.query, continuation: ytContinuation });
|
||||
const res = await fetch(`/api/youtube/search?${params}`);
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
const next = (await res.json()) as { items: YouTubeSong[]; continuationToken: string };
|
||||
ytItems = [...ytItems, ...next.items];
|
||||
ytContinuation = next.continuationToken;
|
||||
if (localHasNext) await loadMoreLocal();
|
||||
else if (ytContinuation && ytSearchTerm) await loadMoreYouTube();
|
||||
} catch {
|
||||
ytContinuation = '';
|
||||
if (localHasNext) localHasNext = false;
|
||||
else ytContinuation = '';
|
||||
} finally {
|
||||
ytLoadingMore = false;
|
||||
loadingMore = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -172,287 +200,62 @@
|
||||
<meta name="description" content="Explora tu música y busca canciones en YouTube Music." />
|
||||
</svelte:head>
|
||||
|
||||
<section class="px-8 pt-12 pb-8">
|
||||
<h1 class="font-display text-3xl font-bold tracking-tight sm:text-4xl">Explorar</h1>
|
||||
<p class="mt-2 text-neutral-400">Busca a la vez en tu música y en YouTube Music.</p>
|
||||
<Page>
|
||||
<PageHeader title="Explorar" subtitle="Busca a la vez en tu música y en YouTube Music." />
|
||||
|
||||
<div class="relative mt-8">
|
||||
<span class="pointer-events-none absolute inset-y-0 left-4 grid place-items-center text-neutral-500">
|
||||
<Search class="h-4 w-4" />
|
||||
</span>
|
||||
<input
|
||||
<div class="mt-8">
|
||||
<Input
|
||||
type="search"
|
||||
icon={Search}
|
||||
bind:value={searchValue}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Buscar en tu música y en YouTube Music..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if form?.message}
|
||||
<p class="mt-4 rounded-xl border border-white/10 bg-neutral-900/40 px-4 py-3 text-sm text-neutral-300">
|
||||
{form.message}
|
||||
</p>
|
||||
<Alert class="mt-4">{form.message}</Alert>
|
||||
{/if}
|
||||
|
||||
{#if nothingFound}
|
||||
<div class="mt-10 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>
|
||||
<EmptyState icon={Music} title="Sin resultados" description="No hay resultados para «{data.query}»." />
|
||||
{/if}
|
||||
|
||||
{#if localVisible}
|
||||
{#if localHeadingVisible}
|
||||
<h2 class="mt-10 font-display text-lg font-bold tracking-tight">Musify</h2>
|
||||
{/if}
|
||||
{#if items.length > 0}
|
||||
<MediaGrid as="ul" class="mt-6">
|
||||
{#each items as item, i (itemId(item))}
|
||||
<TrackTile
|
||||
id={itemId(item)}
|
||||
title={itemTitle(item)}
|
||||
artist={itemArtist(item)}
|
||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||
hue={hueFor(itemId(item))}
|
||||
explicit={itemExplicit(item)}
|
||||
active={itemActive(item)}
|
||||
playing={player.isPlaying}
|
||||
index={i}
|
||||
onClick={() => togglePlay(i)}
|
||||
onContextMenu={(e) => openContextMenu(e, item)}
|
||||
/>
|
||||
{/each}
|
||||
</MediaGrid>
|
||||
|
||||
<p class="{localHeadingVisible ? 'mt-1' : 'mt-6'} text-sm text-neutral-500">
|
||||
{#if data.query}
|
||||
{total}
|
||||
{total === 1 ? 'resultado' : 'resultados'} para «{data.query}»
|
||||
{:else}
|
||||
{total}
|
||||
{total === 1 ? 'canción' : 'canciones'}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
{#if tracks.items.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">Todavía no hay canciones. ¡Sé el primero en subir una!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="mt-6 grid grid-cols-[repeat(auto-fill,minmax(160px,1fr))] gap-4">
|
||||
{#each tracks.items as track, i (track.id)}
|
||||
<li
|
||||
class="group animate-enter relative"
|
||||
style="animation-delay:{i * 40}ms"
|
||||
oncontextmenu={(e) => openContextMenu(e, { kind: 'local', track })}
|
||||
>
|
||||
<button type="button" onclick={() => togglePlayLocal(i)} class="block w-full text-left">
|
||||
<Cover
|
||||
trackId={track.id}
|
||||
hue={hueFor(track.id)}
|
||||
size="large"
|
||||
alt={track.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 === queueIdForTrack(track)}
|
||||
class:!translate-y-0={player.current?.id === queueIdForTrack(track)}
|
||||
>
|
||||
{#if player.current?.id === queueIdForTrack(track) && 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={track.title}>
|
||||
{track.title}
|
||||
</div>
|
||||
{#if track.artist}
|
||||
<div class="mt-0.5 truncate text-xs text-neutral-500">{track.artist}</div>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if tracks.hasPreviousPage || tracks.hasNextPage}
|
||||
<nav class="mt-8 flex items-center justify-center gap-4">
|
||||
{#if tracks.hasPreviousPage}
|
||||
<a
|
||||
href={buildHref(currentPage - 1)}
|
||||
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||
>
|
||||
Anterior
|
||||
</a>
|
||||
{/if}
|
||||
<span class="text-sm text-neutral-500">Página {currentPage}</span>
|
||||
{#if tracks.hasNextPage}
|
||||
<a
|
||||
href={buildHref(currentPage + 1)}
|
||||
class="rounded-lg border border-white/10 px-5 py-2 text-sm text-white/65 transition hover:bg-white/2.5"
|
||||
>
|
||||
Siguiente
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
{/if}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
|
||||
{#if youtubeVisible}
|
||||
{#if youtubeHeadingVisible}
|
||||
<h2 class="mt-12 font-display text-lg font-bold tracking-tight">YouTube Music</h2>
|
||||
{/if}
|
||||
|
||||
{#if data.needsAuth}
|
||||
<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>
|
||||
{: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}
|
||||
<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"
|
||||
oncontextmenu={(e) => openContextMenu(e, { kind: 'youtube', song })}
|
||||
>
|
||||
<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 flex min-w-0 items-center gap-1.5">
|
||||
{#if song.isExplicit}
|
||||
<span
|
||||
class="grid h-3.5 w-3.5 shrink-0 place-items-center rounded-[3px] bg-white/20 text-[9px] font-bold leading-none text-neutral-200"
|
||||
title="Contenido explícito"
|
||||
>
|
||||
E
|
||||
</span>
|
||||
{/if}
|
||||
<span class="truncate text-xs text-neutral-500" title={song.artist}>{song.artist}</span>
|
||||
</div>
|
||||
</button>
|
||||
</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 data.needsAuth}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="Inicia sesión para ver también resultados de YouTube Music."
|
||||
/>
|
||||
{:else if data.ytError}
|
||||
<EmptyState
|
||||
icon={Music}
|
||||
description="YouTube Music no está disponible ahora mismo. Inténtalo de nuevo."
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</Page>
|
||||
|
||||
{#if contextMenu}
|
||||
<div
|
||||
class="fixed inset-0 z-30"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={closeContextMenu}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') closeContextMenu();
|
||||
}}
|
||||
oncontextmenu={(e) => {
|
||||
e.preventDefault();
|
||||
closeContextMenu();
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
class="fixed z-40 w-60 rounded-xl bg-[var(--mf-elevated)] p-1.5 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]"
|
||||
style="left:{contextMenu.x}px; top:{contextMenu.y}px;"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => addToQueue(contextMenu)}
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-sm text-[var(--mf-text-2)] transition hover:bg-white/5"
|
||||
>
|
||||
<ListPlus class="h-6 w-6 shrink-0" strokeWidth={2} />
|
||||
<span class="text-base">Añadir a la cola</span>
|
||||
</button>
|
||||
<div class="group/addmenu relative">
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg px-2 py-2.5 text-sm text-[var(--mf-text-2)] transition group-hover/addmenu:bg-white/5"
|
||||
>
|
||||
<span class="flex-1 text-base cursor-default">Añadir a una playlist</span>
|
||||
<ChevronRight class="h-4 w-4 shrink-0 text-[var(--mf-text-3)]" />
|
||||
</div>
|
||||
<div
|
||||
class="invisible absolute top-0 z-50 opacity-0 transition group-hover/addmenu:visible group-hover/addmenu:opacity-100 {contextMenu.openLeft
|
||||
? 'right-full pr-1.5'
|
||||
: 'left-full pl-1.5'}"
|
||||
>
|
||||
<div class="w-64 overflow-hidden rounded-xl bg-[var(--mf-elevated)] p-2 shadow-[0_12px_28px_-10px_rgba(0,0,0,0.6)]">
|
||||
<div class="max-h-80 overflow-y-auto">
|
||||
{#each data.playlists as playlist (playlist.id)}
|
||||
<form
|
||||
method="POST"
|
||||
action={contextMenu.kind === 'youtube' ? '?/addYouTubeToPlaylist' : '?/addTrack'}
|
||||
use:enhance={() =>
|
||||
({ update }) => {
|
||||
closeContextMenu();
|
||||
return update({ reset: false });
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="playlistId" value={playlist.id} />
|
||||
{#if contextMenu.kind === 'youtube'}
|
||||
<input type="hidden" name="videoId" value={contextMenu.song.videoId} />
|
||||
<input type="hidden" name="title" value={contextMenu.song.title} />
|
||||
<input type="hidden" name="artist" value={contextMenu.song.artist} />
|
||||
<input type="hidden" name="durationSeconds" value={contextMenu.song.durationSeconds} />
|
||||
<input type="hidden" name="thumbnailUrl" value={contextMenu.song.thumbnailUrl} />
|
||||
{:else}
|
||||
<input type="hidden" name="trackId" value={contextMenu.track.id} />
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-3 rounded-lg px-2 py-2.5 text-left text-base text-[var(--mf-text)] transition hover:bg-white/5"
|
||||
>
|
||||
<PlaylistArt
|
||||
playlistId={playlist.id}
|
||||
trackIds={[]}
|
||||
hue={hueFor(playlist.id)}
|
||||
size="small"
|
||||
class="h-8 w-8 flex-shrink-0 rounded-lg"
|
||||
/>
|
||||
<span class="truncate text-base text-[var(--mf-text-2)]">{playlist.name}</span>
|
||||
</button>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TrackContextMenu menu={contextMenu} playlists={data.playlists} onClose={closeContextMenu} onAddToQueue={addToQueue} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user