- {#each playlists as playlist (playlist.id)}
+ {#each visiblePlaylists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
releasePreview);
themeMode.toggle()}
/>
@@ -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
- (confirmingDelete = true)}>
- Eliminar
-
+ {#if isOwner}
+ (editing = true)}>Editar
+ (confirmingDelete = true)}>
+ Eliminar
+
+ {/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.'}
Volver
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}
-
- {heroPlaying ? 'Pausar' : 'Reanudar'}
- {spotlight.name}
-
+ {#await data.spotlightTracks then { items }}
+ {@const previewItems = toQueueItems(items)}
+ playSpotlight(previewItems)}>
+ {isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
+ {spotlight.name}
+
+ {/await}
{/if}
Explorar música
@@ -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}`}
+
+
-
- {spotlight.description || 'Tu colección, siempre a mano.'}
-
-
-
Reproducir
-
Aleatorio
+
+ {#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()}
-
- {fmtPlays(track.listensCount)}
-
- {/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.'));
+};