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

This commit is contained in:
2026-09-24 05:09:37 +02:00
parent 37fc229bf6
commit 5b2a0e4d46
41 changed files with 526 additions and 452 deletions
@@ -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}
-1
View File
@@ -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;
+8 -11
View File
@@ -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 ?? []);
}
-19
View File
@@ -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
};
}
+8 -15
View File
@@ -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);
+8 -40
View File
@@ -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 {
+28 -125
View File
@@ -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;
} }
+139
View File
@@ -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 });
});
});
+94
View File
@@ -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
};
}
+5 -4
View File
@@ -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;
} }
}; };
+53
View File
@@ -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;
}
+3 -3
View File
@@ -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;
+4 -3
View File
@@ -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;
} }
+12 -18
View File
@@ -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
+19 -38
View File
@@ -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));
}; };
+6 -1
View File
@@ -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
)
);
}; };