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';
|
||||
|
||||
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>
|
||||
|
||||
@@ -39,13 +39,13 @@
|
||||
</span>
|
||||
<Slider
|
||||
value={player.progress}
|
||||
max={player.current.duration}
|
||||
max={player.duration}
|
||||
label="Barra de progreso"
|
||||
oninput={seek}
|
||||
variant="seek"
|
||||
/>
|
||||
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(player.current.duration)}
|
||||
{fmtTime(player.duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{#if player.current.id}
|
||||
{#if player.current}
|
||||
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
|
||||
<div class="mb-6 surface-active rounded-control p-2.5">
|
||||
<MediaIdentity
|
||||
|
||||
@@ -13,27 +13,20 @@
|
||||
|
||||
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() {
|
||||
if (!player.current.id) return;
|
||||
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
|
||||
});
|
||||
if (current) liked.toggle(current);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
|
||||
{#if player.current.id}
|
||||
{#if current}
|
||||
<Artwork
|
||||
trackIds={[player.current.id]}
|
||||
trackIds={[current.id]}
|
||||
size="sm"
|
||||
alt={player.current.title}
|
||||
alt={current.title}
|
||||
class="shrink-0 md:size-cover-md"
|
||||
/>
|
||||
{:else}
|
||||
@@ -41,14 +34,14 @@
|
||||
{/if}
|
||||
<div class="min-w-0">
|
||||
<div class="flex min-w-0 items-center gap-1.5">
|
||||
{#if player.current.explicit}
|
||||
{#if current?.explicit}
|
||||
<ExplicitBadge />
|
||||
{/if}
|
||||
<span class="truncate text-sm text-fg">{player.current.title}</span>
|
||||
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
|
||||
</div>
|
||||
<ArtistLink
|
||||
name={player.current.artist || '—'}
|
||||
ownerUserId={player.current.ownerUserId}
|
||||
name={current?.artist || '—'}
|
||||
ownerUserId={current?.ownerUserId}
|
||||
class="mt-0.5 text-xs text-fg-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
<script lang="ts" module>
|
||||
import type { Track } from '$lib/types';
|
||||
|
||||
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
|
||||
|
||||
export interface TrackListTrack {
|
||||
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;
|
||||
}
|
||||
export type TrackListTrack = Track & { date?: string | number };
|
||||
|
||||
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
|
||||
added: { label: 'Añadida', width: '124px' },
|
||||
@@ -108,7 +99,7 @@
|
||||
</div>
|
||||
<div class="mt-1 flex flex-col gap-0.5" role="list">
|
||||
{#each tracks as track, i (track.id)}
|
||||
{@const active = player.current.id === track.id}
|
||||
{@const active = player.currentId === track.id}
|
||||
|
||||
<div
|
||||
role="listitem"
|
||||
@@ -145,7 +136,7 @@
|
||||
title={track.title}
|
||||
artist={track.artist}
|
||||
ownerUserId={track.ownerUserId}
|
||||
explicit={track.isExplicit ?? track.explicit ?? false}
|
||||
explicit={track.explicit}
|
||||
{active}
|
||||
>
|
||||
{#snippet overlay()}
|
||||
@@ -158,16 +149,14 @@
|
||||
{#each columns as column (column)}
|
||||
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
|
||||
{#if column === 'plays'}
|
||||
{Number(track.listensCount ?? 0)}
|
||||
{:else if column === 'added' && track.addedAt}
|
||||
{fmtDate(track.addedAt)}
|
||||
{:else if column === 'uploaded' && track.uploadedAt}
|
||||
{fmtDate(track.uploadedAt)}
|
||||
{track.listensCount}
|
||||
{:else if track.date}
|
||||
{fmtDate(track.date)}
|
||||
{/if}
|
||||
</span>
|
||||
{/each}
|
||||
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
|
||||
{fmtTime(Number(track.duration))}
|
||||
{fmtTime(track.duration)}
|
||||
</span>
|
||||
{#if rowAction}
|
||||
{#if rowAction.action}
|
||||
|
||||
@@ -10,7 +10,6 @@ export const ALBUM_EARLIEST_YEAR = 1877;
|
||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||
export const EXPLORE_ALBUMS_LIMIT = 18;
|
||||
export const HOME_LATEST_PAGE_SIZE = 10;
|
||||
export const HOME_MIXES_LIMIT = 6;
|
||||
export const HOME_SHELF_LIMIT = 6;
|
||||
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[]> {
|
||||
const res = await fetch(`/api/albums/${albumId}/tracks`);
|
||||
async function fetchTracks(url: string): Promise<Track[]> {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
return ((await res.json()) as Paged<Track>).items;
|
||||
}
|
||||
|
||||
export async function fetchPlaylistQueueItems(playlistId: string): Promise<QueueItem[]> {
|
||||
const res = await fetch(`/api/playlists/${playlistId}/tracks`);
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { items: ApiTrackLike[] };
|
||||
return toQueueItems(data.items ?? []);
|
||||
}
|
||||
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
|
||||
|
||||
export const fetchPlaylistTracks = (playlistId: string) =>
|
||||
fetchTracks(`/api/playlists/${playlistId}/tracks`);
|
||||
|
||||
@@ -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 merged: QueueItem[] = [];
|
||||
const merged: Track[] = [];
|
||||
|
||||
for (const t of player.recentlyPlayed) {
|
||||
const id = String(t.id);
|
||||
if (seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
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 });
|
||||
for (const track of [...player.recentlyPlayed, ...history]) {
|
||||
if (seen.has(track.id)) continue;
|
||||
seen.add(track.id);
|
||||
merged.push(track);
|
||||
}
|
||||
|
||||
return merged.slice(0, limit);
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
import { toQueueItems, type ApiTrackLike } from './player.svelte';
|
||||
|
||||
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;
|
||||
};
|
||||
import type { LikedTrack, Track } from '$lib/types';
|
||||
|
||||
class LikedStore {
|
||||
entries = $state<Record<string, LikedTrack>>({});
|
||||
@@ -27,35 +7,23 @@ class LikedStore {
|
||||
count = $derived(Object.keys(this.entries).length);
|
||||
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;
|
||||
this.hydrated = true;
|
||||
|
||||
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;
|
||||
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
|
||||
}
|
||||
|
||||
isLiked(id: string | number): boolean {
|
||||
return String(id) in this.entries;
|
||||
isLiked(id: string): boolean {
|
||||
return id in this.entries;
|
||||
}
|
||||
|
||||
async toggle(track: LikeToggleInput) {
|
||||
const id = String(track.id);
|
||||
async toggle(track: Track) {
|
||||
const id = track.id;
|
||||
const before = this.entries;
|
||||
const wasLiked = id in before;
|
||||
const next = { ...before };
|
||||
if (wasLiked) delete next[id];
|
||||
else next[id] = { ...track, id, likedAt: Date.now() };
|
||||
else next[id] = { ...track, likedAt: Date.now() };
|
||||
this.entries = next;
|
||||
|
||||
try {
|
||||
|
||||
@@ -5,77 +5,20 @@ import { shuffle } from '$lib/data/collections';
|
||||
import { dialog } from '$lib/state/dialog.svelte';
|
||||
import { ListenTracker } from './listenTracker';
|
||||
import { readStorage, writeStorage } from '$lib/utils/storage';
|
||||
import type { Track } from '$lib/types';
|
||||
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
|
||||
|
||||
export interface PlayerTrack {
|
||||
id: string | number;
|
||||
title: string;
|
||||
artist: string;
|
||||
duration: number;
|
||||
explicit?: boolean;
|
||||
ownerUserId?: string | number | null;
|
||||
listensCount?: number | string;
|
||||
export function isQueueCurrent(items: { id: string }[]): boolean {
|
||||
return items.some((item) => item.id === player.currentId);
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
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[]) {
|
||||
export function playAllOrToggle(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
if (isQueueCurrent(items)) player.toggle();
|
||||
else player.playQueue(items, 0);
|
||||
}
|
||||
|
||||
export function playShuffled(items: QueueItem[]) {
|
||||
export function playShuffled(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
player.playQueue(shuffle(items), 0);
|
||||
}
|
||||
@@ -105,34 +48,15 @@ function loadVolume(): number {
|
||||
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 {
|
||||
tracks = $state<PlayerTrack[]>([]);
|
||||
currentId = $state<string | number | null>(null);
|
||||
tracks = $state<Track[]>([]);
|
||||
currentId = $state<string | null>(null);
|
||||
playing = $state(false);
|
||||
progress = $state(0);
|
||||
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
|
||||
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
|
||||
loading = $state(false);
|
||||
recentlyPlayed = $state<PlayerTrack[]>([]);
|
||||
recentlyPlayed = $state<Track[]>([]);
|
||||
shuffle = $state(false);
|
||||
repeat = $state(false);
|
||||
|
||||
@@ -144,10 +68,11 @@ class PlayerState {
|
||||
#rafId: number | null = null;
|
||||
#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);
|
||||
duration = $derived(this.current?.duration ?? 0);
|
||||
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 {
|
||||
@@ -228,15 +153,11 @@ class PlayerState {
|
||||
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
|
||||
}
|
||||
|
||||
#syncMediaSessionMetadata(track: PlayerTrack) {
|
||||
#syncMediaSessionMetadata(track: Track) {
|
||||
if (!browser || !('mediaSession' in navigator)) return;
|
||||
if (!track.id) {
|
||||
navigator.mediaSession.metadata = null;
|
||||
return;
|
||||
}
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
artist: track.artist ?? '',
|
||||
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);
|
||||
}
|
||||
|
||||
#pushRecent(track: PlayerTrack) {
|
||||
if (!track.id) return;
|
||||
#pushRecent(track: Track) {
|
||||
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
|
||||
0,
|
||||
RECENT_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
async #applyAccent(id: string | number) {
|
||||
const key = String(id);
|
||||
const cached = this.#accentCache.get(key);
|
||||
async #applyAccent(id: string) {
|
||||
const cached = this.#accentCache.get(id);
|
||||
if (cached) {
|
||||
this.accentColor = cached;
|
||||
return;
|
||||
@@ -296,7 +215,7 @@ class PlayerState {
|
||||
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
|
||||
if (this.currentId !== id) return;
|
||||
if (result) {
|
||||
this.#accentCache.set(key, result);
|
||||
this.#accentCache.set(id, result);
|
||||
this.accentColor = result;
|
||||
} else {
|
||||
this.accentColor = null;
|
||||
@@ -322,7 +241,7 @@ class PlayerState {
|
||||
this.#listen.begin(listenId);
|
||||
await audio.play();
|
||||
this.loading = false;
|
||||
this.#pushRecent(this.current);
|
||||
if (track) this.#pushRecent(track);
|
||||
} catch (exception) {
|
||||
console.error('playback failed', exception);
|
||||
if (token !== this.#loadToken) return;
|
||||
@@ -375,60 +294,44 @@ class PlayerState {
|
||||
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
|
||||
}
|
||||
|
||||
hydrate(item: ApiTrackLike) {
|
||||
hydrate(track: Track) {
|
||||
if (this.currentId !== null || this.tracks.length > 0) return;
|
||||
const track = toTrack(toQueueItem(item));
|
||||
this.tracks = [track];
|
||||
this.currentId = track.id;
|
||||
this.#applyAccent(track.id);
|
||||
this.#syncMediaSessionMetadata(track);
|
||||
}
|
||||
|
||||
playQueue(list: QueueItem[], startIndex = 0) {
|
||||
playQueue(list: Track[], startIndex = 0) {
|
||||
if (list.length === 0) return;
|
||||
this.tracks = list.map(toTrack);
|
||||
this.tracks = [...list];
|
||||
const start = this.tracks[startIndex] ?? this.tracks[0];
|
||||
this.currentId = start.id;
|
||||
this.#loadCurrent();
|
||||
}
|
||||
|
||||
playOrToggle(list: QueueItem[], index: number) {
|
||||
playOrToggle(list: Track[], index: number) {
|
||||
if (index < 0 || index >= list.length) return;
|
||||
if (this.currentId === list[index].id) this.toggle();
|
||||
else this.playQueue(list, index);
|
||||
}
|
||||
|
||||
playNext(items: QueueItem[]) {
|
||||
playNext(items: Track[]) {
|
||||
if (items.length === 0) return;
|
||||
if (this.currentId === null) {
|
||||
this.playQueue(items, 0);
|
||||
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 (this.currentId === null) {
|
||||
this.playQueue(items, 0);
|
||||
return;
|
||||
}
|
||||
this.tracks = append(this.tracks, items.map(toTrack));
|
||||
}
|
||||
|
||||
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();
|
||||
this.tracks = append(this.tracks, items);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
@@ -485,7 +388,7 @@ class PlayerState {
|
||||
}
|
||||
|
||||
clearUpcoming() {
|
||||
this.tracks = this.currentId === null ? [] : [this.current];
|
||||
this.tracks = this.current ? [this.current] : [];
|
||||
}
|
||||
|
||||
previous() {
|
||||
@@ -503,7 +406,7 @@ class PlayerState {
|
||||
seekFraction(fraction: number) {
|
||||
const audio = this.#audioEl();
|
||||
const clamped = Math.min(1, Math.max(0, fraction));
|
||||
const target = clamped * this.current.duration;
|
||||
const target = clamped * this.duration;
|
||||
this.progress = 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,
|
||||
type MenuPosition
|
||||
} 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() {
|
||||
let state = $state<TrackMenuState | null>(null);
|
||||
@@ -13,7 +14,7 @@ export function createTrackMenu() {
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
open(event: MouseEvent, track: ApiTrackLike) {
|
||||
open(event: MouseEvent, track: Track) {
|
||||
state = { track, ...contextMenuPosition(event) };
|
||||
},
|
||||
close() {
|
||||
@@ -21,7 +22,7 @@ export function createTrackMenu() {
|
||||
},
|
||||
playNext() {
|
||||
if (!state) return;
|
||||
player.playNext([toQueueItem(state.track)]);
|
||||
player.playNext([state.track]);
|
||||
state = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,3 +6,56 @@ export interface SessionUser {
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export function fmtPlays(count: number | string | undefined): string {
|
||||
const value = Math.max(0, Math.floor(Number(count ?? 0)));
|
||||
export function fmtPlays(count: number): string {
|
||||
const value = Math.max(0, Math.floor(count));
|
||||
return plural(value, 'reproducción', 'reproducciones');
|
||||
}
|
||||
|
||||
export function albumMeta(releaseYear?: number, trackCount?: number): string {
|
||||
export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
|
||||
return [
|
||||
releaseYear ? String(releaseYear) : undefined,
|
||||
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
|
||||
|
||||
Reference in New Issue
Block a user