feat(frontend): rework audio player and shared UI components

Expand the player store, split player controls (transport, seek, volume, track info), add Cover/PlaylistArt/NowPlaying UI primitives and a cover image proxy route.
This commit is contained in:
2026-07-14 04:54:51 +02:00
parent 458c20335e
commit 1f531ea70e
10 changed files with 430 additions and 166 deletions
@@ -1,26 +1,22 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte';
import TrackInfo from './TrackInfo.svelte'; import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import SeekBar from './SeekBar.svelte'; import SeekBar from './SeekBar.svelte';
import VolumeControl from './VolumeControl.svelte'; import VolumeControl from './VolumeControl.svelte';
</script> </script>
{#if player.current} <div
<div class="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-neutral-950/95 backdrop-blur"> class="animate-enter fixed bottom-0 left-[264px] right-0 z-50 h-[92px] border-t border-[var(--mf-border)] bg-[var(--mf-bg)]"
<div class="mx-auto grid max-w-6xl grid-cols-[1fr_auto] items-center gap-4 px-4 py-3 sm:grid-cols-3 sm:px-6"> >
<div
class="grid h-full items-center gap-5 px-8"
style="grid-template-columns:minmax(200px,1fr) minmax(0,3fr) minmax(200px,1fr)"
>
<TrackInfo /> <TrackInfo />
<div class="flex flex-col items-center gap-2"> <div class="flex flex-col items-center gap-2">
<TransportControls /> <TransportControls />
<div class="hidden w-full max-w-md sm:block">
<SeekBar /> <SeekBar />
</div> </div>
</div>
<div class="hidden justify-end sm:flex">
<VolumeControl /> <VolumeControl />
</div> </div>
</div> </div>
</div>
{/if}
@@ -1,26 +1,33 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/theme/color';
function format(seconds: number) { function seek(event: MouseEvent) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'; const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
const minutes = Math.floor(seconds / 60); player.seekFraction((event.clientX - rect.left) / rect.width);
const rest = Math.floor(seconds % 60);
return `${minutes}:${rest.toString().padStart(2, '0')}`;
} }
</script> </script>
<div class="flex w-full items-center gap-2"> <div class="flex w-full max-w-[900px] items-center gap-[9px]">
<span class="w-10 text-right text-xs tabular-nums text-neutral-500">{format(player.currentTime)}</span> <span class="w-8 text-right text-[11px] tabular-nums text-[var(--mf-text-4)]">
<input {fmtTime(player.progress)}
type="range" </span>
min="0" <button
max={player.duration || 0} type="button"
step="0.1" onclick={seek}
value={player.currentTime} aria-label="Barra de progreso"
disabled={!player.duration} class="group relative h-1.5 flex-1 cursor-pointer rounded-full bg-[var(--mf-track)] transition-[height] hover:h-2"
oninput={(event) => player.seek(Number(event.currentTarget.value))} >
aria-label="Progreso" <div
class="h-1 w-full cursor-pointer accent-emerald-500" class="absolute inset-y-0 left-0 rounded-full bg-[var(--mf-accent)] transition-[width] duration-75 ease-linear group-hover:brightness-110"
/> style="width:{player.progressPercent}%"
<span class="w-10 text-xs tabular-nums text-neutral-500">{format(player.duration)}</span> ></div>
<span
class="pointer-events-none absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow-[0_1px_4px_rgba(0,0,0,0.5)] transition-[left,opacity] duration-75 ease-linear group-hover:opacity-100"
style="left:{player.progressPercent}%"
></span>
</button>
<span class="w-8 text-[11px] tabular-nums text-[var(--mf-text-4)]">
{fmtTime(player.current.duration)}
</span>
</div> </div>
@@ -1,19 +1,27 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import Cover from '$lib/components/ui/Cover.svelte';
</script> </script>
<div class="flex min-w-0 items-center gap-3"> <div class="flex min-w-0 items-center gap-3">
<div {#if player.current.id}
class="grid h-12 w-12 shrink-0 place-items-center rounded-lg bg-gradient-to-br from-emerald-500/25 to-emerald-500/5 text-xl" <Cover
> trackId={player.current.id}
🎵 hue={player.current.hue}
</div> size="small"
<div class="min-w-0"> alt={player.current.title}
<p class="truncate text-sm font-semibold">{player.current?.title}</p> class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
{#if player.error} />
<p class="truncate text-xs text-red-400">{player.error}</p>
{:else} {:else}
<p class="truncate text-xs text-neutral-500">Musify</p> <div
class="h-[52px] w-[52px] flex-shrink-0 rounded-lg shadow-[0_6px_16px_-6px_rgba(0,0,0,0.6)]"
style="background:{player.artGradient}"
></div>
{/if} {/if}
<div class="min-w-0">
<div class="truncate text-[13.5px] font-semibold text-[var(--mf-text)]">
{player.current.title}
</div>
<div class="truncate text-xs text-[var(--mf-text-3)]">{player.current.artist || '—'}</div>
</div> </div>
</div> </div>
@@ -2,39 +2,33 @@
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
</script> </script>
<div class="flex items-center gap-2"> <div class="flex items-center gap-5">
<button <button
type="button" type="button"
onclick={() => player.previous()} onclick={() => player.previous()}
disabled={!player.current}
aria-label="Anterior" aria-label="Anterior"
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white disabled:opacity-30" class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
> >
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M7 6h2v12H7z" /> <rect x="0" y="0" width="2.5" height="14" rx="1" />
<path d="M20 6v12l-9-6z" /> <path d="M15 0 5 7l10 7z" />
</svg> </svg>
</button> </button>
<button <button
type="button" type="button"
onclick={() => player.toggle()} onclick={() => player.toggle()}
disabled={!player.current} aria-label={player.playing ? 'Pausar' : 'Reproducir'}
aria-label={player.isPlaying ? 'Pausar' : 'Reproducir'} class="grid h-[36px] w-[36px] place-items-center rounded-full bg-[var(--mf-text)] text-[var(--mf-bg)] shadow-lg transition duration-100 active:scale-95"
class="grid h-11 w-11 place-items-center rounded-full bg-emerald-500 text-neutral-950 transition hover:bg-emerald-400 disabled:opacity-40"
> >
{#if player.loading} {#if player.playing}
<svg class="h-5 w-5 animate-spin" viewBox="0 0 24 24" fill="none"> <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="3" stroke-opacity="0.25" /> <rect x="1.5" y="0" width="3.5" height="12" rx="1" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="3" stroke-linecap="round" /> <rect x="7" y="0" width="3.5" height="12" rx="1" />
</svg>
{:else if player.isPlaying}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg> </svg>
{:else} {:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"> <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
<path d="M8 5v14l11-7z" /> <path d="M2.5 0 12 6l-9.5 6z" />
</svg> </svg>
{/if} {/if}
</button> </button>
@@ -42,13 +36,12 @@
<button <button
type="button" type="button"
onclick={() => player.next()} onclick={() => player.next()}
disabled={!player.hasNext}
aria-label="Siguiente" aria-label="Siguiente"
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white disabled:opacity-30" class="flex text-[var(--mf-text)] opacity-70 transition hover:opacity-100 active:scale-90"
> >
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5"> <svg width="16" height="14" viewBox="0 0 16 14" fill="currentColor">
<path d="M15 6h2v12h-2z" /> <path d="M1 0 11 7 1 14z" />
<path d="M4 6v12l9-6z" /> <rect x="13.5" y="0" width="2.5" height="14" rx="1" />
</svg> </svg>
</button> </button>
</div> </div>
@@ -2,33 +2,44 @@
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
</script> </script>
<div class="flex items-center gap-2"> <div class="flex items-center justify-end gap-3.5">
<button
type="button"
onclick={() => player.toggleMute()}
aria-label={player.muted ? 'Activar sonido' : 'Silenciar'}
class="grid h-9 w-9 place-items-center rounded-full text-neutral-300 transition hover:text-white"
>
{#if player.muted || player.volume === 0}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M4 9v6h4l5 5V4L8 9H4z" />
<path d="M16 9l5 6M21 9l-5 6" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{:else}
<svg viewBox="0 0 24 24" fill="currentColor" class="h-5 w-5">
<path d="M4 9v6h4l5 5V4L8 9H4z" />
<path d="M16 8a5 5 0 0 1 0 8" stroke="currentColor" stroke-width="2" fill="none" />
</svg>
{/if}
</button>
<input <input
type="range" type="range"
min="0" min="0"
max="1" max="100"
step="0.01" value={player.volume}
value={player.muted ? 0 : player.volume}
oninput={(event) => player.setVolume(Number(event.currentTarget.value))} oninput={(event) => player.setVolume(Number(event.currentTarget.value))}
aria-label="Volumen" aria-label="Volumen"
class="h-1 w-24 cursor-pointer accent-emerald-500" class="mf-volume"
style="background:linear-gradient(to right, var(--mf-accent) {player.volume}%, var(--mf-track) {player.volume}%)"
/> />
</div> </div>
<style>
.mf-volume {
-webkit-appearance: none;
appearance: none;
width: 80px;
height: 4px;
border-radius: 100px;
outline: none;
cursor: pointer;
}
.mf-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
cursor: pointer;
}
.mf-volume::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--mf-text);
border: none;
cursor: pointer;
}
</style>
@@ -0,0 +1,35 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { gradientForHue } from '$lib/theme/color';
interface Props {
trackId: string | number;
hue: number;
size?: 'small' | 'medium' | 'large';
alt?: string;
class?: string;
children?: Snippet;
}
let { trackId, hue, size = 'medium', alt = '', class: klass = '', children }: Props = $props();
let failed = $state(false);
let loaded = $state(false);
</script>
<div
class="relative overflow-hidden {klass}"
style={loaded && !failed ? undefined : `background:${gradientForHue(hue)}`}
>
{#if !failed}
<img
src="/api/tracks/{trackId}/cover?size={size}"
{alt}
loading="lazy"
onload={() => (loaded = true)}
onerror={() => (failed = true)}
class="h-full w-full object-cover"
/>
{/if}
{@render children?.()}
</div>
@@ -0,0 +1,25 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const bars = [
{ delay: 0, dur: 0.9 },
{ delay: 0.25, dur: 0.7 },
{ delay: 0.1, dur: 1.05 },
{ delay: 0.4, dur: 0.8 }
];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-[2.5px] bg-black/45 pb-2">
{#each bars as bar, i (i)}
<span
class="w-[3px] origin-bottom rounded-full bg-white"
style="height:14px; animation:equalize {bar.dur}s ease-in-out {bar.delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -0,0 +1,34 @@
<script lang="ts">
import Cover from './Cover.svelte';
import { gradientForHue, HUES } from '$lib/theme/color';
interface Props {
trackIds: (string | number)[];
hue: number;
class?: string;
}
let { trackIds, hue, class: klass = '' }: Props = $props();
function hueFor(id: string | number) {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
const mosaic = $derived(trackIds.length >= 4);
const single = $derived(trackIds.length >= 1 && trackIds.length < 4 ? trackIds[0] : null);
</script>
{#if mosaic}
<div class="grid grid-cols-2 grid-rows-2 overflow-hidden {klass}">
{#each trackIds.slice(0, 4) as id (id)}
<Cover trackId={id} hue={hueFor(id)} size="small" class="h-full w-full" />
{/each}
</div>
{:else if single !== null}
<Cover trackId={single} hue={hueFor(single)} size="medium" class={klass} />
{:else}
<div class={klass} style="background:{gradientForHue(hue)}"></div>
{/if}
+203 -73
View File
@@ -1,124 +1,254 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { HUES, accentForHue, gradientForHue } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
export interface PlayerTrack { export interface PlayerTrack {
id: string; id: string | number;
title: string; title: string;
artist: string;
duration: number;
hue: number;
} }
interface StreamResponse { export interface Playlist {
manifestUrl: string; id: string | number;
ticket: string; name: string;
expiresInSeconds: number; trackIds: (string | number)[];
}
export interface QueueItem {
id: string | number;
title: string;
artist?: string;
}
const RECENT_LIMIT = 12;
const EMPTY: PlayerTrack = { id: '', title: '', artist: '', duration: 0, hue: HUES[0] };
function hueFor(id: string | number): number {
const text = String(id);
let hash = 0;
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
return HUES[hash % HUES.length];
}
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
title: item.title,
artist: item.artist ?? '',
duration: 0,
hue: hueFor(item.id)
};
} }
class PlayerState { class PlayerState {
queue = $state<PlayerTrack[]>([]); tracks = $state<PlayerTrack[]>([]);
index = $state(-1); currentId = $state<string | number | null>(null);
isPlaying = $state(false); playing = $state(false);
progress = $state(0);
volume = $state(70);
loading = $state(false); loading = $state(false);
currentTime = $state(0); error = $state('');
duration = $state(0);
volume = $state(1);
muted = $state(false);
error = $state<string | null>(null);
current = $derived(this.index >= 0 ? (this.queue[this.index] ?? null) : null); recentlyPlayed = $state<PlayerTrack[]>([]);
hasNext = $derived(this.index < this.queue.length - 1); playlists = $state<Playlist[]>([]);
hasPrevious = $derived(this.index > 0);
accentColor = $state<string | null>(null);
gradientColor = $state<string | null>(null);
#audio: HTMLAudioElement | null = null; #audio: HTMLAudioElement | null = null;
#loadToken = 0;
#accentCache = new Map<string, Accent>();
#ensureAudio(): HTMLAudioElement { current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
if (this.#audio) return this.#audio; accent = $derived(this.accentColor ?? accentForHue(this.current.hue));
artGradient = $derived(this.gradientColor ?? gradientForHue(this.current.hue));
const audio = new Audio(); progressPercent = $derived(
audio.preload = 'metadata'; this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
audio.volume = this.volume;
audio.addEventListener('timeupdate', () => (this.currentTime = audio.currentTime));
audio.addEventListener(
'durationchange',
() => (this.duration = Number.isFinite(audio.duration) ? audio.duration : 0)
); );
audio.addEventListener('play', () => (this.isPlaying = true));
audio.addEventListener('pause', () => (this.isPlaying = false)); get isPlaying() {
audio.addEventListener('waiting', () => (this.loading = true)); return this.playing;
audio.addEventListener('playing', () => (this.loading = false)); }
audio.addEventListener('canplay', () => (this.loading = false));
#audioEl(): HTMLAudioElement | null {
if (!browser) return null;
if (this.#audio) return this.#audio;
const audio = new Audio();
audio.preload = 'auto';
audio.volume = this.volume / 100;
audio.addEventListener('timeupdate', () => {
this.progress = audio.currentTime;
});
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('play', () => (this.playing = true));
audio.addEventListener('pause', () => (this.playing = false));
audio.addEventListener('ended', () => this.next()); audio.addEventListener('ended', () => this.next());
audio.addEventListener('error', () => { audio.addEventListener('error', () => {
this.loading = false; this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.error = 'No se pudo reproducir la pista.';
}); });
this.#audio = audio; this.#audio = audio;
return audio; return audio;
} }
async playQueue(tracks: PlayerTrack[], startIndex = 0) { #syncDuration(value: number) {
this.queue = tracks; if (!Number.isFinite(value) || value <= 0) return;
await this.#load(startIndex); const id = this.currentId;
this.tracks = this.tracks.map((t) =>
t.id === id ? { ...t, duration: Math.round(value) } : t
);
} }
async #load(index: number) { #index() {
if (!browser || index < 0 || index >= this.queue.length) return; return this.tracks.findIndex((t) => t.id === this.currentId);
}
this.index = index; #pushRecent(track: PlayerTrack) {
this.error = null; if (!track.id) return;
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);
if (cached) {
this.accentColor = cached.accent;
this.gradientColor = cached.gradient;
return;
}
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(key, result);
this.accentColor = result.accent;
this.gradientColor = result.gradient;
} else {
this.accentColor = null;
this.gradientColor = null;
}
}
async #loadCurrent() {
const audio = this.#audioEl();
if (!audio || this.currentId === null) return;
const id = this.currentId;
this.#applyAccent(id);
const token = ++this.#loadToken;
this.loading = true; this.loading = true;
this.currentTime = 0; this.error = '';
this.duration = 0; this.progress = 0;
const audio = this.#ensureAudio();
try { try {
const response = await fetch(`/api/tracks/${this.queue[index].id}/stream`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!response.ok) throw new Error(String(response.status)); if (!res.ok) throw new Error(String(res.status));
const { manifestUrl, ticket } = (await res.json()) as {
const stream: StreamResponse = await response.json(); manifestUrl: string;
audio.src = `${stream.manifestUrl}?t=${encodeURIComponent(stream.ticket)}`; ticket: string;
};
if (token !== this.#loadToken) return;
const sep = manifestUrl.includes('?') ? '&' : '?';
audio.src = ticket ? `${manifestUrl}${sep}t=${encodeURIComponent(ticket)}` : manifestUrl;
audio.volume = this.volume / 100;
await audio.play(); await audio.play();
} catch {
this.loading = false; this.loading = false;
this.#pushRecent(this.current);
} catch {
if (token !== this.#loadToken) return;
this.loading = false;
this.playing = false;
this.error = 'No se pudo reproducir la pista.'; this.error = 'No se pudo reproducir la pista.';
} }
} }
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids
.map((id) => this.tracks.find((t) => t.id === id))
.filter((t): t is PlayerTrack => Boolean(t));
}
mosaicFor(ids: (string | number)[]): string[] {
const gradients = this.playlistTracks(ids)
.slice(0, 4)
.map((t) => gradientForHue(t.hue));
while (gradients.length < 4) gradients.push('var(--mf-surface)');
return gradients;
}
playQueue(list: QueueItem[], startIndex = 0) {
if (list.length === 0) return;
this.tracks = list.map(toTrack);
const start = this.tracks[startIndex] ?? this.tracks[0];
this.currentId = start.id;
this.#loadCurrent();
}
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();
}
playPlaylist(id: string | number) {
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) return;
const items = this.playlistTracks(playlist.trackIds);
if (items.length > 0) this.playQueue(items, 0);
}
toggle() { toggle() {
const audio = this.#audio; const audio = this.#audioEl();
if (!audio || !this.current) return; if (!audio || this.currentId === null) return;
if (audio.paused) audio.play(); if (audio.paused) audio.play().catch(() => {});
else audio.pause(); else audio.pause();
} }
next() { next() {
if (this.hasNext) this.#load(this.index + 1); if (this.tracks.length === 0) return;
else if (this.#audio) this.#audio.pause(); const idx = this.#index();
this.currentId = this.tracks[(idx + 1) % this.tracks.length].id;
this.#loadCurrent();
} }
previous() { previous() {
if (this.currentTime > 3 || !this.hasPrevious) this.seek(0); if (this.tracks.length === 0) return;
else this.#load(this.index - 1); const audio = this.#audioEl();
if (audio && audio.currentTime > 3) {
audio.currentTime = 0;
return;
}
const idx = this.#index();
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
this.#loadCurrent();
} }
seek(seconds: number) { seekFraction(fraction: number) {
const audio = this.#audio; const audio = this.#audioEl();
if (!audio) return; const clamped = Math.min(1, Math.max(0, fraction));
audio.currentTime = seconds; const target = clamped * this.current.duration;
this.currentTime = seconds; this.progress = target;
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
} }
setVolume(value: number) { setVolume(value: number) {
this.volume = value; this.volume = Math.min(100, Math.max(0, Math.round(value)));
this.muted = value === 0; const audio = this.#audioEl();
if (this.#audio) { if (audio) audio.volume = this.volume / 100;
this.#audio.volume = value;
this.#audio.muted = this.muted;
}
}
toggleMute() {
this.muted = !this.muted;
if (this.#audio) this.#audio.muted = this.muted;
} }
} }
@@ -0,0 +1,25 @@
import type { RequestHandler } from './$types';
import { apiConfig } from '$lib/server/config';
const ALLOWED = new Set(['small', 'medium', 'large']);
export const GET: RequestHandler = async ({ params, url, fetch }) => {
const size = url.searchParams.get('size') ?? 'medium';
const safeSize = ALLOWED.has(size) ? size : 'medium';
const upstream = await fetch(
`${apiConfig.baseUrl}/tracks/${params.id}/cover?size=${safeSize}`
);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
}
return new Response(upstream.body, {
status: 200,
headers: {
'Content-Type': upstream.headers.get('content-type') ?? 'image/webp',
'Cache-Control': 'public, max-age=86400'
}
});
};