web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs

This commit is contained in:
2026-09-24 05:02:26 +02:00
parent dda8c30d45
commit bc43b4b6eb
21 changed files with 216 additions and 232 deletions
+1
View File
@@ -940,6 +940,7 @@ export interface components {
smallImageKeyName: null | string; smallImageKeyName: null | string;
mediumImageKeyName: null | string; mediumImageKeyName: null | string;
largeImageKeyName: null | string; largeImageKeyName: null | string;
ownerUserId: string;
visibility: components["schemas"]["PlaylistVisibility"]; visibility: components["schemas"]["PlaylistVisibility"];
/** Format: date-time */ /** Format: date-time */
createdAt: string; createdAt: string;
@@ -9,6 +9,8 @@
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Logo from '../ui/primitives/Logo.svelte'; import Logo from '../ui/primitives/Logo.svelte';
const SIDEBAR_PLAYLISTS_LIMIT = 8;
interface SidebarPlaylist { interface SidebarPlaylist {
id: string; id: string;
name: string; name: string;
@@ -19,11 +21,14 @@
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
playlists: SidebarPlaylist[]; 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) { function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section); return isSectionActive(href, page.url.pathname, page.data.section);
@@ -61,7 +66,7 @@
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-fg-2">Tus playlists</span> <span class="text-eyebrow text-fg-2">Tus playlists</span>
{#if playlists.length > 0} {#if visiblePlaylists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
tone="plain" tone="plain"
@@ -74,7 +79,7 @@
</div> </div>
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md"> <div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each playlists as playlist (playlist.id)} {#each visiblePlaylists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`} {@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow <ListRow
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
@@ -37,9 +37,16 @@
function onInput(event: Event) { function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0]; const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return; if (!file) return;
releasePreview();
preview = URL.createObjectURL(file); preview = URL.createObjectURL(file);
onselect?.(file); onselect?.(file);
} }
function releasePreview() {
if (preview !== '') URL.revokeObjectURL(preview);
}
$effect(() => releasePreview);
</script> </script>
<label <label
@@ -61,7 +61,7 @@
{/if} {/if}
<MenuItem <MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon} icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'} label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
onclick={() => themeMode.toggle()} onclick={() => themeMode.toggle()}
/> />
<form method="POST" action="/auth/logout" data-sveltekit-reload> <form method="POST" action="/auth/logout" data-sveltekit-reload>
@@ -9,15 +9,19 @@
description: string; description: string;
confirmLabel: string; confirmLabel: string;
action: string; action: string;
fields?: Record<string, string>;
} }
let { open, onClose, title, description, confirmLabel, action }: Props = $props(); let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
</script> </script>
<Dialog {open} {onClose} {title} {description}> <Dialog {open} {onClose} {title} {description}>
{#snippet actions()} {#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button> <Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}> <form method="POST" {action}>
{#each Object.entries(fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<Button type="submit" variant="danger">{confirmLabel}</Button> <Button type="submit" variant="danger">{confirmLabel}</Button>
</form> </form>
{/snippet} {/snippet}
+1 -1
View File
@@ -5,6 +5,7 @@ export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px'; export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
export const ALBUMS_PAGE_SIZE = 50; export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200; export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877; export const ALBUM_EARLIEST_YEAR = 1877;
export const LIBRARY_PICKER_PAGE_SIZE = 50; export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10; export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
@@ -14,7 +15,6 @@ export const HOME_MIXES_LIMIT = 6;
export const HOME_SHELF_LIMIT = 6; export const HOME_SHELF_LIMIT = 6;
export const HOME_CONTINUE_LIMIT = 10; export const HOME_CONTINUE_LIMIT = 10;
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5; export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500; export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12; export const EXPLORE_USERS_LIMIT = 12;
+7
View File
@@ -6,3 +6,10 @@ export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]
const data = (await res.json()) as { items: ApiTrackLike[] }; const data = (await res.json()) as { items: ApiTrackLike[] };
return toQueueItems(data.items ?? []); return toQueueItems(data.items ?? []);
} }
export async function fetchPlaylistQueueItems(playlistId: string): Promise<QueueItem[]> {
const res = await fetch(`/api/playlists/${playlistId}/tracks`);
if (!res.ok) return [];
const data = (await res.json()) as { items: ApiTrackLike[] };
return toQueueItems(data.items ?? []);
}
+2 -1
View File
@@ -9,7 +9,8 @@ export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise
try { try {
const api = createApiClient({ fetch: fetchFn }); const api = createApiClient({ fetch: fetchFn });
const { data } = await api.GET('/config/playback'); const { data, error } = await api.GET('/config/playback');
if (error !== undefined) return cached?.allowAnonymousListening ?? false;
const allowAnonymousListening = data?.allowAnonymousListening ?? false; const allowAnonymousListening = data?.allowAnonymousListening ?? false;
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS }; cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
@@ -31,12 +31,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
} }
}); });
const playlistsPromise = user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null);
const albumsPromise = query const albumsPromise = query
? api.GET('/albums', { ? api.GET('/albums', {
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } } params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
@@ -49,9 +43,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
}) })
: Promise.resolve(null); : Promise.resolve(null);
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([ const [tracksRes, albumsRes, usersRes] = await Promise.all([
tracksPromise, tracksPromise,
playlistsPromise,
albumsPromise, albumsPromise,
usersPromise usersPromise
]); ]);
@@ -64,9 +57,10 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
genres, genres,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
users: usersRes?.data?.items ?? [], users: usersRes?.data?.items ?? [],
viewerId: user?.sub ?? null, usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
playlists: playlistsRes?.data?.items ?? [] viewerId: user?.sub ?? null
}; };
}; };
@@ -83,7 +83,7 @@
const hasAlbums = $derived(albums.length > 0); const hasAlbums = $derived(albums.length > 0);
const hasUsers = $derived(users.length > 0); const hasUsers = $derived(users.length > 0);
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query)); const playlistMatches = $derived(matchPlaylists(data.userPlaylists, data.query));
const hasPlaylists = $derived(playlistMatches.length > 0); const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived( const nothingFound = $derived(
@@ -92,10 +92,10 @@
const counts = $derived( const counts = $derived(
searchCounts({ searchCounts({
tracks: songRows.length, tracks: Number(data.tracks.totalItemCount),
albums: albums.length, albums: data.albumsTotal,
playlists: playlistMatches.length, playlists: playlistMatches.length,
users: users.length users: data.usersTotal
}) })
); );
const chips = $derived(searchChips(counts)); const chips = $derived(searchChips(counts));
@@ -400,7 +400,7 @@
> >
{#snippet trailing()} {#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block"> <span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{Number(album.trackCount)} canciones {plural(Number(album.trackCount), 'canción', 'canciones')}
</span> </span>
{/snippet} {/snippet}
</ListRow> </ListRow>
@@ -462,7 +462,7 @@
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -481,6 +481,6 @@
fields: { albumId: albumMenu.albumId }, fields: { albumId: albumMenu.albumId },
label: 'Añadir álbum a una playlist' label: 'Añadir álbum a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -8,6 +8,7 @@
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte'; import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.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'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
let { data } = $props(); let { data } = $props();
@@ -16,6 +17,8 @@
const total = $derived(Number(data.tracks.totalItemCount)); const total = $derived(Number(data.tracks.totalItemCount));
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt }))); const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
let deleting = $state<(typeof items)[number] | null>(null);
function togglePlay(index: number) { function togglePlay(index: number) {
player.playOrToggle(toQueueItems(items), index); player.playOrToggle(toQueueItems(items), index);
} }
@@ -46,7 +49,11 @@
tracks={listTracks} tracks={listTracks}
columns={['uploaded', 'plays']} columns={['uploaded', 'plays']}
onPlay={togglePlay} 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} {:else}
<EmptyState <EmptyState
@@ -63,3 +70,13 @@
</EmptyState> </EmptyState>
{/if} {/if}
</Page> </Page>
<ConfirmDialog
open={deleting !== null}
onClose={() => (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 ?? '' }}
/>
@@ -1,25 +1,14 @@
import type { PageServerLoad, Actions } from './$types'; import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api'; import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addTrackAction } from '$lib/server/playlistActions'; import { addTrackAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => { 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 api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [mixRes, playlistsRes] = await Promise.all([ const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
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 mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404); return { mix: unwrapOrError(mixRes, 'Mezcla no encontrada.', 404) };
return { mix, playlists: playlistsRes.data?.items ?? [] };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -116,6 +116,6 @@
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
playlists={data.playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -13,7 +13,7 @@ const MAX_NAME = 100;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent(); const { allowAnonymousListening } = await parent();
optionalUser(locals, url, allowAnonymousListening); const user = optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes] = await Promise.all([ 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 playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? []; 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 = { export const actions: Actions = {
@@ -9,6 +9,7 @@
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte'; import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import Button from '$lib/components/ui/primitives/Button.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 EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte'; import TrackList from '$lib/components/ui/media/TrackList.svelte';
import { playlistMeta } from '$lib/utils/format'; import { playlistMeta } from '$lib/utils/format';
@@ -17,6 +18,7 @@
const playlist = $derived(data.playlist); const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks); const tracks = $derived(data.tracks);
const isOwner = $derived(data.isOwner);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt }))); const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
let editing = $state(false); let editing = $state(false);
@@ -39,8 +41,13 @@
</svelte:head> </svelte:head>
<Page> <Page>
<BackLink
href={isOwner ? '/playlists' : '/'}
label={isOwner ? 'Volver a tus playlists' : 'Volver al inicio'}
/>
<PageHeader <PageHeader
eyebrow="Lista" eyebrow="Playlist"
title={playlist.name} title={playlist.name}
description={playlist.description ?? undefined} description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public' meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
@@ -67,10 +74,12 @@
Reproducir Reproducir
{/if} {/if}
</Button> </Button>
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button> {#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}> <Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
Eliminar <Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
</Button> Eliminar
</Button>
{/if}
{/snippet} {/snippet}
</PageHeader> </PageHeader>
@@ -79,7 +88,9 @@
tracks={listTracks} tracks={listTracks}
columns={['added', 'plays']} columns={['added', 'plays']}
onPlay={playFrom} 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} {:else}
<EmptyState <EmptyState
@@ -299,14 +299,9 @@
</Surface> </Surface>
<Checkbox bind:checked={acceptedTerms}> <Checkbox bind:checked={acceptedTerms}>
Acepto los Acepto los términos y condiciones al subir {title.trim() !== ''
<a ? `«${title}»`
href="/terms" : 'esta canción'}.
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
>
términos y condiciones
</a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</Checkbox> </Checkbox>
{#if status === 'uploading'} {#if status === 'uploading'}
+1 -1
View File
@@ -10,7 +10,7 @@
<p class="mb-6.5 text-body text-fg-2"> <p class="mb-6.5 text-body text-fg-2">
{page.status === 404 {page.status === 404
? 'Comprueba el enlace o vuelve al inicio.' ? '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.'}
</p> </p>
<Button href="/" variant="secondary">Volver</Button> <Button href="/" variant="secondary">Volver</Button>
</div> </div>
+11 -10
View File
@@ -3,12 +3,11 @@ import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig'; import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { createApiClient } from '$lib/server/api'; import { createApiClient } from '$lib/server/api';
import { authConfig } from '$lib/server/config'; 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 PUBLIC_PATHS = new Set(['/auth']);
const SIDEBAR_PLAYLISTS_LIMIT = 8;
async function fetchSidebarPlaylists( async function fetchUserPlaylists(
fetchFn: typeof fetch, fetchFn: typeof fetch,
accessToken: string | null, accessToken: string | null,
userId: string userId: string
@@ -18,12 +17,12 @@ async function fetchSidebarPlaylists(
const { data } = await api.GET('/playlists/users/{userId}', { const { data } = await api.GET('/playlists/users/{userId}', {
params: { params: {
path: { userId }, 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 { } catch {
return []; return { items: [], total: 0 };
} }
} }
@@ -70,15 +69,16 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
return { return {
user: locals.user, user: locals.user,
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists: [], userPlaylists: [],
userPlaylistsTotal: 0,
likedTracks: [], likedTracks: [],
lastPlayedTrack: null, lastPlayedTrack: null,
accountUrl accountUrl
}; };
} }
const [sidebarPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([ const [userPlaylists, likedTracks, lastPlayedTrack] = await Promise.all([
fetchSidebarPlaylists(fetch, locals.accessToken, locals.user.sub), fetchUserPlaylists(fetch, locals.accessToken, locals.user.sub),
fetchLikedTracks(fetch, locals.accessToken), fetchLikedTracks(fetch, locals.accessToken),
fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub) fetchLastPlayedTrack(fetch, locals.accessToken, locals.user.sub)
]); ]);
@@ -86,7 +86,8 @@ export const load: LayoutServerLoad = async ({ locals, url, fetch }) => {
return { return {
user: locals.user, user: locals.user,
allowAnonymousListening, allowAnonymousListening,
sidebarPlaylists, userPlaylists: userPlaylists.items,
userPlaylistsTotal: userPlaylists.total,
likedTracks, likedTracks,
lastPlayedTrack, lastPlayedTrack,
accountUrl accountUrl
+5 -1
View File
@@ -108,7 +108,11 @@
<div class="app-backdrop"></div> <div class="app-backdrop"></div>
<div class="app-vignette"></div> <div class="app-vignette"></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} /> <Sidebar
user={data.user}
playlists={data.userPlaylists ?? []}
playlistCount={data.userPlaylistsTotal ?? 0}
/>
<div class="flex min-w-0 flex-1 flex-col"> <div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1"> <div class="flex min-h-0 flex-1">
+93 -163
View File
@@ -9,17 +9,18 @@
playAllOrToggle, playAllOrToggle,
playShuffled, playShuffled,
toQueueItem, toQueueItem,
toQueueItems,
trackFromQueueItem, trackFromQueueItem,
type ApiTrackLike type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte'; } from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed'; import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
import { mixItemTrack } from '$lib/data/mixes'; 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 Page from '$lib/components/ui/layout/Page.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte';
import MediaCard from '$lib/components/ui/media/MediaCard.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 ListRow from '$lib/components/ui/media/ListRow.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte'; import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
@@ -27,80 +28,35 @@
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte'; import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/state/menus.svelte'; 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(); let { data } = $props();
const trackMenu = createTrackMenu(); const trackMenu = createTrackMenu();
type SpotlightTrack = Awaited<typeof data.spotlightTracks>['items'][number];
type SpotlightRow = { track: SpotlightTrack; seconds: number };
let spotlightRows = $state<SpotlightRow[]>([]);
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 mixes = $derived(data.mixes);
const playlists = $derived(data.playlists); const playlists = $derived(data.playlists);
const spotlight = $derived(data.spotlightPlaylist); 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 continueItems = $derived(mergeRecentlyPlayed(data.recentlyPlayed, HOME_CONTINUE_LIMIT));
const recentPool = $derived(mergeRecentlyPlayed(data.recentlyPlayed, 24));
const popularTracks = $derived<ApiTrackLike[]>(
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) { function playContinue(index: number) {
player.playOrToggle(continueItems, index); player.playOrToggle(continueItems, index);
} }
function playPopular(index: number) { function playSpotlightTrack(items: QueueItem[], index: number) {
player.playOrToggle(popularTracks.map(toQueueItem), index); player.playOrToggle(items, index);
} }
function playSpotlightTrack(index: number) { async function playSpotlight(previewItems: QueueItem[]) {
player.playOrToggle(spotlightQueue, index); if (!spotlight) return;
if (isQueueCurrent(previewItems)) {
player.toggle();
return;
}
playAllOrToggle(await fetchPlaylistQueueItems(spotlight.id));
} }
function playSpotlight() { async function shuffleSpotlight() {
playAllOrToggle(spotlightQueue); if (!spotlight) return;
} playShuffled(await fetchPlaylistQueueItems(spotlight.id));
function shuffleSpotlight() {
playShuffled(spotlightQueue);
} }
function openContextMenu(event: MouseEvent, track: ApiTrackLike) { function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
@@ -149,10 +105,13 @@
</p> </p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5"> <div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight} {#if spotlight}
<Button variant="primary" onclick={playSpotlight}> {#await data.spotlightTracks then { items }}
{heroPlaying ? 'Pausar' : 'Reanudar'} {@const previewItems = toQueueItems(items)}
{spotlight.name} <Button variant="primary" onclick={() => playSpotlight(previewItems)}>
</Button> {isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
{spotlight.name}
</Button>
{/await}
{/if} {/if}
<Button href="/explore" variant="secondary">Explorar música</Button> <Button href="/explore" variant="secondary">Explorar música</Button>
</div> </div>
@@ -181,7 +140,7 @@
<div class="mt-10 flex flex-col gap-section"> <div class="mt-10 flex flex-col gap-section">
<section> <section>
<SectionHeading title="Continuar escuchando" subtitle="Retomalo donde lo dejaste" /> <SectionHeading title="Continuar escuchando" subtitle="Retómalo donde lo dejaste" />
{#if continueItems.length > 0} {#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5"> <div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)} {#each continueItems as item, i (item.id)}
@@ -209,66 +168,76 @@
{#if spotlight} {#if spotlight}
<section> <section>
<div {#await data.spotlightTracks then { items, totalCount }}
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2" {@const previewItems = toQueueItems(items)}
> {@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
<div class="flex flex-col justify-center p-8 sm:p-9"> {@const meta =
<div totalCount === 0
role="button" ? visibilityLabel
tabindex="0" : `${plural(totalCount, 'canción', 'canciones')} · ${visibilityLabel}`}
use:pressable={() => goto(`/playlists/${spotlight.id}`)} <div
class="flex w-fit items-center gap-5 self-start sm:gap-6" class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
> >
<Artwork <div class="flex flex-col justify-center p-8 sm:p-9">
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent( <a
spotlight.updatedAt href="/playlists/{spotlight.id}"
)}" class="flex w-fit items-center gap-5 self-start sm:gap-6"
trackIds={spotlight.coverTrackIds} >
size="2xl" <Artwork
alt={spotlight.name} src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
class="shrink-0" spotlight.updatedAt
/> )}"
<div class="flex min-w-0 flex-col gap-3"> trackIds={spotlight.coverTrackIds}
<p class="text-eyebrow text-fg-2">Playlist destacada</p> size="2xl"
<h3 class="truncate text-display-2 text-fg"> alt={spotlight.name}
{spotlight.name} class="shrink-0"
</h3> />
<p class="text-sm text-fg-2">{spotlightMeta}</p> <div class="flex min-w-0 flex-col gap-3">
<p class="text-eyebrow text-fg-2">Playlist destacada</p>
<h3 class="truncate text-display-2 text-fg">
{spotlight.name}
</h3>
<p class="text-sm text-fg-2">{meta}</p>
</div>
</a>
<p class="mt-5 max-w-prose-sm text-sm text-fg-2">
{spotlight.description || 'Tu colección, siempre a mano.'}
</p>
<div class="mt-5 flex gap-2.5">
<Button variant="accent" onclick={() => playSpotlight(previewItems)}
>Reproducir</Button
>
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button>
</div> </div>
</div> </div>
<p class="mt-5 max-w-prose-sm text-sm text-fg-2"> <div class="flex flex-col gap-2 p-5.5 sm:p-6">
{spotlight.description || 'Tu colección, siempre a mano.'} {#each items as track, i (track.id)}
</p> <ListRow
<div class="mt-5 flex gap-2.5"> onclick={() => playSpotlightTrack(previewItems, i)}
<Button variant="accent" onclick={playSpotlight}>Reproducir</Button> active={player.current.id === track.id}
<Button variant="secondary" onclick={shuffleSpotlight}>Aleatorio</Button> size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
>
{#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
>{i + 1}</span
>
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(Number(track.duration))}</span
>
{/snippet}
</ListRow>
{:else}
<p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
{/each}
</div> </div>
</div> </div>
<div class="flex flex-col gap-2 p-5.5 sm:p-6"> {/await}
{#each spotlightRows as { track, seconds }, i (track.id)}
<ListRow
onclick={() => playSpotlightTrack(i)}
active={player.current.id === track.id}
size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
>
{#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
>{i + 1}</span
>
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(seconds)}</span>
{/snippet}
</ListRow>
{:else}
<p class="p-2 text-sm text-fg-2">Esta playlist todavía no tiene canciones.</p>
{/each}
</div>
</div>
</section> </section>
{/if} {/if}
@@ -296,45 +265,6 @@
{/if} {/if}
</section> </section>
<section>
<SectionHeading title="Populares esta semana" subtitle="Lo más escuchado de tu biblioteca" />
{#if popularTracks.length > 0}
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTracks as track, i (track.id)}
{@const isCurrent = player.current.id === track.id}
<ListRow
onclick={() => 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()}
<span class="flex h-3.5 w-icon-md shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-fg-2 tabular-nums">{i + 1}</span>
{/if}
</span>
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
{/each}
</div>
{:else}
{@render noListeningHistory()}
{/if}
</section>
<section> <section>
<SectionHeading <SectionHeading
title="Tus playlists" title="Tus playlists"
@@ -387,6 +317,6 @@
fields: { trackId: String(trackMenu.state.track.id) }, fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist' label: 'Añadir a una playlist'
}} }}
{playlists} playlists={data.userPlaylists}
/> />
{/if} {/if}
@@ -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.'));
};