web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs
This commit is contained in:
Vendored
+1
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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.'));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user