Add likes, follows and playlist visibility; redesign web-player UI
Backend: - New Likes feature (toggle/get liked tracks) and endpoints - User follow/unfollow, listening stats, public user profile - Playlist visibility (public/private) support - Various auth and DTO/response adjustments to support the above Frontend (web-player): - New design system pass: theme tokens/mode (light/dark), glass menu, tabs bar, player dock/extras/queue components, mix tiles, artist link - New routes: Liked, public user profile (/u/[id]), likes API proxy - Search refactor: shared SearchBox component used by TopBar and MobileHeader (search now reachable on mobile), live results as you type instead of requiring Enter, dropped the "Buscando…" quick-result preview - Removed now-unused components (MobileNav, CoverBadge, MixBentoTile, PlayPauseOverlay, SuggestionsTable, userDirectory) - Design handoff reference docs added under web-player/design_handoff_musify_player and web-player/docs/design-system-refactor-plan.md
This commit is contained in:
@@ -19,7 +19,30 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
||||
|
||||
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
|
||||
|
||||
return { playlists: data };
|
||||
const tracksByPlaylist = await Promise.all(
|
||||
data.items.map((playlist) =>
|
||||
api
|
||||
.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
||||
})
|
||||
.then((res) => res.data?.items ?? [])
|
||||
)
|
||||
);
|
||||
|
||||
const items = data.items.map((playlist, i) => {
|
||||
const tracks = tracksByPlaylist[i];
|
||||
const trackCount = tracks.length;
|
||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
||||
return { ...playlist, trackCount, durationSeconds };
|
||||
});
|
||||
|
||||
const totals = {
|
||||
playlistCount: items.length,
|
||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
||||
};
|
||||
|
||||
return { playlists: { ...data, items }, totals };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
@@ -30,6 +53,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
@@ -55,7 +79,7 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
const { data, error: err } = await api.POST('/playlists', {
|
||||
body: { name, description, pictureIntentId }
|
||||
body: { name, description, pictureIntentId, visibility }
|
||||
});
|
||||
|
||||
if (err || !data)
|
||||
|
||||
@@ -1,43 +1,21 @@
|
||||
<script lang="ts">
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
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 PlaylistCard from '$lib/components/ui/PlaylistCard.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { fmtDurationLong } from '$lib/format';
|
||||
|
||||
const playlistNames = [
|
||||
'Mi Playlist',
|
||||
'Tus favoritos',
|
||||
'Lo mejor de hoy',
|
||||
'Para relajarse',
|
||||
'Buena vibra',
|
||||
'Modo estudio',
|
||||
'Road Trip',
|
||||
'Domingos',
|
||||
'Late Night',
|
||||
'Descubriendo música',
|
||||
'Todo un poco',
|
||||
'En bucle',
|
||||
'Mi colección',
|
||||
'Canciones para repetir',
|
||||
'Hits del momento'
|
||||
];
|
||||
const namePlaceholder = playlistNames[Math.floor(Math.random() * playlistNames.length)];
|
||||
|
||||
let { data, form } = $props();
|
||||
let { data } = $props();
|
||||
|
||||
const items = $derived(data.playlists.items);
|
||||
|
||||
let creating = $state(false);
|
||||
|
||||
function openCreate() {
|
||||
creating = true;
|
||||
}
|
||||
const totals = $derived(data.totals);
|
||||
const summary = $derived(
|
||||
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'playlist' : 'playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'canción' : 'canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -46,24 +24,24 @@
|
||||
</svelte:head>
|
||||
|
||||
<Page>
|
||||
<PageHeader title="Mis listas" subtitle="Crea y organiza tus colecciones.">
|
||||
<PageHeader
|
||||
title="Mis listas"
|
||||
subtitle={items.length > 0 ? summary : 'Crea y organiza tus colecciones.'}
|
||||
>
|
||||
{#snippet actions()}
|
||||
{#if items.length > 0}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2.5} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PageHeader>
|
||||
|
||||
{#if items.length > 0}
|
||||
<MediaGrid class="mt-9">
|
||||
<MediaGrid min="210px" minMobile="180px" class="mt-7 sm:mt-8">
|
||||
{#each items as playlist, i (playlist.id)}
|
||||
<PlaylistCard
|
||||
id={playlist.id}
|
||||
name={playlist.name}
|
||||
description={playlist.description}
|
||||
trackCount={playlist.trackCount}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
updatedAt={playlist.updatedAt}
|
||||
index={i}
|
||||
@@ -77,22 +55,8 @@
|
||||
description="Crea la primera y añádele canciones de tu biblioteca."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button onclick={openCreate}>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Crear lista
|
||||
</Button>
|
||||
<Button onclick={() => createPlaylistModal.show()}>Crear lista</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<Modal open={creating} onClose={() => (creating = false)} title="Nueva lista">
|
||||
<PlaylistForm
|
||||
action="?/create"
|
||||
{namePlaceholder}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Crear"
|
||||
submittingLabel="Creando…"
|
||||
onCancel={() => (creating = false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
@@ -8,75 +8,28 @@ import {
|
||||
unwrapOrFail
|
||||
} from '$lib/server/api';
|
||||
import { uploadPresignedImage } from '$lib/server/upload';
|
||||
import { fetchYoutubeFiller } from '$lib/server/youtube';
|
||||
import { shuffle } from '$lib/collections';
|
||||
import { addTrackAction, addYouTubeToPlaylistAction } from '$lib/server/playlistActions';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
const MAX_NAME = 100;
|
||||
const SUGGESTIONS_LIMIT = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
|
||||
const { allowAnonymousListening } = await parent();
|
||||
const user = optionalUser(locals, url, allowAnonymousListening);
|
||||
optionalUser(locals, url, allowAnonymousListening);
|
||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||
|
||||
const [playlistRes, tracksRes, libraryRes, playlistsRes] = await Promise.all([
|
||||
const [playlistRes, tracksRes] = await Promise.all([
|
||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||
api.GET('/playlists/{playlistId}/tracks', {
|
||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
||||
}),
|
||||
user
|
||||
? api.GET('/tracks/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
user
|
||||
? api.GET('/playlists/users/{userId}', {
|
||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
|
||||
})
|
||||
: Promise.resolve(null)
|
||||
})
|
||||
]);
|
||||
|
||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||
|
||||
const tracks = tracksRes.data?.items ?? [];
|
||||
const inPlaylist = new Set(tracks.map((t) => t.id));
|
||||
const library = (libraryRes?.data?.items ?? []).filter((t) => !inPlaylist.has(t.id));
|
||||
|
||||
const alreadyLinked = new Set(
|
||||
tracks.flatMap((t) => (t.source === 'YouTube' && t.externalId ? [t.externalId] : []))
|
||||
);
|
||||
const seeds = [...new Set(tracks.map((t) => t.artist).filter((a): a is string => !!a))];
|
||||
const seed = seeds.length > 0 ? shuffle(seeds)[0] : undefined;
|
||||
|
||||
const youtube = fetchYoutubeFiller(
|
||||
api,
|
||||
locals.accessToken,
|
||||
SUGGESTIONS_LIMIT + alreadyLinked.size,
|
||||
seed
|
||||
)
|
||||
.then((filler) => ({
|
||||
query: filler.query,
|
||||
items: (filler.items as YouTubeSong[])
|
||||
.filter((song) => !alreadyLinked.has(song.videoId))
|
||||
.slice(0, SUGGESTIONS_LIMIT)
|
||||
}))
|
||||
.catch(() => ({ query: '', items: [] as YouTubeSong[] }));
|
||||
|
||||
return {
|
||||
playlist,
|
||||
tracks,
|
||||
library,
|
||||
youtube,
|
||||
playlists: playlistsRes?.data?.items ?? []
|
||||
};
|
||||
return { playlist, tracks };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
addTrack: addTrackAction,
|
||||
addYouTubeToPlaylist: addYouTubeToPlaylistAction,
|
||||
|
||||
removeTrack: async ({ request, params, locals, fetch }) => {
|
||||
const accessToken = requireAccessTokenAction(locals);
|
||||
if (typeof accessToken !== 'string') return accessToken;
|
||||
@@ -98,6 +51,7 @@ export const actions: Actions = {
|
||||
const form = await request.formData();
|
||||
const name = String(form.get('name') ?? '').trim();
|
||||
const description = String(form.get('description') ?? '').trim();
|
||||
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
|
||||
const cover = form.get('cover');
|
||||
if (name === '' || name.length > MAX_NAME) {
|
||||
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
|
||||
@@ -119,7 +73,7 @@ export const actions: Actions = {
|
||||
|
||||
const result = await api.PUT('/playlists/{playlistId}', {
|
||||
params: { path: { playlistId: params.id } },
|
||||
body: { newName: name, newDescription: description, newPictureIntentId }
|
||||
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
|
||||
});
|
||||
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
|
||||
if (failure) return failure;
|
||||
|
||||
@@ -1,30 +1,21 @@
|
||||
<script lang="ts">
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, queueIdForTrack, toQueueItems } from '$lib/player/player.svelte';
|
||||
import Page from '$lib/components/ui/Page.svelte';
|
||||
import Modal from '$lib/components/ui/Modal.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/PlaylistForm.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import EmptyState from '$lib/components/ui/EmptyState.svelte';
|
||||
import PlaylistHeader from './components/PlaylistHeader.svelte';
|
||||
import PlaylistTrackTable from './components/PlaylistTrackTable.svelte';
|
||||
import SuggestionsTable from './components/SuggestionsTable.svelte';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
const playlist = $derived(data.playlist);
|
||||
const tracks = $derived(data.tracks);
|
||||
const library = $derived(data.library);
|
||||
|
||||
let editing = $state(false);
|
||||
let confirmingDelete = $state(false);
|
||||
let youtubeSuggestions = $state<YouTubeSong[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
youtubeSuggestions = [];
|
||||
data.youtube.then((result) => {
|
||||
youtubeSuggestions = result.items;
|
||||
});
|
||||
});
|
||||
|
||||
const isCurrentQueue = $derived(tracks.some((t) => queueIdForTrack(t) === player.current.id));
|
||||
|
||||
@@ -47,6 +38,7 @@
|
||||
description={playlist.description}
|
||||
trackIds={tracks.map((t) => t.id)}
|
||||
updatedAt={playlist.updatedAt}
|
||||
visibility={playlist.visibility}
|
||||
playing={isCurrentQueue && player.playing}
|
||||
onPlayAll={playAll}
|
||||
onEdit={() => (editing = true)}
|
||||
@@ -55,10 +47,17 @@
|
||||
|
||||
{#if tracks.length > 0}
|
||||
<PlaylistTrackTable {tracks} />
|
||||
{/if}
|
||||
|
||||
{#if library.length > 0 || youtubeSuggestions.length > 0}
|
||||
<SuggestionsTable {library} youtube={youtubeSuggestions} playlists={data.playlists} />
|
||||
{:else}
|
||||
<EmptyState
|
||||
icon={ListMusic}
|
||||
title="Esta playlist todavía está vacía"
|
||||
description="Añade canciones desde tu biblioteca o explora música nueva para empezar."
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button href="/explore" variant="secondary">Explorar música</Button>
|
||||
{/snippet}
|
||||
</EmptyState>
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
@@ -67,6 +66,7 @@
|
||||
action="?/rename"
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description ?? ''}
|
||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
@@ -79,10 +79,10 @@
|
||||
</Modal>
|
||||
|
||||
<Modal open={confirmingDelete} onClose={() => (confirmingDelete = false)} maxWidth="max-w-sm">
|
||||
<h2 class="text-lg font-semibold tracking-tight text-fg">
|
||||
<h2 class="font-display text-xl font-semibold tracking-[-0.02em] text-fg">
|
||||
¿Eliminar «{playlist.name}»?
|
||||
</h2>
|
||||
<p class="mt-2 text-base text-fg-3">
|
||||
<p class="mt-2.5 text-[13px] leading-[1.65] text-fg-2">
|
||||
Esta acción no se puede deshacer, la playlist se eliminará de tu biblioteca y de todos los
|
||||
dispositivos donde la tengas guardada.
|
||||
</p>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import SquarePencil from '@lucide/svelte/icons/square-pen';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import PlaylistArt from '$lib/components/ui/PlaylistArt.svelte';
|
||||
import BackLink from '$lib/components/ui/BackLink.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -13,39 +12,50 @@
|
||||
description?: string | null;
|
||||
trackIds: (string | number)[];
|
||||
updatedAt: string;
|
||||
visibility: 'Public' | 'Private';
|
||||
playing: boolean;
|
||||
onPlayAll: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
let { id, name, description, trackIds, updatedAt, playing, onPlayAll, onEdit, onDelete }: Props =
|
||||
$props();
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
trackIds,
|
||||
updatedAt,
|
||||
visibility,
|
||||
playing,
|
||||
onPlayAll,
|
||||
onEdit,
|
||||
onDelete
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<BackLink href="/playlists" label="Volver a tus listas" />
|
||||
|
||||
<div class="mt-5 flex flex-col gap-5 sm:mt-6 sm:flex-row sm:items-end sm:gap-6">
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:items-center sm:gap-6">
|
||||
<PlaylistArt
|
||||
playlistId={id}
|
||||
{trackIds}
|
||||
size="large"
|
||||
version={updatedAt}
|
||||
class="h-36 w-36 shrink-0 rounded-art-lg shadow-art-lg sm:h-44 sm:w-44"
|
||||
class="h-36 w-36 shrink-0 rounded-[18px] shadow-[0_24px_56px_rgba(0,0,0,.55)] sm:h-41 sm:w-41"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm tracking-[0.14em] text-fg-3 uppercase">Lista</p>
|
||||
<p class="text-[10.5px] font-semibold tracking-[0.16em] text-fg-2 uppercase">Lista</p>
|
||||
<h1
|
||||
class="mt-1.5 font-display text-3xl font-semibold tracking-tight break-words text-fg sm:text-5xl md:text-7xl"
|
||||
class="mt-3 font-display text-[28px] font-semibold tracking-[-0.035em] break-words text-fg sm:text-[33px]"
|
||||
>
|
||||
{name}
|
||||
</h1>
|
||||
{#if description}
|
||||
<p class="mt-3 max-w-2xl text-sm text-fg-2 sm:text-base">{description}</p>
|
||||
{/if}
|
||||
<p class="mt-3 text-sm text-muted">
|
||||
<p class="mt-3.25 text-[12.5px] text-fg-2">
|
||||
{trackIds.length}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'}
|
||||
{trackIds.length === 1 ? 'canción' : 'canciones'} · {visibility === 'Public'
|
||||
? 'Pública'
|
||||
: 'Privada'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,19 +63,15 @@
|
||||
<div class="mt-6 flex flex-wrap items-center gap-2.5 sm:mt-7">
|
||||
<Button size="sm" onclick={onPlayAll} disabled={trackIds.length === 0}>
|
||||
{#if playing}
|
||||
<Pause class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Pausar
|
||||
{:else}
|
||||
<Play class="h-4 w-4" fill="currentColor" strokeWidth={1.5} />
|
||||
Reproducir
|
||||
{/if}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={onEdit}>
|
||||
<SquarePencil class="h-4 w-4" strokeWidth={1.5} />
|
||||
Editar
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onclick={onDelete}>
|
||||
<Trash class="h-4 w-4" strokeWidth={1.5} />
|
||||
Eliminar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -31,23 +31,29 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<TrackTable index action meta={[{ label: 'Añadida', width: '140px' }]} class="mt-6 sm:mt-8">
|
||||
<TrackTable
|
||||
index
|
||||
action
|
||||
meta={[
|
||||
{ label: 'Álbum', width: '160px' },
|
||||
{ label: 'Añadida', width: '140px' }
|
||||
]}
|
||||
class="mt-6 sm:mt-8"
|
||||
>
|
||||
{#each tracks as track, i (track.id)}
|
||||
<TrackRow>
|
||||
<TrackIndexCell
|
||||
index={i}
|
||||
active={player.current.id === queueIdForTrack(track)}
|
||||
playing={player.playing}
|
||||
onToggle={() => playFrom(i)}
|
||||
/>
|
||||
{@const active = player.current.id === queueIdForTrack(track)}
|
||||
<TrackRow {active}>
|
||||
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
|
||||
<TrackTitleCell
|
||||
trackId={track.id}
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
coverSrc={isPendingYouTubeTrack(track) && track.externalId
|
||||
? youTubeThumbnailUrl(track.externalId)
|
||||
: undefined}
|
||||
explicit={track.isExplicit}
|
||||
{active}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet badge()}
|
||||
@@ -58,6 +64,7 @@
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta class="hidden sm:block">—</TrackMeta>
|
||||
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
|
||||
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
|
||||
<form
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import {
|
||||
player,
|
||||
queueIdForTrack,
|
||||
type ApiTrackLike,
|
||||
type QueueItem
|
||||
} from '$lib/player/player.svelte';
|
||||
import { fmtTime } from '$lib/format';
|
||||
import type { YouTubeSong } from '$lib/types';
|
||||
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
|
||||
import IconButton from '$lib/components/ui/IconButton.svelte';
|
||||
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
|
||||
import TrackTable from '$lib/components/ui/TrackTable.svelte';
|
||||
import TrackRow from '$lib/components/ui/TrackRow.svelte';
|
||||
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
|
||||
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
|
||||
import TrackContextMenu, {
|
||||
contextMenuStateFor,
|
||||
type ContextMenuState
|
||||
} from '$lib/components/TrackContextMenu.svelte';
|
||||
|
||||
type LibraryTrack = ApiTrackLike & { duration: number | string };
|
||||
type Suggestion = { kind: 'local'; track: LibraryTrack } | { kind: 'youtube'; song: YouTubeSong };
|
||||
|
||||
interface Props {
|
||||
library: LibraryTrack[];
|
||||
youtube: YouTubeSong[];
|
||||
playlists: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
let { library, youtube, playlists }: Props = $props();
|
||||
|
||||
let contextMenu = $state<ContextMenuState | null>(null);
|
||||
|
||||
function openContextMenu(event: MouseEvent, item: Suggestion) {
|
||||
contextMenu = contextMenuStateFor(event, item);
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
contextMenu = null;
|
||||
}
|
||||
|
||||
function addToQueue() {
|
||||
if (!contextMenu) return;
|
||||
player.addToQueue(queueItemFor(contextMenu as Suggestion));
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
const suggestions = $derived<Suggestion[]>([
|
||||
...library.map((track) => ({ kind: 'local' as const, track })),
|
||||
...youtube.map((song) => ({ kind: 'youtube' as const, song }))
|
||||
]);
|
||||
|
||||
function idFor(item: Suggestion) {
|
||||
return item.kind === 'youtube' ? item.song.videoId : queueIdForTrack(item.track);
|
||||
}
|
||||
|
||||
function queueItemFor(item: Suggestion): QueueItem {
|
||||
return item.kind === 'youtube'
|
||||
? {
|
||||
id: item.song.videoId,
|
||||
title: item.song.title,
|
||||
artist: item.song.artist,
|
||||
source: 'youtube',
|
||||
coverUrl: item.song.thumbnailUrl,
|
||||
explicit: item.song.isExplicit
|
||||
}
|
||||
: {
|
||||
id: queueIdForTrack(item.track),
|
||||
title: item.track.title,
|
||||
artist: item.track.artist ?? undefined,
|
||||
source: 'local',
|
||||
coverUrl: `/api/tracks/${item.track.id}/cover?size=small`
|
||||
};
|
||||
}
|
||||
|
||||
function playFrom(index: number) {
|
||||
player.playOrToggle(suggestions.map(queueItemFor), index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mt-10 sm:mt-12">
|
||||
<SectionHeading title="Sugerencias" />
|
||||
<TrackTable action>
|
||||
{#each suggestions as item, i (idFor(item))}
|
||||
<TrackRow oncontextmenu={(e) => openContextMenu(e, item)}>
|
||||
<TrackTitleCell
|
||||
trackId={item.kind === 'youtube' ? item.song.videoId : item.track.id}
|
||||
title={item.kind === 'youtube' ? item.song.title : item.track.title}
|
||||
artist={item.kind === 'youtube' ? item.song.artist : item.track.artist}
|
||||
coverSrc={item.kind === 'youtube' ? item.song.thumbnailUrl : undefined}
|
||||
explicit={item.kind === 'youtube' && item.song.isExplicit}
|
||||
onClick={() => playFrom(i)}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
{#if player.current.id === idFor(item)}
|
||||
<NowPlaying paused={!player.playing} />
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TrackTitleCell>
|
||||
<TrackMeta>
|
||||
{fmtTime(
|
||||
item.kind === 'youtube' ? item.song.durationSeconds : Number(item.track.duration)
|
||||
)}
|
||||
</TrackMeta>
|
||||
{#if item.kind === 'youtube'}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addYouTubeToPlaylist"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="videoId" value={item.song.videoId} />
|
||||
<input type="hidden" name="title" value={item.song.title} />
|
||||
<input type="hidden" name="artist" value={item.song.artist} />
|
||||
<input type="hidden" name="durationSeconds" value={item.song.durationSeconds} />
|
||||
<input type="hidden" name="thumbnailUrl" value={item.song.thumbnailUrl} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{:else}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addTrack"
|
||||
use:enhance={() =>
|
||||
async ({ update }) =>
|
||||
update()}
|
||||
>
|
||||
<input type="hidden" name="trackId" value={item.track.id} />
|
||||
<IconButton type="submit" label="Añadir a la playlist" size="sm">
|
||||
<Plus class="h-4 w-4" />
|
||||
</IconButton>
|
||||
</form>
|
||||
{/if}
|
||||
</TrackRow>
|
||||
{/each}
|
||||
</TrackTable>
|
||||
</div>
|
||||
|
||||
{#if contextMenu}
|
||||
<TrackContextMenu
|
||||
menu={contextMenu}
|
||||
{playlists}
|
||||
onClose={closeContextMenu}
|
||||
onAddToQueue={addToQueue}
|
||||
/>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user