web-player: normalize tracks, albums and playlists into single domain types at the server boundary
This commit is contained in:
@@ -12,7 +12,7 @@
|
|||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
|
|
||||||
function seek(value: number) {
|
function seek(value: number) {
|
||||||
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
|
if (player.duration > 0) player.seekFraction(value / player.duration);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -39,13 +39,13 @@
|
|||||||
</span>
|
</span>
|
||||||
<Slider
|
<Slider
|
||||||
value={player.progress}
|
value={player.progress}
|
||||||
max={player.current.duration}
|
max={player.duration}
|
||||||
label="Barra de progreso"
|
label="Barra de progreso"
|
||||||
oninput={seek}
|
oninput={seek}
|
||||||
variant="seek"
|
variant="seek"
|
||||||
/>
|
/>
|
||||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(player.current.duration)}
|
{fmtTime(player.duration)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if player.current.id}
|
{#if player.current}
|
||||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||||
<div class="mb-6 surface-active rounded-control p-2.5">
|
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||||
<MediaIdentity
|
<MediaIdentity
|
||||||
|
|||||||
@@ -13,27 +13,20 @@
|
|||||||
|
|
||||||
let { compact = false }: Props = $props();
|
let { compact = false }: Props = $props();
|
||||||
|
|
||||||
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
|
const current = $derived(player.current);
|
||||||
|
const isLiked = $derived(current !== null && liked.isLiked(current.id));
|
||||||
|
|
||||||
function toggleLike() {
|
function toggleLike() {
|
||||||
if (!player.current.id) return;
|
if (current) liked.toggle(current);
|
||||||
liked.toggle({
|
|
||||||
id: player.current.id,
|
|
||||||
title: player.current.title,
|
|
||||||
artist: player.current.artist,
|
|
||||||
explicit: player.current.explicit,
|
|
||||||
ownerUserId: player.current.ownerUserId,
|
|
||||||
duration: player.current.duration
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||||
{#if player.current.id}
|
{#if current}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={[player.current.id]}
|
trackIds={[current.id]}
|
||||||
size="sm"
|
size="sm"
|
||||||
alt={player.current.title}
|
alt={current.title}
|
||||||
class="shrink-0 md:size-cover-md"
|
class="shrink-0 md:size-cover-md"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -41,14 +34,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="flex min-w-0 items-center gap-1.5">
|
<div class="flex min-w-0 items-center gap-1.5">
|
||||||
{#if player.current.explicit}
|
{#if current?.explicit}
|
||||||
<ExplicitBadge />
|
<ExplicitBadge />
|
||||||
{/if}
|
{/if}
|
||||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<ArtistLink
|
<ArtistLink
|
||||||
name={player.current.artist || '—'}
|
name={current?.artist || '—'}
|
||||||
ownerUserId={player.current.ownerUserId}
|
ownerUserId={current?.ownerUserId}
|
||||||
class="mt-0.5 text-xs text-fg-2"
|
class="mt-0.5 text-xs text-fg-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||||
|
|
||||||
export interface TrackListTrack {
|
export type TrackListTrack = Track & { date?: string | number };
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string | null;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
isExplicit?: boolean;
|
|
||||||
explicit?: boolean;
|
|
||||||
duration?: number | string;
|
|
||||||
listensCount?: number | string;
|
|
||||||
addedAt?: string | number;
|
|
||||||
uploadedAt?: string | number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||||
added: { label: 'Añadida', width: '124px' },
|
added: { label: 'Añadida', width: '124px' },
|
||||||
@@ -108,7 +99,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||||
{#each tracks as track, i (track.id)}
|
{#each tracks as track, i (track.id)}
|
||||||
{@const active = player.current.id === track.id}
|
{@const active = player.currentId === track.id}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -145,7 +136,7 @@
|
|||||||
title={track.title}
|
title={track.title}
|
||||||
artist={track.artist}
|
artist={track.artist}
|
||||||
ownerUserId={track.ownerUserId}
|
ownerUserId={track.ownerUserId}
|
||||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
explicit={track.explicit}
|
||||||
{active}
|
{active}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
@@ -158,16 +149,14 @@
|
|||||||
{#each columns as column (column)}
|
{#each columns as column (column)}
|
||||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||||
{#if column === 'plays'}
|
{#if column === 'plays'}
|
||||||
{Number(track.listensCount ?? 0)}
|
{track.listensCount}
|
||||||
{:else if column === 'added' && track.addedAt}
|
{:else if track.date}
|
||||||
{fmtDate(track.addedAt)}
|
{fmtDate(track.date)}
|
||||||
{:else if column === 'uploaded' && track.uploadedAt}
|
|
||||||
{fmtDate(track.uploadedAt)}
|
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||||
{fmtTime(Number(track.duration))}
|
{fmtTime(track.duration)}
|
||||||
</span>
|
</span>
|
||||||
{#if rowAction}
|
{#if rowAction}
|
||||||
{#if rowAction.action}
|
{#if rowAction.action}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ 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;
|
||||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
|
||||||
export const HOME_MIXES_LIMIT = 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;
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
import type { Paged, Track } from '$lib/types';
|
||||||
|
|
||||||
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
|
async function fetchTracks(url: string): Promise<Track[]> {
|
||||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
const res = await fetch(url);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
return ((await res.json()) as Paged<Track>).items;
|
||||||
return toQueueItems(data.items ?? []);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchPlaylistQueueItems(playlistId: string): Promise<QueueItem[]> {
|
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
|
||||||
const res = await fetch(`/api/playlists/${playlistId}/tracks`);
|
|
||||||
if (!res.ok) return [];
|
export const fetchPlaylistTracks = (playlistId: string) =>
|
||||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
fetchTracks(`/api/playlists/${playlistId}/tracks`);
|
||||||
return toQueueItems(data.items ?? []);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { components } from '$lib/api/schema';
|
|
||||||
import type { ApiTrackLike } from '$lib/player/player.svelte';
|
|
||||||
|
|
||||||
export type Mix = components['schemas']['MixApplicationResponse'];
|
|
||||||
export type MixItem = components['schemas']['MixItemApplicationResponse'];
|
|
||||||
|
|
||||||
export function mixItemKey(item: MixItem): string {
|
|
||||||
return `mf:${item.trackId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mixItemTrack(item: MixItem): ApiTrackLike {
|
|
||||||
return {
|
|
||||||
id: item.trackId,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
duration: item.durationSeconds,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,14 @@
|
|||||||
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
|
export function mergeRecentlyPlayed(history: Track[], limit: number): Track[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const merged: QueueItem[] = [];
|
const merged: Track[] = [];
|
||||||
|
|
||||||
for (const t of player.recentlyPlayed) {
|
for (const track of [...player.recentlyPlayed, ...history]) {
|
||||||
const id = String(t.id);
|
if (seen.has(track.id)) continue;
|
||||||
if (seen.has(id)) continue;
|
seen.add(track.id);
|
||||||
seen.add(id);
|
merged.push(track);
|
||||||
merged.push({ ...t, id });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of toQueueItems(history)) {
|
|
||||||
const id = String(item.id);
|
|
||||||
if (seen.has(id)) continue;
|
|
||||||
seen.add(id);
|
|
||||||
merged.push({ ...item, id });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged.slice(0, limit);
|
return merged.slice(0, limit);
|
||||||
|
|||||||
@@ -1,24 +1,4 @@
|
|||||||
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
import type { LikedTrack, Track } from '$lib/types';
|
||||||
|
|
||||||
export interface LikedTrack {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
duration?: number | string;
|
|
||||||
likedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type LikeToggleInput = {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
duration?: number | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
class LikedStore {
|
class LikedStore {
|
||||||
entries = $state<Record<string, LikedTrack>>({});
|
entries = $state<Record<string, LikedTrack>>({});
|
||||||
@@ -27,35 +7,23 @@ class LikedStore {
|
|||||||
count = $derived(Object.keys(this.entries).length);
|
count = $derived(Object.keys(this.entries).length);
|
||||||
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
|
||||||
|
|
||||||
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
|
hydrate(tracks: LikedTrack[]) {
|
||||||
if (this.hydrated) return;
|
if (this.hydrated) return;
|
||||||
this.hydrated = true;
|
this.hydrated = true;
|
||||||
|
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
|
||||||
const items = toQueueItems(tracks);
|
|
||||||
const next: Record<string, LikedTrack> = {};
|
|
||||||
items.forEach((item, i) => {
|
|
||||||
const id = String(item.id);
|
|
||||||
next[id] = {
|
|
||||||
...item,
|
|
||||||
id,
|
|
||||||
duration: tracks[i].duration,
|
|
||||||
likedAt: new Date(tracks[i].createdAt).getTime()
|
|
||||||
};
|
|
||||||
});
|
|
||||||
this.entries = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLiked(id: string | number): boolean {
|
isLiked(id: string): boolean {
|
||||||
return String(id) in this.entries;
|
return id in this.entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggle(track: LikeToggleInput) {
|
async toggle(track: Track) {
|
||||||
const id = String(track.id);
|
const id = track.id;
|
||||||
const before = this.entries;
|
const before = this.entries;
|
||||||
const wasLiked = id in before;
|
const wasLiked = id in before;
|
||||||
const next = { ...before };
|
const next = { ...before };
|
||||||
if (wasLiked) delete next[id];
|
if (wasLiked) delete next[id];
|
||||||
else next[id] = { ...track, id, likedAt: Date.now() };
|
else next[id] = { ...track, likedAt: Date.now() };
|
||||||
this.entries = next;
|
this.entries = next;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,77 +5,20 @@ import { shuffle } from '$lib/data/collections';
|
|||||||
import { dialog } from '$lib/state/dialog.svelte';
|
import { dialog } from '$lib/state/dialog.svelte';
|
||||||
import { ListenTracker } from './listenTracker';
|
import { ListenTracker } from './listenTracker';
|
||||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||||
|
|
||||||
export interface PlayerTrack {
|
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||||
id: string | number;
|
return items.some((item) => item.id === player.currentId);
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
duration: number;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueueItem {
|
export function playAllOrToggle(items: Track[]) {
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string;
|
|
||||||
duration?: number | string;
|
|
||||||
explicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiTrackLike {
|
|
||||||
id: string | number;
|
|
||||||
title: string;
|
|
||||||
artist?: string | null;
|
|
||||||
duration?: number | string;
|
|
||||||
isExplicit?: boolean;
|
|
||||||
ownerUserId?: string | number | null;
|
|
||||||
listensCount?: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
|
|
||||||
return tracks.map((track) => ({
|
|
||||||
id: track.id,
|
|
||||||
title: track.title,
|
|
||||||
artist: track.artist ?? undefined,
|
|
||||||
duration: track.duration,
|
|
||||||
explicit: track.isExplicit,
|
|
||||||
ownerUserId: track.ownerUserId,
|
|
||||||
listensCount: track.listensCount
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toQueueItem(track: ApiTrackLike): QueueItem {
|
|
||||||
return toQueueItems([track])[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist,
|
|
||||||
duration: item.duration,
|
|
||||||
isExplicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isQueueCurrent(items: { id: string | number }[]): boolean {
|
|
||||||
return items.some((item) => item.id === player.current.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function playAllOrToggle(items: QueueItem[]) {
|
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (isQueueCurrent(items)) player.toggle();
|
if (isQueueCurrent(items)) player.toggle();
|
||||||
else player.playQueue(items, 0);
|
else player.playQueue(items, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function playShuffled(items: QueueItem[]) {
|
export function playShuffled(items: Track[]) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
player.playQueue(shuffle(items), 0);
|
player.playQueue(shuffle(items), 0);
|
||||||
}
|
}
|
||||||
@@ -105,34 +48,15 @@ function loadVolume(): number {
|
|||||||
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
|
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: PlayerTrack = {
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
artist: '',
|
|
||||||
duration: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
function toTrack(item: QueueItem): PlayerTrack {
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
title: item.title,
|
|
||||||
artist: item.artist ?? '',
|
|
||||||
duration: Number(item.duration) || 0,
|
|
||||||
explicit: item.explicit,
|
|
||||||
ownerUserId: item.ownerUserId,
|
|
||||||
listensCount: item.listensCount
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class PlayerState {
|
class PlayerState {
|
||||||
tracks = $state<PlayerTrack[]>([]);
|
tracks = $state<Track[]>([]);
|
||||||
currentId = $state<string | number | null>(null);
|
currentId = $state<string | null>(null);
|
||||||
playing = $state(false);
|
playing = $state(false);
|
||||||
progress = $state(0);
|
progress = $state(0);
|
||||||
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
|
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
|
||||||
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
|
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
|
||||||
loading = $state(false);
|
loading = $state(false);
|
||||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
recentlyPlayed = $state<Track[]>([]);
|
||||||
shuffle = $state(false);
|
shuffle = $state(false);
|
||||||
repeat = $state(false);
|
repeat = $state(false);
|
||||||
|
|
||||||
@@ -144,10 +68,11 @@ class PlayerState {
|
|||||||
#rafId: number | null = null;
|
#rafId: number | null = null;
|
||||||
#listen = new ListenTracker();
|
#listen = new ListenTracker();
|
||||||
|
|
||||||
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
|
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
|
||||||
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
|
||||||
|
duration = $derived(this.current?.duration ?? 0);
|
||||||
progressPercent = $derived(
|
progressPercent = $derived(
|
||||||
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
|
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
#audioEl(): HTMLAudioElement | null {
|
#audioEl(): HTMLAudioElement | null {
|
||||||
@@ -228,15 +153,11 @@ class PlayerState {
|
|||||||
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
||||||
}
|
}
|
||||||
|
|
||||||
#syncMediaSessionMetadata(track: PlayerTrack) {
|
#syncMediaSessionMetadata(track: Track) {
|
||||||
if (!browser || !('mediaSession' in navigator)) return;
|
if (!browser || !('mediaSession' in navigator)) return;
|
||||||
if (!track.id) {
|
|
||||||
navigator.mediaSession.metadata = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigator.mediaSession.metadata = new MediaMetadata({
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist ?? '',
|
||||||
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -278,17 +199,15 @@ class PlayerState {
|
|||||||
return this.tracks.findIndex((t) => t.id === this.currentId);
|
return this.tracks.findIndex((t) => t.id === this.currentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
#pushRecent(track: PlayerTrack) {
|
#pushRecent(track: Track) {
|
||||||
if (!track.id) return;
|
|
||||||
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
||||||
0,
|
0,
|
||||||
RECENT_LIMIT
|
RECENT_LIMIT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async #applyAccent(id: string | number) {
|
async #applyAccent(id: string) {
|
||||||
const key = String(id);
|
const cached = this.#accentCache.get(id);
|
||||||
const cached = this.#accentCache.get(key);
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
this.accentColor = cached;
|
this.accentColor = cached;
|
||||||
return;
|
return;
|
||||||
@@ -296,7 +215,7 @@ class PlayerState {
|
|||||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||||
if (this.currentId !== id) return;
|
if (this.currentId !== id) return;
|
||||||
if (result) {
|
if (result) {
|
||||||
this.#accentCache.set(key, result);
|
this.#accentCache.set(id, result);
|
||||||
this.accentColor = result;
|
this.accentColor = result;
|
||||||
} else {
|
} else {
|
||||||
this.accentColor = null;
|
this.accentColor = null;
|
||||||
@@ -322,7 +241,7 @@ class PlayerState {
|
|||||||
this.#listen.begin(listenId);
|
this.#listen.begin(listenId);
|
||||||
await audio.play();
|
await audio.play();
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.#pushRecent(this.current);
|
if (track) this.#pushRecent(track);
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
console.error('playback failed', exception);
|
console.error('playback failed', exception);
|
||||||
if (token !== this.#loadToken) return;
|
if (token !== this.#loadToken) return;
|
||||||
@@ -375,60 +294,44 @@ class PlayerState {
|
|||||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
hydrate(item: ApiTrackLike) {
|
hydrate(track: Track) {
|
||||||
if (this.currentId !== null || this.tracks.length > 0) return;
|
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||||
const track = toTrack(toQueueItem(item));
|
|
||||||
this.tracks = [track];
|
this.tracks = [track];
|
||||||
this.currentId = track.id;
|
this.currentId = track.id;
|
||||||
this.#applyAccent(track.id);
|
this.#applyAccent(track.id);
|
||||||
this.#syncMediaSessionMetadata(track);
|
this.#syncMediaSessionMetadata(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
playQueue(list: QueueItem[], startIndex = 0) {
|
playQueue(list: Track[], startIndex = 0) {
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
this.tracks = list.map(toTrack);
|
this.tracks = [...list];
|
||||||
const start = this.tracks[startIndex] ?? this.tracks[0];
|
const start = this.tracks[startIndex] ?? this.tracks[0];
|
||||||
this.currentId = start.id;
|
this.currentId = start.id;
|
||||||
this.#loadCurrent();
|
this.#loadCurrent();
|
||||||
}
|
}
|
||||||
|
|
||||||
playOrToggle(list: QueueItem[], index: number) {
|
playOrToggle(list: Track[], index: number) {
|
||||||
if (index < 0 || index >= list.length) return;
|
if (index < 0 || index >= list.length) return;
|
||||||
if (this.currentId === list[index].id) this.toggle();
|
if (this.currentId === list[index].id) this.toggle();
|
||||||
else this.playQueue(list, index);
|
else this.playQueue(list, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
playNext(items: QueueItem[]) {
|
playNext(items: Track[]) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (this.currentId === null) {
|
if (this.currentId === null) {
|
||||||
this.playQueue(items, 0);
|
this.playQueue(items, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.tracks = insertNext(this.tracks, this.#index(), items.map(toTrack));
|
this.tracks = insertNext(this.tracks, this.#index(), items);
|
||||||
}
|
}
|
||||||
|
|
||||||
appendToQueue(items: QueueItem[]) {
|
appendToQueue(items: Track[]) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
if (this.currentId === null) {
|
if (this.currentId === null) {
|
||||||
this.playQueue(items, 0);
|
this.playQueue(items, 0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.tracks = append(this.tracks, items.map(toTrack));
|
this.tracks = append(this.tracks, items);
|
||||||
}
|
|
||||||
|
|
||||||
playTrack(id: string | number) {
|
|
||||||
if (this.currentId === id) {
|
|
||||||
this.toggle();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const known =
|
|
||||||
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
|
|
||||||
if (known && !this.tracks.some((t) => t.id === id)) {
|
|
||||||
this.tracks = [known];
|
|
||||||
}
|
|
||||||
if (!this.tracks.some((t) => t.id === id)) return;
|
|
||||||
this.currentId = id;
|
|
||||||
this.#loadCurrent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
@@ -485,7 +388,7 @@ class PlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearUpcoming() {
|
clearUpcoming() {
|
||||||
this.tracks = this.currentId === null ? [] : [this.current];
|
this.tracks = this.current ? [this.current] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
previous() {
|
previous() {
|
||||||
@@ -503,7 +406,7 @@ class PlayerState {
|
|||||||
seekFraction(fraction: number) {
|
seekFraction(fraction: number) {
|
||||||
const audio = this.#audioEl();
|
const audio = this.#audioEl();
|
||||||
const clamped = Math.min(1, Math.max(0, fraction));
|
const clamped = Math.min(1, Math.max(0, fraction));
|
||||||
const target = clamped * this.current.duration;
|
const target = clamped * this.duration;
|
||||||
this.progress = target;
|
this.progress = target;
|
||||||
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
|
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
import {
|
||||||
|
toAlbum,
|
||||||
|
toDatedTrack,
|
||||||
|
toLikedTrack,
|
||||||
|
toMix,
|
||||||
|
toPage,
|
||||||
|
toPlaylist,
|
||||||
|
toPlaylistSummary,
|
||||||
|
toTrack
|
||||||
|
} from './mappers';
|
||||||
|
|
||||||
|
const trackDto: components['schemas']['TrackApplicationResponse'] = {
|
||||||
|
id: 't1',
|
||||||
|
title: 'Song',
|
||||||
|
artist: null,
|
||||||
|
audioStatus: 'Completed',
|
||||||
|
duration: '183.5',
|
||||||
|
listensCount: '12',
|
||||||
|
createdAt: '2026-01-02T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-03T00:00:00Z',
|
||||||
|
ownerUserId: '42',
|
||||||
|
tags: [],
|
||||||
|
isExplicit: true
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('toTrack', () => {
|
||||||
|
it('normalizes numeric strings and renames isExplicit', () => {
|
||||||
|
expect(toTrack(trackDto)).toEqual({
|
||||||
|
id: 't1',
|
||||||
|
title: 'Song',
|
||||||
|
artist: null,
|
||||||
|
ownerUserId: '42',
|
||||||
|
explicit: true,
|
||||||
|
duration: 183.5,
|
||||||
|
listensCount: 12
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('falls back to 0 for invalid numbers', () => {
|
||||||
|
const track = toTrack({ ...trackDto, duration: 'x', listensCount: 'y' });
|
||||||
|
expect(track.duration).toBe(0);
|
||||||
|
expect(track.listensCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toDatedTrack and toLikedTrack', () => {
|
||||||
|
it('keep the creation date', () => {
|
||||||
|
expect(toDatedTrack(trackDto).date).toBe('2026-01-02T00:00:00Z');
|
||||||
|
expect(toLikedTrack(trackDto).likedAt).toBe(Date.parse('2026-01-02T00:00:00Z'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toMix', () => {
|
||||||
|
it('maps items into tracks', () => {
|
||||||
|
const mix = toMix({
|
||||||
|
id: 'm1',
|
||||||
|
title: 'Mix',
|
||||||
|
subtitle: null,
|
||||||
|
itemCount: 1,
|
||||||
|
items: [{ trackId: 't1', title: 'Song', artist: 'A', durationSeconds: '60', listensCount: 3 }]
|
||||||
|
});
|
||||||
|
expect(mix.tracks).toEqual([
|
||||||
|
{
|
||||||
|
id: 't1',
|
||||||
|
title: 'Song',
|
||||||
|
artist: 'A',
|
||||||
|
ownerUserId: null,
|
||||||
|
explicit: false,
|
||||||
|
duration: 60,
|
||||||
|
listensCount: 3
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toAlbum', () => {
|
||||||
|
const dto: components['schemas']['AlbumApplicationResponse'] = {
|
||||||
|
id: 'a1',
|
||||||
|
title: 'Album',
|
||||||
|
description: null,
|
||||||
|
releaseYear: '2020',
|
||||||
|
ownerUserId: '42',
|
||||||
|
trackCount: '5',
|
||||||
|
smallImageKeyName: null,
|
||||||
|
mediumImageKeyName: null,
|
||||||
|
largeImageKeyName: null,
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: 'u',
|
||||||
|
coverTrackIds: ['t1']
|
||||||
|
};
|
||||||
|
it('normalizes year and count', () => {
|
||||||
|
expect(toAlbum(dto)).toMatchObject({ releaseYear: 2020, trackCount: 5, updatedAt: 'u' });
|
||||||
|
});
|
||||||
|
it('keeps a missing year as null', () => {
|
||||||
|
expect(toAlbum({ ...dto, releaseYear: null }).releaseYear).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPlaylist', () => {
|
||||||
|
const dto: components['schemas']['PlayListApplicationResponse'] = {
|
||||||
|
id: 'p1',
|
||||||
|
name: 'List',
|
||||||
|
description: 'd',
|
||||||
|
smallImageKeyName: null,
|
||||||
|
mediumImageKeyName: null,
|
||||||
|
largeImageKeyName: null,
|
||||||
|
ownerUserId: '42',
|
||||||
|
visibility: 'Public',
|
||||||
|
createdAt: '',
|
||||||
|
updatedAt: 'u',
|
||||||
|
coverTrackIds: []
|
||||||
|
};
|
||||||
|
it('drops storage keys', () => {
|
||||||
|
expect(toPlaylist(dto)).toEqual({
|
||||||
|
id: 'p1',
|
||||||
|
name: 'List',
|
||||||
|
description: 'd',
|
||||||
|
visibility: 'Public',
|
||||||
|
ownerUserId: '42',
|
||||||
|
coverTrackIds: [],
|
||||||
|
updatedAt: 'u'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('adds the track count in summaries', () => {
|
||||||
|
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toPage', () => {
|
||||||
|
it('maps items and normalizes numbers', () => {
|
||||||
|
expect(
|
||||||
|
toPage(
|
||||||
|
{ items: [1, 2], pageNumber: '2', hasNextPage: true, totalItemCount: '10' },
|
||||||
|
(n) => n * 2
|
||||||
|
)
|
||||||
|
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { components } from '$lib/api/schema';
|
||||||
|
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types';
|
||||||
|
|
||||||
|
type TrackDto = components['schemas']['TrackApplicationResponse'];
|
||||||
|
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
|
||||||
|
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
|
||||||
|
type MixDto = components['schemas']['MixApplicationResponse'];
|
||||||
|
type MixItemDto = components['schemas']['MixItemApplicationResponse'];
|
||||||
|
|
||||||
|
interface PageDto<T> {
|
||||||
|
items: T[];
|
||||||
|
pageNumber: number | string;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
totalItemCount: number | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toTrack(dto: TrackDto): Track {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
title: dto.title,
|
||||||
|
artist: dto.artist,
|
||||||
|
ownerUserId: dto.ownerUserId,
|
||||||
|
explicit: dto.isExplicit,
|
||||||
|
duration: Number(dto.duration) || 0,
|
||||||
|
listensCount: Number(dto.listensCount) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDatedTrack(dto: TrackDto): Track & { date: string } {
|
||||||
|
return { ...toTrack(dto), date: dto.createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLikedTrack(dto: TrackDto): LikedTrack {
|
||||||
|
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMixTrack(item: MixItemDto): Track {
|
||||||
|
return {
|
||||||
|
id: item.trackId,
|
||||||
|
title: item.title,
|
||||||
|
artist: item.artist,
|
||||||
|
ownerUserId: null,
|
||||||
|
explicit: false,
|
||||||
|
duration: Number(item.durationSeconds) || 0,
|
||||||
|
listensCount: Number(item.listensCount) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toMix(dto: MixDto): Mix {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
title: dto.title,
|
||||||
|
subtitle: dto.subtitle,
|
||||||
|
tracks: dto.items.map(toMixTrack)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAlbum(dto: AlbumDto): Album {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
title: dto.title,
|
||||||
|
description: dto.description,
|
||||||
|
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
|
||||||
|
ownerUserId: dto.ownerUserId,
|
||||||
|
trackCount: Number(dto.trackCount) || 0,
|
||||||
|
coverTrackIds: dto.coverTrackIds,
|
||||||
|
updatedAt: dto.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPlaylist(dto: PlaylistDto): Playlist {
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name,
|
||||||
|
description: dto.description,
|
||||||
|
visibility: dto.visibility,
|
||||||
|
ownerUserId: dto.ownerUserId,
|
||||||
|
coverTrackIds: dto.coverTrackIds,
|
||||||
|
updatedAt: dto.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary {
|
||||||
|
return { ...toPlaylist(dto), trackCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
|
||||||
|
return {
|
||||||
|
items: dto.items.map(map),
|
||||||
|
pageNumber: Number(dto.pageNumber) || 1,
|
||||||
|
hasNextPage: dto.hasNextPage,
|
||||||
|
totalItemCount: Number(dto.totalItemCount) || 0
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,9 +2,10 @@ import {
|
|||||||
contextMenuPosition,
|
contextMenuPosition,
|
||||||
type MenuPosition
|
type MenuPosition
|
||||||
} from '$lib/components/ui/overlay/ContextMenu.svelte';
|
} from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||||
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
|
|
||||||
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
|
export type TrackMenuState = { track: Track } & MenuPosition;
|
||||||
|
|
||||||
export function createTrackMenu() {
|
export function createTrackMenu() {
|
||||||
let state = $state<TrackMenuState | null>(null);
|
let state = $state<TrackMenuState | null>(null);
|
||||||
@@ -13,7 +14,7 @@ export function createTrackMenu() {
|
|||||||
get state() {
|
get state() {
|
||||||
return state;
|
return state;
|
||||||
},
|
},
|
||||||
open(event: MouseEvent, track: ApiTrackLike) {
|
open(event: MouseEvent, track: Track) {
|
||||||
state = { track, ...contextMenuPosition(event) };
|
state = { track, ...contextMenuPosition(event) };
|
||||||
},
|
},
|
||||||
close() {
|
close() {
|
||||||
@@ -21,7 +22,7 @@ export function createTrackMenu() {
|
|||||||
},
|
},
|
||||||
playNext() {
|
playNext() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
player.playNext([toQueueItem(state.track)]);
|
player.playNext([state.track]);
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,3 +6,56 @@ export interface SessionUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FollowList = 'followers' | 'following';
|
export type FollowList = 'followers' | 'following';
|
||||||
|
|
||||||
|
export interface Track {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
artist: string | null;
|
||||||
|
ownerUserId: string | null;
|
||||||
|
explicit: boolean;
|
||||||
|
duration: number;
|
||||||
|
listensCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LikedTrack extends Track {
|
||||||
|
likedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Album {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
releaseYear: number | null;
|
||||||
|
ownerUserId: string;
|
||||||
|
trackCount: number;
|
||||||
|
coverTrackIds: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Playlist {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
visibility: 'Public' | 'Private';
|
||||||
|
ownerUserId: string;
|
||||||
|
coverTrackIds: string[];
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistSummary extends Playlist {
|
||||||
|
trackCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mix {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string | null;
|
||||||
|
tracks: Track[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Paged<T> {
|
||||||
|
items: T[];
|
||||||
|
pageNumber: number;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
totalItemCount: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ export function plural(n: number, one: string, many: string): string {
|
|||||||
return `${n} ${n === 1 ? one : many}`;
|
return `${n} ${n === 1 ? one : many}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fmtPlays(count: number | string | undefined): string {
|
export function fmtPlays(count: number): string {
|
||||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
const value = Math.max(0, Math.floor(count));
|
||||||
return plural(value, 'reproducción', 'reproducciones');
|
return plural(value, 'reproducción', 'reproducciones');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
|
||||||
return [
|
return [
|
||||||
releaseYear ? String(releaseYear) : undefined,
|
releaseYear ? String(releaseYear) : undefined,
|
||||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
requireUser,
|
requireUser,
|
||||||
unwrapOrError
|
unwrapOrError
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { toAlbum, toPage } from '$lib/server/mappers';
|
||||||
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
import { ALBUMS_PAGE_SIZE } from '$lib/config';
|
||||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
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 } }
|
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 };
|
return { albums };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,10 +40,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/albums/{album.id}"
|
href="/albums/{album.id}"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
subtitle={albumMeta(
|
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
|
||||||
Number(album.trackCount)
|
|
||||||
)}
|
|
||||||
trackIds={album.coverTrackIds}
|
trackIds={album.coverTrackIds}
|
||||||
src="/api/albums/{album.id}/cover?size=large"
|
src="/api/albums/{album.id}/cover?size=large"
|
||||||
index={i}
|
index={i}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
unwrapOrError,
|
unwrapOrError,
|
||||||
unwrapOrFail
|
unwrapOrFail
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { toAlbum, toTrack } from '$lib/server/mappers';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
|
||||||
import { parseAlbumForm } from '$lib/server/albumForm';
|
import { parseAlbumForm } from '$lib/server/albumForm';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
@@ -34,11 +35,13 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
: Promise.resolve(null)
|
: 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 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;
|
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 X from '@lucide/svelte/icons/x';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
@@ -28,15 +28,15 @@
|
|||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
playAllOrToggle(toQueueItems(tracks));
|
playAllOrToggle(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playTrackFrom(index: number) {
|
function playTrackFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playLibraryFrom(index: number) {
|
function playLibraryFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(library), index);
|
player.playOrToggle(library, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -55,10 +55,7 @@
|
|||||||
eyebrow="Álbum"
|
eyebrow="Álbum"
|
||||||
title={album.title}
|
title={album.title}
|
||||||
description={album.description ?? undefined}
|
description={album.description ?? undefined}
|
||||||
meta={albumMeta(
|
meta={albumMeta(album.releaseYear, tracks.length)}
|
||||||
album.releaseYear === null ? undefined : Number(album.releaseYear),
|
|
||||||
tracks.length
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
@@ -118,7 +115,7 @@
|
|||||||
action="?/edit"
|
action="?/edit"
|
||||||
initialTitle={album.title}
|
initialTitle={album.title}
|
||||||
initialDescription={album.description ?? ''}
|
initialDescription={album.description ?? ''}
|
||||||
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
|
initialReleaseYear={album.releaseYear ?? undefined}
|
||||||
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
||||||
formMessage={form?.message}
|
formMessage={form?.message}
|
||||||
submitLabel="Guardar"
|
submitLabel="Guardar"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
|
||||||
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
|
||||||
import {
|
import {
|
||||||
EXPLORE_ALBUMS_PAGE_SIZE,
|
EXPLORE_ALBUMS_PAGE_SIZE,
|
||||||
@@ -48,14 +49,16 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
|
|||||||
usersPromise
|
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 {
|
return {
|
||||||
query,
|
query,
|
||||||
genre,
|
genre,
|
||||||
genres,
|
genres,
|
||||||
tracks,
|
tracks,
|
||||||
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
|
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
|
||||||
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
|
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
|
||||||
users: usersRes?.data?.items ?? [],
|
users: usersRes?.data?.items ?? [],
|
||||||
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
|
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Music from '@lucide/svelte/icons/music';
|
import Music from '@lucide/svelte/icons/music';
|
||||||
import { player, toQueueItem } from '$lib/player/player.svelte';
|
import { player } from '$lib/player/player.svelte';
|
||||||
import { fetchAlbumQueueItems } from '$lib/data/albums';
|
import { fetchAlbumTracks } from '$lib/data/albums';
|
||||||
|
import type { Track } from '$lib/types';
|
||||||
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
@@ -49,9 +50,7 @@
|
|||||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||||
let sfilter = $state<SearchFilter>('Todo');
|
let sfilter = $state<SearchFilter>('Todo');
|
||||||
|
|
||||||
type LocalTrack = (typeof data.tracks.items)[number]['track'];
|
let localItems = $state<Track[]>([]);
|
||||||
|
|
||||||
let localItems = $state<LocalTrack[]>([]);
|
|
||||||
let localPage = $state(1);
|
let localPage = $state(1);
|
||||||
let hasMore = $state(false);
|
let hasMore = $state(false);
|
||||||
let loadingMore = $state(false);
|
let loadingMore = $state(false);
|
||||||
@@ -60,19 +59,12 @@
|
|||||||
trackMenu.close();
|
trackMenu.close();
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
sfilter = data.genre ? 'Canciones' : 'Todo';
|
||||||
localItems = data.tracks.items.map((item) => item.track);
|
localItems = data.tracks.items;
|
||||||
localPage = Number(data.tracks.pageNumber);
|
localPage = data.tracks.pageNumber;
|
||||||
hasMore = data.tracks.hasNextPage;
|
hasMore = data.tracks.hasNextPage;
|
||||||
});
|
});
|
||||||
|
|
||||||
const songRows = $derived(
|
const items = $derived(localItems);
|
||||||
localItems.map((track) => ({
|
|
||||||
track,
|
|
||||||
seconds: Number(track.duration)
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
const items = $derived(songRows.map((row) => row.track));
|
|
||||||
|
|
||||||
const tiles = $derived(genreTiles(data.genres));
|
const tiles = $derived(genreTiles(data.genres));
|
||||||
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
|
||||||
@@ -92,7 +84,7 @@
|
|||||||
|
|
||||||
const counts = $derived(
|
const counts = $derived(
|
||||||
searchCounts({
|
searchCounts({
|
||||||
tracks: Number(data.tracks.totalItemCount),
|
tracks: data.tracks.totalItemCount,
|
||||||
albums: data.albumsTotal,
|
albums: data.albumsTotal,
|
||||||
playlists: playlistMatches.length,
|
playlists: playlistMatches.length,
|
||||||
users: data.usersTotal
|
users: data.usersTotal
|
||||||
@@ -106,7 +98,7 @@
|
|||||||
type TopResult = NonNullable<
|
type TopResult = NonNullable<
|
||||||
ReturnType<
|
ReturnType<
|
||||||
typeof findTopResult<
|
typeof findTopResult<
|
||||||
LocalTrack,
|
Track,
|
||||||
(typeof albums)[number],
|
(typeof albums)[number],
|
||||||
(typeof playlistMatches)[number],
|
(typeof playlistMatches)[number],
|
||||||
(typeof users)[number]
|
(typeof users)[number]
|
||||||
@@ -128,7 +120,7 @@
|
|||||||
|
|
||||||
function playTopResult() {
|
function playTopResult() {
|
||||||
if (topResult?.kind !== 'track') return;
|
if (topResult?.kind !== 'track') return;
|
||||||
player.playOrToggle([toQueueItem(topResult.track)], 0);
|
player.playOrToggle([topResult.track], 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topResultHref = $derived.by(() => {
|
const topResultHref = $derived.by(() => {
|
||||||
@@ -139,11 +131,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(items.map(toQueueItem), index);
|
player.playOrToggle(items, index);
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, track: LocalTrack) {
|
|
||||||
trackMenu.open(event, track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||||
@@ -154,14 +142,14 @@
|
|||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.playNext(await fetchAlbumQueueItems(albumId));
|
player.playNext(await fetchAlbumTracks(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function albumAddToQueue() {
|
async function albumAddToQueue() {
|
||||||
if (!albumMenu) return;
|
if (!albumMenu) return;
|
||||||
const { albumId } = albumMenu;
|
const { albumId } = albumMenu;
|
||||||
albumMenu = null;
|
albumMenu = null;
|
||||||
player.appendToQueue(await fetchAlbumQueueItems(albumId));
|
player.appendToQueue(await fetchAlbumTracks(albumId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
@@ -177,12 +165,8 @@
|
|||||||
const res = await fetch(`/api/tracks?${params}`);
|
const res = await fetch(`/api/tracks?${params}`);
|
||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as typeof data.tracks;
|
const next = (await res.json()) as typeof data.tracks;
|
||||||
localItems = appendUnique(
|
localItems = appendUnique(localItems, next.items, (track) => track.id);
|
||||||
localItems,
|
localPage = next.pageNumber;
|
||||||
next.items.map((item) => item.track),
|
|
||||||
(track) => track.id
|
|
||||||
);
|
|
||||||
localPage = Number(next.pageNumber);
|
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
@@ -340,30 +324,30 @@
|
|||||||
<div class="mt-9 flex flex-col gap-9">
|
<div class="mt-9 flex flex-col gap-9">
|
||||||
{#if showGroup(sfilter, 'Canciones')}
|
{#if showGroup(sfilter, 'Canciones')}
|
||||||
<div>
|
<div>
|
||||||
{#if songRows.length > 0}
|
{#if items.length > 0}
|
||||||
<SectionHeading title="Canciones" count={counts.Canciones} />
|
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||||
<ul class="flex flex-col gap-1">
|
<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>
|
<li>
|
||||||
<ListRow
|
<ListRow
|
||||||
title={track.title}
|
title={track.title}
|
||||||
subtitle={track.artist}
|
subtitle={track.artist}
|
||||||
subtitleHref={track.ownerUserId}
|
subtitleHref={track.ownerUserId}
|
||||||
explicit={track.isExplicit}
|
explicit={track.explicit}
|
||||||
active={player.current.id === track.id}
|
active={player.currentId === track.id}
|
||||||
size="lg"
|
size="lg"
|
||||||
trackId={track.id}
|
trackId={track.id}
|
||||||
onclick={() => togglePlay(i)}
|
onclick={() => togglePlay(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, track)}
|
oncontextmenu={(e) => trackMenu.open(e, track)}
|
||||||
>
|
>
|
||||||
{#snippet overlay()}
|
{#snippet overlay()}
|
||||||
{#if player.current.id === track.id}
|
{#if player.currentId === track.id}
|
||||||
<EqBars overlay paused={!player.playing} />
|
<EqBars overlay paused={!player.playing} />
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#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">
|
||||||
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
|
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -400,7 +384,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">
|
||||||
{plural(Number(album.trackCount), 'canción', 'canciones')}
|
{plural(album.trackCount, 'canción', 'canciones')}
|
||||||
</span>
|
</span>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -459,7 +443,7 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.userPlaylists}
|
playlists={data.userPlaylists}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { toDatedTrack, toPage } from '$lib/server/mappers';
|
||||||
import {
|
import {
|
||||||
createApiClient,
|
createApiClient,
|
||||||
requireAccessTokenAction,
|
requireAccessTokenAction,
|
||||||
@@ -17,7 +18,9 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Upload from '@lucide/svelte/icons/upload';
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
import Music from '@lucide/svelte/icons/music';
|
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 { plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
@@ -14,13 +14,12 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.tracks.items);
|
const items = $derived(data.tracks.items);
|
||||||
const total = $derived(Number(data.tracks.totalItemCount));
|
const total = $derived(data.tracks.totalItemCount);
|
||||||
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
|
|
||||||
|
|
||||||
let deleting = $state<(typeof items)[number] | null>(null);
|
let deleting = $state<(typeof items)[number] | null>(null);
|
||||||
|
|
||||||
function togglePlay(index: number) {
|
function togglePlay(index: number) {
|
||||||
player.playOrToggle(toQueueItems(items), index);
|
player.playOrToggle(items, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -46,7 +45,7 @@
|
|||||||
|
|
||||||
{#if items.length > 0}
|
{#if items.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
tracks={items}
|
||||||
columns={['uploaded', 'plays']}
|
columns={['uploaded', 'plays']}
|
||||||
onPlay={togglePlay}
|
onPlay={togglePlay}
|
||||||
rowAction={{
|
rowAction={{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
import TrackList from '$lib/components/ui/media/TrackList.svelte';
|
||||||
|
|
||||||
const tracks = $derived(liked.list);
|
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));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
columns={['added', 'plays']}
|
columns={['added', 'plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
rowAction={{
|
rowAction={{
|
||||||
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
|
onclick: (track) => liked.toggle(track),
|
||||||
icon: X,
|
icon: X,
|
||||||
label: 'Quitar de Me gusta'
|
label: 'Quitar de Me gusta'
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { toMix } from '$lib/server/mappers';
|
||||||
import { addTrackAction } from '$lib/server/playlistActions';
|
import { addTrackAction } from '$lib/server/playlistActions';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
|
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 } } });
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import {
|
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||||
player,
|
|
||||||
isQueueCurrent,
|
|
||||||
playAllOrToggle,
|
|
||||||
playShuffled,
|
|
||||||
toQueueItem
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { fmtTime, plural } from '$lib/utils/format';
|
import { fmtTime, plural } from '$lib/utils/format';
|
||||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||||
@@ -18,33 +12,19 @@
|
|||||||
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 { mixItemTrack } from '$lib/data/mixes';
|
|
||||||
|
|
||||||
let { data, form } = $props();
|
let { data, form } = $props();
|
||||||
|
|
||||||
const mix = $derived(data.mix);
|
const mix = $derived(data.mix);
|
||||||
const items = $derived(mix.items);
|
const tracks = $derived(mix.tracks);
|
||||||
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 totalSeconds = $derived(
|
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
|
||||||
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
);
|
|
||||||
const isCurrentQueue = $derived(isQueueCurrent(queue));
|
|
||||||
|
|
||||||
const trackMenu = createTrackMenu();
|
const trackMenu = createTrackMenu();
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(queue, index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,18 +40,18 @@
|
|||||||
eyebrow="Mezcla"
|
eyebrow="Mezcla"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
description={mix.subtitle ?? undefined}
|
description={mix.subtitle ?? undefined}
|
||||||
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
|
||||||
>
|
>
|
||||||
{#snippet cover()}
|
{#snippet cover()}
|
||||||
<Artwork
|
<Artwork
|
||||||
trackIds={items.map((item) => item.trackId)}
|
trackIds={tracks.map((track) => track.id)}
|
||||||
size="hero"
|
size="hero"
|
||||||
alt={mix.title}
|
alt={mix.title}
|
||||||
class="shrink-0"
|
class="shrink-0"
|
||||||
/>
|
/>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet actions()}
|
{#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}
|
{#if isCurrentQueue && player.playing}
|
||||||
Pausar
|
Pausar
|
||||||
{:else}
|
{:else}
|
||||||
@@ -81,8 +61,8 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onclick={() => playShuffled(queue)}
|
onclick={() => playShuffled(tracks)}
|
||||||
disabled={queue.length === 0}
|
disabled={tracks.length === 0}
|
||||||
>
|
>
|
||||||
<Shuffle class="size-4" />
|
<Shuffle class="size-4" />
|
||||||
Aleatorio
|
Aleatorio
|
||||||
@@ -95,7 +75,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
{tracks}
|
||||||
columns={['plays']}
|
columns={['plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
|
||||||
@@ -113,7 +93,7 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.userPlaylists}
|
playlists={data.userPlaylists}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
requireUser,
|
requireUser,
|
||||||
unwrapOrError
|
unwrapOrError
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
|
||||||
const MAX_NAME = 100;
|
const MAX_NAME = 100;
|
||||||
@@ -25,24 +26,21 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
.GET('/playlists/{playlistId}/tracks', {
|
.GET('/playlists/{playlistId}/tracks', {
|
||||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
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 items = data.items.map((playlist, i) =>
|
||||||
const tracks = tracksByPlaylist[i];
|
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
|
||||||
const trackCount = tracks.length;
|
);
|
||||||
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
|
|
||||||
return { ...playlist, trackCount, durationSeconds };
|
|
||||||
});
|
|
||||||
|
|
||||||
const totals = {
|
const totals = {
|
||||||
playlistCount: items.length,
|
playlistCount: items.length,
|
||||||
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
|
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
|
||||||
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
|
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
|
||||||
};
|
};
|
||||||
|
|
||||||
return { playlists: { ...data, items }, totals };
|
return { playlists: items, totals };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
const items = $derived(data.playlists.items);
|
const items = $derived(data.playlists);
|
||||||
const totals = $derived(data.totals);
|
const totals = $derived(data.totals);
|
||||||
const summary = $derived(
|
const summary = $derived(
|
||||||
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
playlist.updatedAt
|
playlist.updatedAt
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
unwrapOrError,
|
unwrapOrError,
|
||||||
unwrapOrFail
|
unwrapOrFail
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
|
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
|
||||||
const MAX_NAME = 100;
|
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 playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
|
||||||
const tracks = tracksRes.data?.items ?? [];
|
const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack);
|
||||||
|
|
||||||
const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
|
const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import X from '@lucide/svelte/icons/x';
|
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 Page from '$lib/components/ui/layout/Page.svelte';
|
||||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||||
@@ -19,7 +19,6 @@
|
|||||||
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 isOwner = $derived(data.isOwner);
|
||||||
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
|
|
||||||
|
|
||||||
let editing = $state(false);
|
let editing = $state(false);
|
||||||
let confirmingDelete = $state(false);
|
let confirmingDelete = $state(false);
|
||||||
@@ -27,11 +26,11 @@
|
|||||||
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
const isCurrentQueue = $derived(isQueueCurrent(tracks));
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
playAllOrToggle(toQueueItems(tracks));
|
playAllOrToggle(tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
function playFrom(index: number) {
|
function playFrom(index: number) {
|
||||||
player.playOrToggle(toQueueItems(tracks), index);
|
player.playOrToggle(tracks, index);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -85,7 +84,7 @@
|
|||||||
|
|
||||||
{#if tracks.length > 0}
|
{#if tracks.length > 0}
|
||||||
<TrackList
|
<TrackList
|
||||||
tracks={listTracks}
|
{tracks}
|
||||||
columns={['added', 'plays']}
|
columns={['added', 'plays']}
|
||||||
onPlay={playFrom}
|
onPlay={playFrom}
|
||||||
rowAction={isOwner
|
rowAction={isOwner
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPlaylistSummary } from '$lib/server/mappers';
|
||||||
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
|
||||||
|
|
||||||
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
|
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) => ({
|
const playlists = playlistItems.map((playlist, i) =>
|
||||||
...playlist,
|
toPlaylistSummary(playlist, tracksByPlaylist[i])
|
||||||
trackCount: tracksByPlaylist[i]
|
);
|
||||||
}));
|
|
||||||
|
|
||||||
const isOwnProfile = viewer !== null && viewer.sub === params.id;
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet followStats()}
|
{#snippet followStats()}
|
||||||
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
|
{@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
|
||||||
{#if data.isOwnProfile || profile.canViewFollowers}
|
{#if data.isOwnProfile || profile.canViewFollowers}
|
||||||
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
|
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<MediaCard
|
<MediaCard
|
||||||
href="/playlists/{playlist.id}"
|
href="/playlists/{playlist.id}"
|
||||||
title={playlist.name}
|
title={playlist.name}
|
||||||
subtitle={playlistMeta(Number(playlist.trackCount))}
|
subtitle={playlistMeta(playlist.trackCount)}
|
||||||
trackIds={playlist.coverTrackIds}
|
trackIds={playlist.coverTrackIds}
|
||||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||||
playlist.updatedAt
|
playlist.updatedAt
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage } from '$lib/server/mappers';
|
||||||
import { fetchFollowList } from '$lib/server/follows';
|
import { fetchFollowList } from '$lib/server/follows';
|
||||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
@@ -19,7 +20,9 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
return {
|
return {
|
||||||
list: params.list,
|
list: params.list,
|
||||||
profile,
|
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,
|
forbidden,
|
||||||
viewerId: viewer?.sub ?? null
|
viewerId: viewer?.sub ?? null
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,13 +20,13 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
items = data.users?.items ?? [];
|
items = data.users?.items ?? [];
|
||||||
page = Number(data.users?.pageNumber ?? 1);
|
page = data.users?.pageNumber ?? 1;
|
||||||
hasMore = data.users?.hasNextPage ?? false;
|
hasMore = data.users?.hasNextPage ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const isFollowers = $derived(data.list === 'followers');
|
const isFollowers = $derived(data.list === 'followers');
|
||||||
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
const title = $derived(isFollowers ? 'Seguidores' : 'Siguiendo');
|
||||||
const total = $derived(Number(data.users?.totalItemCount ?? 0));
|
const total = $derived(data.users?.totalItemCount ?? 0);
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loadingMore) return;
|
if (loadingMore) return;
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
if (!res.ok) throw new Error(String(res.status));
|
if (!res.ok) throw new Error(String(res.status));
|
||||||
const next = (await res.json()) as NonNullable<typeof data.users>;
|
const next = (await res.json()) as NonNullable<typeof data.users>;
|
||||||
items = appendUnique(items, next.items, (u) => u.id);
|
items = appendUnique(items, next.items, (u) => u.id);
|
||||||
page = Number(next.pageNumber);
|
page = next.pageNumber;
|
||||||
hasMore = next.hasNextPage;
|
hasMore = next.hasNextPage;
|
||||||
} catch {
|
} catch {
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit';
|
|||||||
import type { LayoutServerLoad } from './$types';
|
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 { toLikedTrack, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||||
import { authConfig } from '$lib/server/config';
|
import { authConfig } from '$lib/server/config';
|
||||||
import { LIKED_TRACKS_PAGE_SIZE, PLAYLIST_PICKER_PAGE_SIZE } from '$lib/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 }
|
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 {
|
} catch {
|
||||||
return { items: [], total: 0 };
|
return { items: [], total: 0 };
|
||||||
}
|
}
|
||||||
@@ -32,7 +33,7 @@ async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | nul
|
|||||||
const { data } = await api.GET('/likes', {
|
const { data } = await api.GET('/likes', {
|
||||||
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
|
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
|
||||||
});
|
});
|
||||||
return data?.items ?? [];
|
return (data?.items ?? []).map(toLikedTrack);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -48,7 +49,7 @@ async function fetchLastPlayedTrack(
|
|||||||
const { data } = await api.GET('/users/{id}/last-listened-track', {
|
const { data } = await api.GET('/users/{id}/last-listened-track', {
|
||||||
params: { path: { id: userId } }
|
params: { path: { id: userId } }
|
||||||
});
|
});
|
||||||
return data ?? null;
|
return data ? toTrack(data) : null;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import type { PageServerLoad, Actions } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { createApiClient, requireUser } from '$lib/server/api';
|
import { createApiClient, requireUser } from '$lib/server/api';
|
||||||
|
import { toMix, toPlaylist, toTrack } from '$lib/server/mappers';
|
||||||
import { addTrackAction } from '$lib/server/playlistActions';
|
import { addTrackAction } from '$lib/server/playlistActions';
|
||||||
import {
|
import { HOME_MIXES_LIMIT, HOME_SHELF_LIMIT, HOME_SPOTLIGHT_TRACKS_LIMIT } from '$lib/config';
|
||||||
HOME_LATEST_PAGE_SIZE,
|
|
||||||
HOME_MIXES_LIMIT,
|
|
||||||
HOME_SHELF_LIMIT,
|
|
||||||
HOME_SPOTLIGHT_TRACKS_LIMIT
|
|
||||||
} from '$lib/config';
|
|
||||||
import { pickGreeting } from '$lib/server/greeting';
|
import { pickGreeting } from '$lib/server/greeting';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
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 user = requireUser(locals, url);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
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}', {
|
const playlistsPromise = api.GET('/playlists/users/{userId}', {
|
||||||
params: {
|
params: {
|
||||||
path: { userId: user.sub },
|
path: { userId: user.sub },
|
||||||
@@ -30,17 +23,19 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
});
|
});
|
||||||
const listeningStatsPromise = api
|
const listeningStatsPromise = api
|
||||||
.GET('/users/{id}/listening-stats', { params: { path: { id: user.sub } } })
|
.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 }));
|
.catch(() => ({ tracksThisWeek: 0, secondsThisWeek: 0, streakDays: 0 }));
|
||||||
|
|
||||||
const [latest, playlists, mixes, recentlyPlayed] = await Promise.all([
|
const [playlists, mixes, recentlyPlayed] = await Promise.all([
|
||||||
latestPromise,
|
|
||||||
playlistsPromise,
|
playlistsPromise,
|
||||||
mixesPromise,
|
mixesPromise,
|
||||||
recentlyPlayedPromise
|
recentlyPlayedPromise
|
||||||
]);
|
]);
|
||||||
const playlistItems = playlists.data?.items ?? [];
|
const playlistItems = (playlists.data?.items ?? []).map(toPlaylist);
|
||||||
const latestItems = (latest.data?.items ?? []).map((item) => item.track);
|
|
||||||
|
|
||||||
const spotlightPlaylist = playlistItems[0] ?? null;
|
const spotlightPlaylist = playlistItems[0] ?? null;
|
||||||
const spotlightTracksPromise = spotlightPlaylist
|
const spotlightTracksPromise = spotlightPlaylist
|
||||||
@@ -52,7 +47,7 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((res) => ({
|
.then((res) => ({
|
||||||
items: res.data?.items ?? [],
|
items: (res.data?.items ?? []).map(toTrack),
|
||||||
totalCount: Number(res.data?.totalItemCount ?? 0)
|
totalCount: Number(res.data?.totalItemCount ?? 0)
|
||||||
}))
|
}))
|
||||||
.catch(() => ({ items: [], totalCount: 0 }))
|
.catch(() => ({ items: [], totalCount: 0 }))
|
||||||
@@ -60,11 +55,10 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
greeting: pickGreeting(new Date()),
|
greeting: pickGreeting(new Date()),
|
||||||
recentlyPlayed: recentlyPlayed.data ?? [],
|
recentlyPlayed: (recentlyPlayed.data ?? []).map(toTrack),
|
||||||
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT),
|
mixes: (mixes.data ?? []).slice(0, HOME_MIXES_LIMIT).map(toMix),
|
||||||
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
playlists: playlistItems.slice(0, HOME_SHELF_LIMIT),
|
||||||
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
playlistsHasMore: playlistItems.length > HOME_SHELF_LIMIT,
|
||||||
newReleases: latestItems,
|
|
||||||
spotlightPlaylist,
|
spotlightPlaylist,
|
||||||
spotlightTracks: spotlightTracksPromise,
|
spotlightTracks: spotlightTracksPromise,
|
||||||
listeningStats: listeningStatsPromise
|
listeningStats: listeningStatsPromise
|
||||||
|
|||||||
@@ -3,20 +3,10 @@
|
|||||||
import History from '@lucide/svelte/icons/history';
|
import History from '@lucide/svelte/icons/history';
|
||||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||||
import {
|
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||||
player,
|
import type { Track } from '$lib/types';
|
||||||
isQueueCurrent,
|
|
||||||
playAllOrToggle,
|
|
||||||
playShuffled,
|
|
||||||
toQueueItem,
|
|
||||||
toQueueItems,
|
|
||||||
trackFromQueueItem,
|
|
||||||
type ApiTrackLike,
|
|
||||||
type QueueItem
|
|
||||||
} from '$lib/player/player.svelte';
|
|
||||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||||
import { mixItemTrack } from '$lib/data/mixes';
|
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||||
import { fetchPlaylistQueueItems } from '$lib/data/albums';
|
|
||||||
import { fmtTime, fmtDurationLong, plural } from '$lib/utils/format';
|
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';
|
||||||
@@ -43,32 +33,25 @@
|
|||||||
function playContinue(index: number) {
|
function playContinue(index: number) {
|
||||||
player.playOrToggle(continueItems, index);
|
player.playOrToggle(continueItems, index);
|
||||||
}
|
}
|
||||||
function playSpotlightTrack(items: QueueItem[], index: number) {
|
function playSpotlightTrack(items: Track[], index: number) {
|
||||||
player.playOrToggle(items, index);
|
player.playOrToggle(items, index);
|
||||||
}
|
}
|
||||||
async function playSpotlight(previewItems: QueueItem[]) {
|
async function playSpotlight(previewItems: Track[]) {
|
||||||
if (!spotlight) return;
|
if (!spotlight) return;
|
||||||
if (isQueueCurrent(previewItems)) {
|
if (isQueueCurrent(previewItems)) {
|
||||||
player.toggle();
|
player.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
playAllOrToggle(await fetchPlaylistQueueItems(spotlight.id));
|
playAllOrToggle(await fetchPlaylistTracks(spotlight.id));
|
||||||
}
|
}
|
||||||
async function shuffleSpotlight() {
|
async function shuffleSpotlight() {
|
||||||
if (!spotlight) return;
|
if (!spotlight) return;
|
||||||
playShuffled(await fetchPlaylistQueueItems(spotlight.id));
|
playShuffled(await fetchPlaylistTracks(spotlight.id));
|
||||||
}
|
|
||||||
|
|
||||||
function openContextMenu(event: MouseEvent, track: ApiTrackLike) {
|
|
||||||
trackMenu.open(event, track);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
function playMix(mix: (typeof mixes)[number], event: MouseEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
player.playQueue(
|
player.playQueue(mix.tracks, 0);
|
||||||
mix.items.map((item) => toQueueItem(mixItemTrack(item))),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -105,8 +88,7 @@
|
|||||||
</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}
|
||||||
{#await data.spotlightTracks then { items }}
|
{#await data.spotlightTracks then { items: previewItems }}
|
||||||
{@const previewItems = toQueueItems(items)}
|
|
||||||
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
|
<Button variant="primary" onclick={() => playSpotlight(previewItems)}>
|
||||||
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
|
{isQueueCurrent(previewItems) && player.playing ? 'Pausar' : 'Reanudar'}
|
||||||
{spotlight.name}
|
{spotlight.name}
|
||||||
@@ -121,7 +103,7 @@
|
|||||||
{@const statsList = [
|
{@const statsList = [
|
||||||
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
{ value: stats.tracksThisWeek, label: 'Canciones nuevas escuchadas esta semana' },
|
||||||
{
|
{
|
||||||
value: fmtDurationLong(Number(stats.secondsThisWeek)),
|
value: fmtDurationLong(stats.secondsThisWeek),
|
||||||
label: 'Tiempo de escucha esta semana'
|
label: 'Tiempo de escucha esta semana'
|
||||||
},
|
},
|
||||||
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
{ value: stats.streakDays, label: 'Días consecutivos escuchando' }
|
||||||
@@ -144,10 +126,10 @@
|
|||||||
{#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)}
|
||||||
{@const isCurrent = player.current.id === item.id}
|
{@const isCurrent = player.currentId === item.id}
|
||||||
<ListRow
|
<ListRow
|
||||||
onclick={() => playContinue(i)}
|
onclick={() => playContinue(i)}
|
||||||
oncontextmenu={(e) => openContextMenu(e, trackFromQueueItem(item))}
|
oncontextmenu={(e) => trackMenu.open(e, item)}
|
||||||
active={isCurrent}
|
active={isCurrent}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant="card"
|
variant="card"
|
||||||
@@ -168,8 +150,7 @@
|
|||||||
|
|
||||||
{#if spotlight}
|
{#if spotlight}
|
||||||
<section>
|
<section>
|
||||||
{#await data.spotlightTracks then { items, totalCount }}
|
{#await data.spotlightTracks then { items: previewItems, totalCount }}
|
||||||
{@const previewItems = toQueueItems(items)}
|
|
||||||
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
|
{@const visibilityLabel = spotlight.visibility === 'Public' ? 'Pública' : 'Privada'}
|
||||||
{@const meta =
|
{@const meta =
|
||||||
totalCount === 0
|
totalCount === 0
|
||||||
@@ -211,15 +192,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2 p-5.5 sm:p-6">
|
<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
|
<ListRow
|
||||||
onclick={() => playSpotlightTrack(previewItems, i)}
|
onclick={() => playSpotlightTrack(previewItems, i)}
|
||||||
active={player.current.id === track.id}
|
active={player.currentId === track.id}
|
||||||
size="sm"
|
size="sm"
|
||||||
title={track.title}
|
title={track.title}
|
||||||
subtitle={track.artist}
|
subtitle={track.artist}
|
||||||
subtitleHref={track.ownerUserId}
|
subtitleHref={track.ownerUserId}
|
||||||
explicit={track.isExplicit}
|
explicit={track.explicit}
|
||||||
>
|
>
|
||||||
{#snippet leading()}
|
{#snippet leading()}
|
||||||
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
|
<span class="w-5 shrink-0 text-center text-xs text-fg-2 tabular-nums"
|
||||||
@@ -228,7 +209,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet trailing()}
|
{#snippet trailing()}
|
||||||
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
|
||||||
>{fmtTime(Number(track.duration))}</span
|
>{fmtTime(track.duration)}</span
|
||||||
>
|
>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</ListRow>
|
</ListRow>
|
||||||
@@ -250,7 +231,7 @@
|
|||||||
href="/mixes/{mix.id}"
|
href="/mixes/{mix.id}"
|
||||||
title={mix.title}
|
title={mix.title}
|
||||||
subtitle={mix.subtitle}
|
subtitle={mix.subtitle}
|
||||||
trackIds={mix.items.map((item) => item.trackId)}
|
trackIds={mix.tracks.map((track) => track.id)}
|
||||||
onPlay={(event) => playMix(mix, event)}
|
onPlay={(event) => playMix(mix, event)}
|
||||||
index={i}
|
index={i}
|
||||||
/>
|
/>
|
||||||
@@ -314,7 +295,7 @@
|
|||||||
]}
|
]}
|
||||||
playlistAction={{
|
playlistAction={{
|
||||||
action: '?/addTrack',
|
action: '?/addTrack',
|
||||||
fields: { trackId: String(trackMenu.state.track.id) },
|
fields: { trackId: trackMenu.state.track.id },
|
||||||
label: 'Añadir a una playlist'
|
label: 'Añadir a una playlist'
|
||||||
}}
|
}}
|
||||||
playlists={data.userPlaylists}
|
playlists={data.userPlaylists}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage, toTrack } from '$lib/server/mappers';
|
||||||
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
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 type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage, toTrack } from '$lib/server/mappers';
|
||||||
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params, locals, fetch }) => {
|
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));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage, toTrack } from '$lib/server/mappers';
|
||||||
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
|
import { EXPLORE_PAGE_SIZE, MAX_PAGE_SIZE } from '$lib/config';
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
|
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 } }
|
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 type { RequestHandler } from './$types';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
import { createApiClient, unwrapOrError } from '$lib/server/api';
|
||||||
|
import { toPage } from '$lib/server/mappers';
|
||||||
import { fetchFollowList } from '$lib/server/follows';
|
import { fetchFollowList } from '$lib/server/follows';
|
||||||
import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config';
|
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
|
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
|
||||||
|
)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user