web-player: normalize tracks, albums and playlists into single domain types at the server boundary

This commit is contained in:
2026-09-24 05:09:37 +02:00
parent 37fc229bf6
commit 5b2a0e4d46
41 changed files with 526 additions and 452 deletions
@@ -6,6 +6,7 @@ import {
requireUser,
unwrapOrError
} from '$lib/server/api';
import { toAlbum, toPage } from '$lib/server/mappers';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
@@ -18,7 +19,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
});
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
const albums = toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
return { albums };
};
@@ -40,10 +40,7 @@
<MediaCard
href="/albums/{album.id}"
title={album.title}
subtitle={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
subtitle={albumMeta(album.releaseYear, album.trackCount)}
trackIds={album.coverTrackIds}
src="/api/albums/{album.id}/cover?size=large"
index={i}
@@ -7,6 +7,7 @@ import {
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
@@ -34,11 +35,13 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
: Promise.resolve(null)
]);
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
const album = toAlbum(unwrapOrError(albumRes, 'Álbum no encontrado.', 404));
const tracks = tracksRes.data?.items ?? [];
const tracks = (tracksRes.data?.items ?? []).map(toTrack);
const inAlbum = new Set(tracks.map((track) => track.id));
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id));
const library = (libraryRes?.data?.items ?? [])
.filter((track) => !inAlbum.has(track.id))
.map(toTrack);
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
@@ -1,5 +1,5 @@
<script lang="ts">
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import Page from '$lib/components/ui/layout/Page.svelte';
@@ -28,15 +28,15 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(toQueueItems(tracks));
playAllOrToggle(tracks);
}
function playTrackFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
player.playOrToggle(tracks, index);
}
function playLibraryFrom(index: number) {
player.playOrToggle(toQueueItems(library), index);
player.playOrToggle(library, index);
}
</script>
@@ -55,10 +55,7 @@
eyebrow="Álbum"
title={album.title}
description={album.description ?? undefined}
meta={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
meta={albumMeta(album.releaseYear, tracks.length)}
>
{#snippet cover()}
<Artwork
@@ -118,7 +115,7 @@
action="?/edit"
initialTitle={album.title}
initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
initialReleaseYear={album.releaseYear ?? undefined}
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
formMessage={form?.message}
submitLabel="Guardar"
@@ -1,5 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
@@ -48,14 +49,16 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
usersPromise
]);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track)
);
return {
query,
genre,
genres,
tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
users: usersRes?.data?.items ?? [],
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
@@ -1,7 +1,8 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItem } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/data/albums';
import { player } from '$lib/player/player.svelte';
import { fetchAlbumTracks } from '$lib/data/albums';
import type { Track } from '$lib/types';
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
@@ -49,9 +50,7 @@
let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
type LocalTrack = (typeof data.tracks.items)[number]['track'];
let localItems = $state<LocalTrack[]>([]);
let localItems = $state<Track[]>([]);
let localPage = $state(1);
let hasMore = $state(false);
let loadingMore = $state(false);
@@ -60,19 +59,12 @@
trackMenu.close();
albumMenu = null;
sfilter = data.genre ? 'Canciones' : 'Todo';
localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber);
localItems = data.tracks.items;
localPage = data.tracks.pageNumber;
hasMore = data.tracks.hasNextPage;
});
const songRows = $derived(
localItems.map((track) => ({
track,
seconds: Number(track.duration)
}))
);
const items = $derived(songRows.map((row) => row.track));
const items = $derived(localItems);
const tiles = $derived(genreTiles(data.genres));
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
@@ -92,7 +84,7 @@
const counts = $derived(
searchCounts({
tracks: Number(data.tracks.totalItemCount),
tracks: data.tracks.totalItemCount,
albums: data.albumsTotal,
playlists: playlistMatches.length,
users: data.usersTotal
@@ -106,7 +98,7 @@
type TopResult = NonNullable<
ReturnType<
typeof findTopResult<
LocalTrack,
Track,
(typeof albums)[number],
(typeof playlistMatches)[number],
(typeof users)[number]
@@ -128,7 +120,7 @@
function playTopResult() {
if (topResult?.kind !== 'track') return;
player.playOrToggle([toQueueItem(topResult.track)], 0);
player.playOrToggle([topResult.track], 0);
}
const topResultHref = $derived.by(() => {
@@ -139,11 +131,7 @@
});
function togglePlay(index: number) {
player.playOrToggle(items.map(toQueueItem), index);
}
function openContextMenu(event: MouseEvent, track: LocalTrack) {
trackMenu.open(event, track);
player.playOrToggle(items, index);
}
function openAlbumMenu(event: MouseEvent, albumId: string) {
@@ -154,14 +142,14 @@
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumQueueItems(albumId));
player.playNext(await fetchAlbumTracks(albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumQueueItems(albumId));
player.appendToQueue(await fetchAlbumTracks(albumId));
}
async function loadMore() {
@@ -177,12 +165,8 @@
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(
localItems,
next.items.map((item) => item.track),
(track) => track.id
);
localPage = Number(next.pageNumber);
localItems = appendUnique(localItems, next.items, (track) => track.id);
localPage = next.pageNumber;
hasMore = next.hasNextPage;
} catch {
hasMore = false;
@@ -340,30 +324,30 @@
<div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'Canciones')}
<div>
{#if songRows.length > 0}
{#if items.length > 0}
<SectionHeading title="Canciones" count={counts.Canciones} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)}
{#each capped(sfilter, items) as track, i (track.id)}
<li>
<ListRow
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
active={player.current.id === track.id}
explicit={track.explicit}
active={player.currentId === track.id}
size="lg"
trackId={track.id}
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, track)}
oncontextmenu={(e) => trackMenu.open(e, track)}
>
{#snippet overlay()}
{#if player.current.id === track.id}
{#if player.currentId === track.id}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
@@ -400,7 +384,7 @@
>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{plural(Number(album.trackCount), 'canción', 'canciones')}
{plural(album.trackCount, 'canción', 'canciones')}
</span>
{/snippet}
</ListRow>
@@ -459,7 +443,7 @@
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
@@ -1,5 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers';
import {
createApiClient,
requireAccessTokenAction,
@@ -17,7 +18,9 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
});
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
return {
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
};
};
export const actions: Actions = {
@@ -2,7 +2,7 @@
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems } from '$lib/player/player.svelte';
import { player } from '$lib/player/player.svelte';
import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
@@ -14,13 +14,12 @@
let { data } = $props();
const items = $derived(data.tracks.items);
const total = $derived(Number(data.tracks.totalItemCount));
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
const total = $derived(data.tracks.totalItemCount);
let deleting = $state<(typeof items)[number] | null>(null);
function togglePlay(index: number) {
player.playOrToggle(toQueueItems(items), index);
player.playOrToggle(items, index);
}
</script>
@@ -46,7 +45,7 @@
{#if items.length > 0}
<TrackList
tracks={listTracks}
tracks={items}
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{
@@ -11,7 +11,7 @@
import TrackList from '$lib/components/ui/media/TrackList.svelte';
const tracks = $derived(liked.list);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt })));
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playFrom(index: number) {
@@ -55,7 +55,7 @@
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
onclick: (track) => liked.toggle(track),
icon: X,
label: 'Quitar de Me gusta'
}}
@@ -1,5 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { toMix } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
@@ -8,7 +9,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
return { mix: unwrapOrError(mixRes, 'Mezcla no encontrada.', 404) };
return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) };
};
export const actions: Actions = {
@@ -1,12 +1,6 @@
<script lang="ts">
import Shuffle from '@lucide/svelte/icons/shuffle';
import {
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem
} from '$lib/player/player.svelte';
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import { fmtTime, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
@@ -18,33 +12,19 @@
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 { mixItemTrack } from '$lib/data/mixes';
let { data, form } = $props();
const mix = $derived(data.mix);
const items = $derived(mix.items);
const tracks = $derived(items.map(mixItemTrack));
const queue = $derived(tracks.map(toQueueItem));
const listTracks = $derived(
items.map((item) => ({
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
}))
);
const tracks = $derived(mix.tracks);
const totalSeconds = $derived(
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
);
const isCurrentQueue = $derived(isQueueCurrent(queue));
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
const trackMenu = createTrackMenu();
function playFrom(index: number) {
player.playOrToggle(queue, index);
player.playOrToggle(tracks, index);
}
</script>
@@ -60,18 +40,18 @@
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
>
{#snippet cover()}
<Artwork
trackIds={items.map((item) => item.trackId)}
trackIds={tracks.map((track) => track.id)}
size="hero"
alt={mix.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
<Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}>
{#if isCurrentQueue && player.playing}
Pausar
{:else}
@@ -81,8 +61,8 @@
<Button
variant="secondary"
size="sm"
onclick={() => playShuffled(queue)}
disabled={queue.length === 0}
onclick={() => playShuffled(tracks)}
disabled={tracks.length === 0}
>
<Shuffle class="size-4" />
Aleatorio
@@ -95,7 +75,7 @@
{/if}
<TrackList
tracks={listTracks}
{tracks}
columns={['plays']}
onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
@@ -113,7 +93,7 @@
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
@@ -6,6 +6,7 @@ import {
requireUser,
unwrapOrError
} from '$lib/server/api';
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
@@ -25,24 +26,21 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
.GET('/playlists/{playlistId}/tracks', {
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
})
.then((res) => res.data?.items ?? [])
.then((res) => (res.data?.items ?? []).map(toTrack))
)
);
const items = data.items.map((playlist, i) => {
const tracks = tracksByPlaylist[i];
const trackCount = tracks.length;
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
return { ...playlist, trackCount, durationSeconds };
});
const items = data.items.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
);
const totals = {
playlistCount: items.length,
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
};
return { playlists: { ...data, items }, totals };
return { playlists: items, totals };
};
export const actions: Actions = {
@@ -10,7 +10,7 @@
let { data } = $props();
const items = $derived(data.playlists.items);
const items = $derived(data.playlists);
const totals = $derived(data.totals);
const summary = $derived(
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
@@ -40,7 +40,7 @@
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
subtitle={playlistMeta(playlist.trackCount)}
trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
@@ -7,6 +7,7 @@ import {
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
@@ -23,8 +24,8 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
})
]);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack);
const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
@@ -1,7 +1,7 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
@@ -19,7 +19,6 @@
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);
let confirmingDelete = $state(false);
@@ -27,11 +26,11 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(toQueueItems(tracks));
playAllOrToggle(tracks);
}
function playFrom(index: number) {
player.playOrToggle(toQueueItems(tracks), index);
player.playOrToggle(tracks, index);
}
</script>
@@ -85,7 +84,7 @@
{#if tracks.length > 0}
<TrackList
tracks={listTracks}
{tracks}
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={isOwner
@@ -1,5 +1,6 @@
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylistSummary } from '$lib/server/mappers';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
@@ -32,14 +33,19 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
)
);
const playlists = playlistItems.map((playlist, i) => ({
...playlist,
trackCount: tracksByPlaylist[i]
}));
const playlists = playlistItems.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i])
);
const isOwnProfile = viewer !== null && viewer.sub === params.id;
return { profile, playlists, isOwnProfile, isAnonymous: viewer === null };
return {
profile,
followersCount: Number(profile.followersCount),
playlists,
isOwnProfile,
isAnonymous: viewer === null
};
};
export const actions: Actions = {
@@ -26,7 +26,7 @@
{/snippet}
{#snippet followStats()}
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
{@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
{#if data.isOwnProfile || profile.canViewFollowers}
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
{:else}
@@ -60,7 +60,7 @@
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(Number(playlist.trackCount))}
subtitle={playlistMeta(playlist.trackCount)}
trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
@@ -19,7 +20,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
return {
list: params.list,
profile,
users: forbidden ? null : unwrapOrError(listRes, 'No se pudo cargar la lista.'),
users: forbidden
? null
: toPage(unwrapOrError(listRes, 'No se pudo cargar la lista.'), (item) => item),
forbidden,
viewerId: viewer?.sub ?? null
};
@@ -20,13 +20,13 @@
$effect(() => {
items = data.users?.items ?? [];
page = Number(data.users?.pageNumber ?? 1);
page = data.users?.pageNumber ?? 1;
hasMore = data.users?.hasNextPage ?? false;
});
const isFollowers = $derived(data.list === 'followers');
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
const total = $derived(Number(data.users?.totalItemCount ?? 0));
const total = $derived(data.users?.totalItemCount ?? 0);
async function loadMore() {
if (loadingMore) return;
@@ -36,7 +36,7 @@
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as NonNullable<typeof data.users>;
items = appendUnique(items, next.items, (u) => u.id);
page = Number(next.pageNumber);
page = next.pageNumber;
hasMore = next.hasNextPage;
} catch {
hasMore = false;
+4 -3
View File
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
import type { LayoutServerLoad } from './$types';
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
import { createApiClient } from '$lib/server/api';
import { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
import { authConfig } from '$lib/server/config';
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
@@ -20,7 +21,7 @@ async function fetchUserPlaylists(
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
});
return { items: data?.items ?? [], total: Number(data?.totalItemCount ?? 0) };
return { items: (data?.items ?? []).map(toPlaylist), total: Number(data?.totalItemCount ?? 0) };
} catch {
return { items: [], total: 0 };
}
@@ -32,7 +33,7 @@ async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | nul
const { data } = await api.GET('/likes', {
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
});
return data?.items ?? [];
return (data?.items ?? []).map(toLikedTrack);
} catch {
return [];
}
@@ -48,7 +49,7 @@ async function fetchLastPlayedTrack(
const { data } = await api.GET('/users/{id}/last-listened-track', {
params: { path: { id: userId } }
});
return data ?? null;
return data ? toTrack(data) : null;
} catch {
return null;
}
+12 -18
View File
@@ -1,13 +1,9 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { createApiClient, requireUser } from '$lib/server/api';
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
import { addTrackAction } from '$lib/server/playlistActions';
import {
HOME_LATEST_PAGE_SIZE,
HOME_MIXES_LIMIT,
HOME_SHELF_LIMIT,
HOME_SPOTLIGHT_TRACKS_LIMIT
} from '$lib/config';
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
import { pickGreeting } from '$lib/server/greeting';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
@@ -15,9 +11,6 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const latestPromise = api.GET('/tracks', {
params: { query: { pageNumber: 1, pageSize: HOME_LATEST_PAGE_SIZE } }
});
const playlistsPromise = api.GET('/playlists/users/{userId}', {
params: {
path: { userId: user.sub },
@@ -30,17 +23,19 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
});
const listeningStatsPromise = api
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
.then((res) => res.data ?? { tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 })
.then((res) => ({
tracksThisWeek: Number(res.data?.tracksThisWeek ?? 0),
secondsThisWeek: Number(res.data?.secondsThisWeek ?? 0),
streakDays: Number(res.data?.streakDays ?? 0)
}))
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
latestPromise,
const [playlists, mixes, recentlyPlayed] = await Promise.all([
playlistsPromise,
mixesPromise,
recentlyPlayedPromise
]);
const playlistItems = playlists.data?.items ?? [];
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
const spotlightPlaylist = playlistItems[0] ?? null;
const spotlightTracksPromise = spotlightPlaylist
@@ -52,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
}
})
.then((res) => ({
items: res.data?.items ?? [],
items: (res.data?.items ?? []).map(toTrack),
totalCount: Number(res.data?.totalItemCount ?? 0)
}))
.catch(() => ({ items: [], totalCount: 0 }))
@@ -60,11 +55,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
return {
greeting: pickGreeting(new Date()),
recentlyPlayed: recentlyPlayed.data ?? [],
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
newReleases: latestItems,
spotlightPlaylist,
spotlightTracks: spotlightTracksPromise,
listeningStats: listeningStatsPromise
+19 -38
View File
@@ -3,20 +3,10 @@
import History from '@lucide/svelte/icons/history';
import Shuffle from '@lucide/svelte/icons/shuffle';
import ListMusic from '@lucide/svelte/icons/list-music';
import {
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem,
toQueueItems,
trackFromQueueItem,
type ApiTrackLike,
type QueueItem
} from '$lib/player/player.svelte';
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
import { mixItemTrack } from '$lib/data/mixes';
import { fetchPlaylistQueueItems } from '$lib/data/albums';
import { fetchPlaylistTracks } 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';
@@ -43,32 +33,25 @@
function playContinue(index: number) {
player.playOrToggle(continueItems, index);
}
function playSpotlightTrack(items: QueueItem[], index: number) {
function playSpotlightTrack(items: Track[], index: number) {
player.playOrToggle(items, index);
}
async function playSpotlight(previewItems: QueueItem[]) {
async function playSpotlight(previewItems: Track[]) {
if (!spotlight) return;
if (isQueueCurrent(previewItems)) {
player.toggle();
return;
}
playAllOrToggle(await fetchPlaylistQueueItems(spotlight.id));
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
}
async function shuffleSpotlight() {
if (!spotlight) return;
playShuffled(await fetchPlaylistQueueItems(spotlight.id));
}
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
trackMenu.open(event, track);
playShuffled(await fetchPlaylistTracks(spotlight.id));
}
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
event.preventDefault();
player.playQueue(
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
0
);
player.playQueue(mix.tracks, 0);
}
</script>
@@ -105,8 +88,7 @@
</p>
<div class="mt-6.5 flex flex-wrap items-center gap-2.5">
{#if spotlight}
{#await data.spotlightTracks then { items }}
{@const previewItems = toQueueItems(items)}
{#await data.spotlightTracks then { items: previewItems }}
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
{spotlight.name}
@@ -121,7 +103,7 @@
{@const statsList = [
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
{
value: fmtDurationLong(Number(stats.secondsThisWeek)),
value: fmtDurationLong(stats.secondsThisWeek),
label: 'Tiempo de escucha esta semana'
},
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
@@ -144,10 +126,10 @@
{#if continueItems.length > 0}
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
{@const isCurrent = player.currentId === item.id}
<ListRow
onclick={() => playContinue(i)}
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
oncontextmenu={(e) => trackMenu.open(e, item)}
active={isCurrent}
size="lg"
variant="card"
@@ -168,8 +150,7 @@
{#if spotlight}
<section>
{#await data.spotlightTracks then { items, totalCount }}
{@const previewItems = toQueueItems(items)}
{#await data.spotlightTracks then { items: previewItems, totalCount }}
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
{@const meta =
totalCount === 0
@@ -211,15 +192,15 @@
</div>
</div>
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
{#each items as track, i (track.id)}
{#each previewItems as track, i (track.id)}
<ListRow
onclick={() => playSpotlightTrack(previewItems, i)}
active={player.current.id === track.id}
active={player.currentId === track.id}
size="sm"
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.isExplicit}
explicit={track.explicit}
>
{#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
@@ -228,7 +209,7 @@
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(Number(track.duration))}</span
>{fmtTime(track.duration)}</span
>
{/snippet}
</ListRow>
@@ -250,7 +231,7 @@
href="/mixes/{mix.id}"
title={mix.title}
subtitle={mix.subtitle}
trackIds={mix.items.map((item) => item.trackId)}
trackIds={mix.tracks.map((track) => track.id)}
onPlay={(event) => playMix(mix, event)}
index={i}
/>
@@ -314,7 +295,7 @@
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: String(trackMenu.state.track.id) },
fields: { trackId: trackMenu.state.track.id },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
@@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => {
}
});
return json(unwrapOrError(result, 'No se pudo cargar el álbum.'));
return json(toPage(unwrapOrError(result, 'No se pudo cargar el álbum.'), toTrack));
};
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
@@ -12,5 +13,5 @@ export const GET: RequestHandler = async ({ params, locals, fetch }) => {
}
});
return json(unwrapOrError(result, 'No se pudo cargar la playlist.'));
return json(toPage(unwrapOrError(result, 'No se pudo cargar la playlist.'), toTrack));
};
+6 -1
View File
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { toPage, toTrack } from '$lib/server/mappers';
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
@@ -17,5 +18,9 @@ export const GET: RequestHandler = async ({ url, locals, fetch }) => {
params: { query: { name, genre, pageNumber, pageSize } }
});
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
return json(
toPage(unwrapOrError(result, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track)
)
);
};
@@ -1,6 +1,7 @@
import type { RequestHandler } from './$types';
import { json } from '@sveltejs/kit';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { toPage } from '$lib/server/mappers';
import { fetchFollowList } from '$lib/server/follows';
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
@@ -15,5 +16,10 @@ export const GET: RequestHandler = async ({ params, url, locals, fetch }) => {
FOLLOW_LIST_PAGE_SIZE
);
return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status));
return json(
toPage(
unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status),
(user) => user
)
);
};