From bc43b4b6eb2e61138a790266a31784aeb558a22f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 24 Sep 2026 05:02:26 +0200 Subject: [PATCH] web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs --- web-player/src/lib/api/schema.d.ts | 1 + .../src/lib/components/layout/Sidebar.svelte | 13 +- .../components/ui/forms/ImageDropzone.svelte | 7 + .../components/ui/overlay/AccountMenu.svelte | 2 +- .../ui/overlay/ConfirmDialog.svelte | 6 +- web-player/src/lib/config.ts | 2 +- web-player/src/lib/data/albums.ts | 7 + web-player/src/lib/server/playbackConfig.ts | 3 +- .../src/routes/(app)/explore/+page.server.ts | 14 +- .../src/routes/(app)/explore/+page.svelte | 14 +- .../src/routes/(app)/library/+page.svelte | 19 +- .../routes/(app)/mixes/[id]/+page.server.ts | 17 +- .../src/routes/(app)/mixes/[id]/+page.svelte | 2 +- .../(app)/playlists/[id]/+page.server.ts | 6 +- .../routes/(app)/playlists/[id]/+page.svelte | 23 +- .../src/routes/(app)/upload/+page.svelte | 11 +- web-player/src/routes/+error.svelte | 2 +- web-player/src/routes/+layout.server.ts | 21 +- web-player/src/routes/+layout.svelte | 6 +- web-player/src/routes/+page.svelte | 256 +++++++----------- .../api/playlists/[id]/tracks/+server.ts | 16 ++ 21 files changed, 216 insertions(+), 232 deletions(-) create mode 100644 web-player/src/routes/api/playlists/[id]/tracks/+server.ts diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 064a333..863dbff 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -940,6 +940,7 @@ export interface components { smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; + ownerUserId: string; visibility: components["schemas"]["PlaylistVisibility"]; /** Format: date-time */ createdAt: string; diff --git a/web-player/src/lib/components/layout/Sidebar.svelte b/web-player/src/lib/components/layout/Sidebar.svelte index 7649cf9..dfdc201 100644 --- a/web-player/src/lib/components/layout/Sidebar.svelte +++ b/web-player/src/lib/components/layout/Sidebar.svelte @@ -9,6 +9,8 @@ import Plus from '@lucide/svelte/icons/plus'; import Logo from '../ui/primitives/Logo.svelte'; + const SIDEBAR_PLAYLISTS_LIMIT = 8; + interface SidebarPlaylist { id: string; name: string; @@ -19,11 +21,14 @@ interface Props { user: SessionUser | null; playlists: SidebarPlaylist[]; + playlistCount: number; } - let { user, playlists }: Props = $props(); + let { user, playlists, playlistCount }: Props = $props(); - const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count })); + const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT)); + + const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count })); function isActive(href: string) { return isSectionActive(href, page.url.pathname, page.data.section); @@ -61,7 +66,7 @@
Tus playlists - {#if playlists.length > 0} + {#if visiblePlaylists.length > 0}
- {#each playlists as playlist (playlist.id)} + {#each visiblePlaylists as playlist (playlist.id)} {@const active = page.url.pathname === `/playlists/${playlist.id}`} releasePreview); @@ -462,7 +462,7 @@ fields: { trackId: String(trackMenu.state.track.id) }, label: 'Añadir a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} @@ -481,6 +481,6 @@ fields: { albumId: albumMenu.albumId }, label: 'Añadir álbum a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} diff --git a/web-player/src/routes/(app)/library/+page.svelte b/web-player/src/routes/(app)/library/+page.svelte index 1dc9858..fcd1705 100644 --- a/web-player/src/routes/(app)/library/+page.svelte +++ b/web-player/src/routes/(app)/library/+page.svelte @@ -8,6 +8,7 @@ import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; + import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte'; let { data } = $props(); @@ -16,6 +17,8 @@ const total = $derived(Number(data.tracks.totalItemCount)); const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt }))); + let deleting = $state<(typeof items)[number] | null>(null); + function togglePlay(index: number) { player.playOrToggle(toQueueItems(items), index); } @@ -46,7 +49,11 @@ tracks={listTracks} columns={['uploaded', 'plays']} onPlay={togglePlay} - rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }} + rowAction={{ + onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null), + icon: X, + label: 'Borrar canción' + }} /> {:else} {/if} + + (deleting = null)} + title="¿Borrar «{deleting?.title ?? ''}»?" + description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté." + confirmLabel="Borrar" + action="?/deleteTrack" + fields={{ trackId: deleting?.id ?? '' }} +/> diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts index 5312412..51ffa46 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/mixes/[id]/+page.server.ts @@ -1,25 +1,14 @@ import type { PageServerLoad, Actions } from './$types'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { addTrackAction } from '$lib/server/playlistActions'; -import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { - const user = requireUser(locals, url); + requireUser(locals, url); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); - const [mixRes, playlistsRes] = await Promise.all([ - api.GET('/mixes/{id}', { params: { path: { id: params.id } } }), - api.GET('/playlists/users/{userId}', { - params: { - path: { userId: user.sub }, - query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } - } - }) - ]); + const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } }); - const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404); - - return { mix, playlists: playlistsRes.data?.items ?? [] }; + return { mix: unwrapOrError(mixRes, 'Mezcla no encontrada.', 404) }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/mixes/[id]/+page.svelte b/web-player/src/routes/(app)/mixes/[id]/+page.svelte index 6f1c7a2..92ee433 100644 --- a/web-player/src/routes/(app)/mixes/[id]/+page.svelte +++ b/web-player/src/routes/(app)/mixes/[id]/+page.svelte @@ -116,6 +116,6 @@ fields: { trackId: String(trackMenu.state.track.id) }, label: 'Añadir a una playlist' }} - playlists={data.playlists} + playlists={data.userPlaylists} /> {/if} diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts index fd32656..e119baa 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/playlists/[id]/+page.server.ts @@ -13,7 +13,7 @@ const MAX_NAME = 100; export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { const { allowAnonymousListening } = await parent(); - optionalUser(locals, url, allowAnonymousListening); + const user = optionalUser(locals, url, allowAnonymousListening); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const [playlistRes, tracksRes] = await Promise.all([ @@ -26,7 +26,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404); const tracks = tracksRes.data?.items ?? []; - return { playlist, tracks }; + const isOwner = user !== null && String(playlist.ownerUserId) === user.sub; + + return { playlist, tracks, isOwner, section: isOwner ? '/playlists' : null }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/playlists/[id]/+page.svelte b/web-player/src/routes/(app)/playlists/[id]/+page.svelte index a466830..c59b5b7 100644 --- a/web-player/src/routes/(app)/playlists/[id]/+page.svelte +++ b/web-player/src/routes/(app)/playlists/[id]/+page.svelte @@ -9,6 +9,7 @@ import Artwork from '$lib/components/ui/media/Artwork.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte'; + import BackLink from '$lib/components/ui/primitives/BackLink.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte'; import { playlistMeta } from '$lib/utils/format'; @@ -17,6 +18,7 @@ const playlist = $derived(data.playlist); const tracks = $derived(data.tracks); + const isOwner = $derived(data.isOwner); const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt }))); let editing = $state(false); @@ -39,8 +41,13 @@ + + (editing = true)}>Editar - + {#if isOwner} + + + {/if} {/snippet} @@ -79,7 +88,9 @@ tracks={listTracks} columns={['added', 'plays']} onPlay={playFrom} - rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }} + rowAction={isOwner + ? { action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' } + : undefined} /> {:else} - Acepto los - - términos y condiciones - - al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}. + Acepto los términos y condiciones al subir {title.trim() !== '' + ? `«${title}»` + : 'esta canción'}. {#if status === 'uploading'} diff --git a/web-player/src/routes/+error.svelte b/web-player/src/routes/+error.svelte index 6597497..80a31da 100644 --- a/web-player/src/routes/+error.svelte +++ b/web-player/src/routes/+error.svelte @@ -10,7 +10,7 @@

{page.status === 404 ? 'Comprueba el enlace o vuelve al inicio.' - : 'La conexión se interrumpió. Lo que tengas descargado seguirá disponible sin conexión.'} + : 'Algo salió mal al cargar la página. Inténtalo de nuevo en unos momentos.'}

diff --git a/web-player/src/routes/+layout.server.ts b/web-player/src/routes/+layout.server.ts index caf325d..944581e 100644 --- a/web-player/src/routes/+layout.server.ts +++ b/web-player/src/routes/+layout.server.ts @@ -3,12 +3,11 @@ import type { LayoutServerLoad } from './$types'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { createApiClient } from '$lib/server/api'; import { authConfig } from '$lib/server/config'; -import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config'; +import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config'; const PUBLIC_PATHS = new Set(['/auth']); -const SIDEBAR_PLAYLISTS_LIMIT = 8; -async function fetchSidebarPlaylists( +async function fetchUserPlaylists( fetchFn: typeof fetch, accessToken: string | null, userId: string @@ -18,12 +17,12 @@ async function fetchSidebarPlaylists( const { data } = await api.GET('/playlists/users/{userId}', { params: { path: { userId }, - query: { pageNumber: 1, pageSize: SIDEBAR_PLAYLISTS_LIMIT } + query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE } } }); - return data?.items ?? []; + return { items: data?.items ?? [], total: Number(data?.totalItemCount ?? 0) }; } catch { - return []; + return { items: [], total: 0 }; } } @@ -70,15 +69,16 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { return { user: locals.user, allowAnonymousListening, - sidebarPlaylists: [], + userPlaylists: [], + userPlaylistsTotal: 0, likedTracks: [], lastPlayedTrack: null, accountUrl }; } - const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ - fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), + const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ + fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub), fetchLikedTracks(fetch, locals.accessToken), fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) ]); @@ -86,7 +86,8 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => { return { user: locals.user, allowAnonymousListening, - sidebarPlaylists, + userPlaylists: userPlaylists.items, + userPlaylistsTotal: userPlaylists.total, likedTracks, lastPlayedTrack, accountUrl diff --git a/web-player/src/routes/+layout.svelte b/web-player/src/routes/+layout.svelte index 71250f5..f842c89 100644 --- a/web-player/src/routes/+layout.svelte +++ b/web-player/src/routes/+layout.svelte @@ -108,7 +108,11 @@
- +
diff --git a/web-player/src/routes/+page.svelte b/web-player/src/routes/+page.svelte index 906c6e6..56aedbb 100644 --- a/web-player/src/routes/+page.svelte +++ b/web-player/src/routes/+page.svelte @@ -9,17 +9,18 @@ playAllOrToggle, playShuffled, toQueueItem, + toQueueItems, trackFromQueueItem, - type ApiTrackLike + type ApiTrackLike, + type QueueItem } from '$lib/player/player.svelte'; - import { pressable } from '$lib/actions/pressable'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; import { mixItemTrack } from '$lib/data/mixes'; - import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/utils/format'; + import { fetchPlaylistQueueItems } from '$lib/data/albums'; + import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format'; import Page from '$lib/components/ui/layout/Page.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte'; import MediaCard from '$lib/components/ui/media/MediaCard.svelte'; - import EqBars from '$lib/components/ui/media/EqBars.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; @@ -27,80 +28,35 @@ import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import { createTrackMenu } from '$lib/state/menus.svelte'; - import { HOME_CONTINUE_LIMIT, HOME_POPULAR_MAX } from '$lib/config'; + import { HOME_CONTINUE_LIMIT } from '$lib/config'; let { data } = $props(); const trackMenu = createTrackMenu(); - type SpotlightTrack = Awaited['items'][number]; - type SpotlightRow = { track: SpotlightTrack; seconds: number }; - let spotlightRows = $state([]); - let spotlightTotal = $state(0); - - $effect(() => { - spotlightRows = []; - spotlightTotal = 0; - let cancelled = false; - data.spotlightTracks.then(({ items, totalCount }) => { - if (cancelled) return; - spotlightRows = items.map((track) => ({ - track, - seconds: Number(track.duration) - })); - spotlightTotal = totalCount; - }); - return () => { - cancelled = true; - }; - }); - const mixes = $derived(data.mixes); const playlists = $derived(data.playlists); const spotlight = $derived(data.spotlightPlaylist); - const spotlightItems = $derived(spotlightRows.map((row) => row.track)); - const spotlightDurationSeconds = $derived( - spotlightRows.reduce((sum, row) => sum + row.seconds, 0) - ); - const spotlightTrackCount = $derived(spotlightTotal || spotlightItems.length); - const spotlightVisibilityLabel = $derived( - spotlight?.visibility === 'Public' ? 'Pública' : 'Privada' - ); - const spotlightMeta = $derived( - spotlightTrackCount === 0 - ? spotlightVisibilityLabel - : [ - plural(spotlightTrackCount, 'canción', 'canciones'), - fmtDurationLong(spotlightDurationSeconds), - spotlightVisibilityLabel - ].join(' · ') - ); const continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT)); - const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24)); - - const popularTracks = $derived( - recentPool.map(trackFromQueueItem).slice(0, HOME_POPULAR_MAX) - ); - - const spotlightQueue = $derived(spotlightItems.map(toQueueItem)); - const spotlightIsCurrent = $derived(isQueueCurrent(spotlightQueue)); - const heroPlaying = $derived(spotlightIsCurrent && player.playing); function playContinue(index: number) { player.playOrToggle(continueItems, index); } - function playPopular(index: number) { - player.playOrToggle(popularTracks.map(toQueueItem), index); + function playSpotlightTrack(items: QueueItem[], index: number) { + player.playOrToggle(items, index); } - function playSpotlightTrack(index: number) { - player.playOrToggle(spotlightQueue, index); + async function playSpotlight(previewItems: QueueItem[]) { + if (!spotlight) return; + if (isQueueCurrent(previewItems)) { + player.toggle(); + return; + } + playAllOrToggle(await fetchPlaylistQueueItems(spotlight.id)); } - function playSpotlight() { - playAllOrToggle(spotlightQueue); - } - function shuffleSpotlight() { - playShuffled(spotlightQueue); + async function shuffleSpotlight() { + if (!spotlight) return; + playShuffled(await fetchPlaylistQueueItems(spotlight.id)); } function openContextMenu(event: MouseEvent, track: ApiTrackLike) { @@ -149,10 +105,13 @@

{#if spotlight} - + {#await data.spotlightTracks then { items }} + {@const previewItems = toQueueItems(items)} + + {/await} {/if}
@@ -181,7 +140,7 @@
- + {#if continueItems.length > 0}
{#each continueItems as item, i (item.id)} @@ -209,66 +168,76 @@ {#if spotlight}
-
-
-
goto(`/playlists/${spotlight.id}`)} - class="flex w-fit items-center gap-5 self-start sm:gap-6" - > - -
-

Playlist destacada

-

- {spotlight.name} -

-

{spotlightMeta}

+ {#await data.spotlightTracks then { items, totalCount }} + {@const previewItems = toQueueItems(items)} + {@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'} + {@const meta = + totalCount === 0 + ? visibilityLabel + : `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`} +
+
+ + +
+

Playlist destacada

+

+ {spotlight.name} +

+

{meta}

+
+
+

+ {spotlight.description || 'Tu colección, siempre a mano.'} +

+
+ +
-

- {spotlight.description || 'Tu colección, siempre a mano.'} -

-
- - +
+ {#each items as track, i (track.id)} + playSpotlightTrack(previewItems, i)} + active={player.current.id === track.id} + size="sm" + title={track.title} + subtitle={track.artist} + subtitleHref={track.ownerUserId} + explicit={track.isExplicit} + > + {#snippet leading()} + {i + 1} + {/snippet} + {#snippet trailing()} + {fmtTime(Number(track.duration))} + {/snippet} + + {:else} +

Esta playlist todavía no tiene canciones.

+ {/each}
-
- {#each spotlightRows as { track, seconds }, i (track.id)} - playSpotlightTrack(i)} - active={player.current.id === track.id} - size="sm" - title={track.title} - subtitle={track.artist} - subtitleHref={track.ownerUserId} - explicit={track.isExplicit} - > - {#snippet leading()} - {i + 1} - {/snippet} - {#snippet trailing()} - {fmtTime(seconds)} - {/snippet} - - {:else} -

Esta playlist todavía no tiene canciones.

- {/each} -
-
+ {/await}
{/if} @@ -296,45 +265,6 @@ {/if}
-
- - {#if popularTracks.length > 0} -
- {#each popularTracks as track, i (track.id)} - {@const isCurrent = player.current.id === track.id} - playPopular(i)} - oncontextmenu={(e) => openContextMenu(e, track)} - active={isCurrent} - size="sm" - title={track.title} - subtitle={track.artist} - subtitleHref={track.ownerUserId} - explicit={track.isExplicit} - trackId={track.id} - > - {#snippet leading()} - - {#if isCurrent} - - {:else} - {i + 1} - {/if} - - {/snippet} - {#snippet trailing()} - - {/snippet} - - {/each} -
- {:else} - {@render noListeningHistory()} - {/if} -
-
{/if} diff --git a/web-player/src/routes/api/playlists/[id]/tracks/+server.ts b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts new file mode 100644 index 0000000..3f8618d --- /dev/null +++ b/web-player/src/routes/api/playlists/[id]/tracks/+server.ts @@ -0,0 +1,16 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config'; + +export const GET: RequestHandler = async ({ params, locals, fetch }) => { + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const result = await api.GET('/playlists/{playlistId}/tracks', { + params: { + path: { playlistId: params.id }, + query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE } + } + }); + + return json(unwrapOrError(result, 'No se pudo cargar la playlist.')); +};