web-player: split player, navigation and accent state into focused modules

This commit is contained in:
2026-09-24 05:25:35 +02:00
parent e89463fb83
commit 5cda7cb4c1
47 changed files with 520 additions and 457 deletions
@@ -1,8 +1,9 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import { appNavLinks, isNavActive } from './navLinks';
import { playlistCover } from '$lib/utils/hrefs';
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/state/playlists.svelte';
import { createPlaylistModal } from '$lib/state/panels.svelte';
import type { SessionUser } from '$lib/types';
import ListRow from '../ui/media/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte';
@@ -14,7 +15,7 @@
interface SidebarPlaylist {
id: string;
name: string;
coverTrackIds: (string | number)[];
coverTrackIds: string[];
updatedAt: string;
}
@@ -29,10 +30,6 @@
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<aside
@@ -45,7 +42,7 @@
<nav class="flex flex-col gap-1">
{#each navLinks as link (link.href)}
{@const active = isActive(link.href)}
{@const active = isNavActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
@@ -86,9 +83,7 @@
{active}
size="xs"
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
coverSrc={playlistCover(playlist, 'small')}
trackIds={playlist.coverTrackIds}
class="px-3 py-2"
/>
@@ -1,6 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
import { appNavLinks, isNavActive } from './navLinks';
import type { SessionUser } from '$lib/types';
interface Props {
@@ -11,10 +10,6 @@
let { user, class: klass = '' }: Props = $props();
const links = $derived(appNavLinks(user).filter((link) => link.primary));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
}
</script>
<nav
@@ -22,7 +17,7 @@
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
{@const active = isActive(link.href)}
{@const active = isNavActive(link.href)}
<a
href={link.href}
aria-current={active ? 'page' : undefined}
@@ -3,7 +3,7 @@
import { page } from '$app/state';
import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/state/navigation.svelte';
import { searchHref } from '$lib/utils/hrefs';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
@@ -0,0 +1,48 @@
import { page } from '$app/state';
import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import type { SessionUser } from '$lib/types';
export interface AppNavLink {
href: string;
label: string;
icon: LucideIcon;
count?: number;
primary: boolean;
}
export interface AppNavCounts {
playlists?: number;
liked?: number;
}
export function isNavActive(href: string): boolean {
const section: string | null | undefined = page.data.section;
if (section !== undefined) return section === href;
return href === '/' ? page.url.pathname === '/' : page.url.pathname.startsWith(href);
}
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
return [
{ href: '/', label: 'Inicio', icon: Home, primary: true },
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
{
href: '/playlists',
label: 'Playlists',
icon: ListMusic,
count: counts.playlists,
primary: true
},
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
];
}
@@ -4,7 +4,7 @@
import Slider from '$lib/components/ui/primitives/Slider.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import { queuePanel } from '$lib/state/panels.svelte';
import { fmtTime } from '$lib/utils/format';
import Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2';
@@ -1,6 +1,6 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import { queuePanel } from '$lib/state/panels.svelte';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
interface Props {
name?: string | null;
ownerUserId?: string | number | null;
ownerUserId?: string | null;
class?: string;
}
@@ -1,11 +1,12 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { trackCover } from '$lib/utils/hrefs';
type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'hero' | 'fill';
interface Props {
src?: string | null;
trackIds?: (string | number)[];
trackIds?: string[];
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
@@ -76,7 +77,6 @@
const showSrc = $derived(!!src && !srcFailed);
const showMosaic = $derived(!showSrc && trackIds.length >= 4);
const singleId = $derived(!showSrc && !showMosaic && trackIds.length > 0 ? trackIds[0] : null);
const showSingle = $derived(singleId !== null && !singleFailed);
</script>
<div class="relative overflow-hidden {DIMENSION[size]} {radius} {klass}">
@@ -94,7 +94,7 @@
<div class="grid h-full w-full grid-cols-2 grid-rows-2">
{#each trackIds.slice(0, 4) as id (id)}
<img
src="/api/tracks/{id}/cover?size=small"
src={trackCover(id, 'small')}
alt=""
loading="lazy"
decoding="async"
@@ -104,9 +104,9 @@
/>
{/each}
</div>
{:else if showSingle}
{:else if singleId !== null && !singleFailed}
<img
src="/api/tracks/{singleId}/cover?size={resolvedImageSize}"
src={trackCover(singleId, resolvedImageSize)}
{alt}
loading="lazy"
decoding="async"
@@ -12,10 +12,10 @@
variant?: 'row' | 'card';
title: string;
subtitle?: string | null;
subtitleHref?: string | number | null;
subtitleHref?: string | null;
explicit?: boolean;
trackId?: string | number;
trackIds?: (string | number)[];
trackId?: string;
trackIds?: string[];
coverSrc?: string | null;
art?: Snippet;
overlay?: Snippet;
@@ -8,7 +8,7 @@
title: string;
subtitle?: string | null;
src?: string | null;
trackIds?: (string | number)[];
trackIds?: string[];
onPlay?: (event: MouseEvent) => void;
art?: Snippet;
oncontextmenu?: (event: MouseEvent) => void;
@@ -5,11 +5,11 @@
import ArtistLink from './ArtistLink.svelte';
interface Props {
trackId?: string | number;
trackIds?: (string | number)[];
trackId?: string;
trackIds?: string[];
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
ownerUserId?: string | null;
coverSrc?: string | null;
size?: 'xs' | 'sm' | 'lg';
explicit?: boolean;
@@ -1,24 +1,6 @@
<script lang="ts" module>
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
export interface MenuPosition {
x: number;
y: number;
openLeft: boolean;
}
export function contextMenuPosition(event: MouseEvent): MenuPosition {
event.preventDefault();
return {
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import { playlistCover } from '$lib/utils/hrefs';
import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import MediaIdentity from '../media/MediaIdentity.svelte';
@@ -32,7 +14,7 @@
onClose: () => void;
items: { icon: LucideIcon; label: string; onclick: () => void }[];
playlistAction?: { action: string; fields: Record<string, string>; label: string };
playlists?: { id: string; name: string }[];
playlists?: { id: string; name: string; updatedAt: string }[];
}
let { x, y, openLeft, onClose, items, playlistAction, playlists = [] }: Props = $props();
@@ -97,7 +79,7 @@
>
<MediaIdentity
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
coverSrc={playlistCover(playlist, 'small')}
size="xs"
titleClass="text-fg-2"
/>
@@ -1,6 +1,6 @@
<script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { previousPage } from '$lib/state/navigation.svelte';
import { previousPage } from '$lib/state/history.svelte';
interface Props {
href: string;
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findTopResult } from './search';
import { findTopResult, searchChips } from './search';
const empty = { tracks: [], albums: [], playlists: [], users: [] };
@@ -26,3 +26,17 @@ describe('findTopResult', () => {
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
});
});
describe('searchChips', () => {
it('adds an "all" chip with the total', () => {
const chips = searchChips({ tracks: 3, albums: 2, playlists: 0, users: 1 });
expect(chips.map((chip) => [chip.filter, chip.count])).toEqual([
['all', 6],
['tracks', 3],
['albums', 2],
['playlists', 0],
['users', 1]
]);
expect(chips[0].label).toBe('Todo');
});
});
+24 -22
View File
@@ -1,38 +1,40 @@
export const SEARCH_FILTERS = ['Todo', 'Canciones', 'Álbumes', 'Playlists', 'Usuarios'] as const;
export type SearchFilter = (typeof SEARCH_FILTERS)[number];
export const SEARCH_KINDS = ['tracks', 'albums', 'playlists', 'users'] as const;
export type SearchKind = (typeof SEARCH_KINDS)[number];
export type SearchFilter = 'all' | SearchKind;
export const SEARCH_GROUP_PREVIEW = 4;
export function showGroup(filter: SearchFilter, label: Exclude<SearchFilter, 'Todo'>) {
return filter === 'Todo' || filter === label;
export const SEARCH_LABELS: Record<SearchFilter, string> = {
all: 'Todo',
tracks: 'Canciones',
albums: 'Álbumes',
playlists: 'Playlists',
users: 'Usuarios'
};
export function showGroup(filter: SearchFilter, kind: SearchKind) {
return filter === 'all' || filter === kind;
}
export function capped<T>(filter: SearchFilter, list: T[]) {
return filter === 'Todo' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
return filter === 'all' ? list.slice(0, SEARCH_GROUP_PREVIEW) : list;
}
export function matchPlaylists<T extends { name: string }>(playlists: T[], query: string): T[] {
return query ? playlists.filter((p) => p.name.toLowerCase().includes(query.toLowerCase())) : [];
}
export function searchCounts(counts: {
tracks: number;
albums: number;
playlists: number;
users: number;
}) {
return {
Canciones: counts.tracks,
Álbumes: counts.albums,
Playlists: counts.playlists,
Usuarios: counts.users
};
export type SearchCounts = Record<SearchKind, number>;
export function searchTotal(counts: SearchCounts): number {
return SEARCH_KINDS.reduce((sum, kind) => sum + counts[kind], 0);
}
export function searchChips(counts: ReturnType<typeof searchCounts>) {
const total = counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios;
return SEARCH_FILTERS.map((label) => ({
label,
count: label === 'Todo' ? total : counts[label]
export function searchChips(counts: SearchCounts) {
const filters: SearchFilter[] = ['all', ...SEARCH_KINDS];
return filters.map((filter) => ({
filter,
label: SEARCH_LABELS[filter],
count: filter === 'all' ? searchTotal(counts) : counts[filter]
}));
}
+18
View File
@@ -0,0 +1,18 @@
import { shuffle } from '$lib/utils/collections';
import type { Track } from '$lib/types';
import { player } from './player.svelte';
export function isQueueCurrent(items: { id: string }[]): boolean {
return items.some((item) => item.id === player.currentId);
}
export function playAllOrToggle(items: Track[]) {
if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: Track[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
+36
View File
@@ -0,0 +1,36 @@
import { browser } from '$app/environment';
import type { Track } from '$lib/types';
import { trackCover } from '$lib/utils/hrefs';
interface MediaSessionHandlers {
play: () => void;
pause: () => void;
previous: () => void;
next: () => void;
}
const supported = () => browser && 'mediaSession' in navigator;
export const mediaSession = {
setup(handlers: MediaSessionHandlers) {
if (!supported()) return;
navigator.mediaSession.setActionHandler('play', handlers.play);
navigator.mediaSession.setActionHandler('pause', handlers.pause);
navigator.mediaSession.setActionHandler('previoustrack', handlers.previous);
navigator.mediaSession.setActionHandler('nexttrack', handlers.next);
},
setPlaying(playing: boolean) {
if (!supported()) return;
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused';
},
setTrack(track: Track) {
if (!supported()) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist ?? '',
artwork: [{ src: trackCover(track.id, 'small'), sizes: '256x256' }]
});
}
};
+32 -131
View File
@@ -1,36 +1,14 @@
import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { accent } from '$lib/theme/accent.svelte';
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 { readStorage, writeStorage } from '$lib/utils/storage';
import { trackCover } from '$lib/utils/hrefs';
import { ListenTracker } from './listenTracker';
import { mediaSession } from './mediaSession';
import { ProgressClock } from './progressClock';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
export function isQueueCurrent(items: { id: string }[]): boolean {
return items.some((item) => item.id === player.currentId);
}
export function playAllOrToggle(items: Track[]) {
if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: Track[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
import { resolveStream } from './stream';
const RECENT_LIMIT = 15;
const DEFAULT_VOLUME = 100;
@@ -60,16 +38,15 @@ class PlayerState {
shuffle = $state(false);
repeat = $state(false);
accentColor = $state<string | null>(null);
#audio: HTMLAudioElement | null = null;
#loadToken = 0;
#accentCache = new Map<string, string>();
#rafId: number | null = null;
#listen = new ListenTracker();
#clock = new ProgressClock(
() => this.#audio,
(value) => (this.progress = value)
);
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.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
@@ -92,18 +69,18 @@ class PlayerState {
audio.addEventListener('play', () => {
this.#listen.resync(audio.currentTime);
this.playing = true;
this.#startProgressLoop();
this.#syncMediaSessionPlaybackState();
this.#clock.start();
mediaSession.setPlaying(true);
});
audio.addEventListener('pause', () => {
this.#listen.interrupt();
this.#listen.flush();
this.playing = false;
this.#stopProgressLoop();
this.#syncMediaSessionPlaybackState();
this.#clock.stop();
mediaSession.setPlaying(false);
});
audio.addEventListener('ended', () => {
this.#stopProgressLoop();
this.#clock.stop();
this.#listen.tick(audio.currentTime);
this.#listen.begin(null);
if (this.repeat) {
@@ -119,7 +96,7 @@ class PlayerState {
this.next();
});
audio.addEventListener('error', () => {
this.#stopProgressLoop();
this.#clock.stop();
this.loading = false;
this.playing = false;
this.#fail();
@@ -129,7 +106,12 @@ class PlayerState {
if (document.visibilityState === 'hidden') this.#listen.flush();
});
window.addEventListener('pagehide', () => this.#listen.flush());
this.#setupMediaSession(audio);
mediaSession.setup({
play: () => audio.play().catch(() => {}),
pause: () => audio.pause(),
previous: () => this.previous(),
next: () => this.next()
});
return audio;
}
@@ -137,56 +119,7 @@ class PlayerState {
audio.currentTime = 0;
this.progress = 0;
this.playing = false;
this.#syncMediaSessionPlaybackState();
}
#setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
navigator.mediaSession.setActionHandler('pause', () => audio.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => this.previous());
navigator.mediaSession.setActionHandler('nexttrack', () => this.next());
}
#syncMediaSessionPlaybackState() {
if (!browser || !('mediaSession' in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
}
#syncMediaSessionMetadata(track: Track) {
if (!browser || !('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist ?? '',
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
});
}
#clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
const audio = this.#audio;
if (!audio) return;
if (audio.currentTime !== this.#clockTime) {
this.#clockTime = audio.currentTime;
this.#clockStamp = now;
}
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
#startProgressLoop() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tickProgress);
}
#stopProgressLoop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
mediaSession.setPlaying(false);
}
#syncDuration(value: number) {
@@ -206,20 +139,9 @@ class PlayerState {
);
}
async #applyAccent(id: string) {
const cached = this.#accentCache.get(id);
if (cached) {
this.accentColor = cached;
return;
}
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(id, result);
this.accentColor = result;
} else {
this.accentColor = null;
}
#announce(track: Track) {
accent.follow(trackCover(track.id, 'small'), () => this.currentId === track.id);
mediaSession.setTrack(track);
}
async #loadCurrent() {
@@ -227,14 +149,13 @@ class PlayerState {
if (!audio || this.currentId === null) return;
const id = this.currentId;
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id);
if (track) this.#syncMediaSessionMetadata(track);
if (track) this.#announce(track);
this.#listen.begin(null);
const token = ++this.#loadToken;
this.loading = true;
this.progress = 0;
try {
const { src, listenId } = await this.#resolveLocalSrc(String(id));
const { src, listenId } = await resolveStream(id);
if (token !== this.#loadToken) return;
audio.src = src;
audio.volume = this.volume / 100;
@@ -260,42 +181,22 @@ class PlayerState {
);
}
async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket, listenId } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
return { src: this.#withTicket(manifestUrl, ticket), listenId: listenId ?? null };
}
async #beginRepeatListen() {
const id = this.currentId;
if (id === null) return;
try {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) return;
const { listenId } = (await res.json()) as { listenId?: string | null };
if (this.currentId === id) this.#listen.begin(listenId ?? null);
const { listenId } = await resolveStream(id);
if (this.currentId === id) this.#listen.begin(listenId);
} catch {
return;
}
}
#withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
}
hydrate(track: Track) {
if (this.currentId !== null || this.tracks.length > 0) return;
this.tracks = [track];
this.currentId = track.id;
this.#applyAccent(track.id);
this.#syncMediaSessionMetadata(track);
this.#announce(track);
}
playQueue(list: Track[], startIndex = 0) {
@@ -0,0 +1,36 @@
const MAX_INTERPOLATION_SECONDS = 0.25;
export class ProgressClock {
#rafId: number | null = null;
#time = 0;
#stamp = 0;
constructor(
private getAudio: () => HTMLAudioElement | null,
private onProgress: (seconds: number) => void
) {}
#tick = (now: number) => {
const audio = this.getAudio();
if (!audio) return;
if (audio.currentTime !== this.#time) {
this.#time = audio.currentTime;
this.#stamp = now;
}
const elapsed = Math.min(MAX_INTERPOLATION_SECONDS, (now - this.#stamp) / 1000);
const smooth = this.#time + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.onProgress(Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth));
this.#rafId = requestAnimationFrame(this.#tick);
};
start() {
if (this.#rafId !== null) return;
this.#rafId = requestAnimationFrame(this.#tick);
}
stop() {
if (this.#rafId === null) return;
cancelAnimationFrame(this.#rafId);
this.#rafId = null;
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
const t = (id: string) => ({ id });
const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id);
const ids = (queue: { id: string }[]) => queue.map((item) => item.id);
describe('insertNext', () => {
it('inserts right after the current track without mutating', () => {
+1 -1
View File
@@ -1,5 +1,5 @@
interface Identified {
id: string | number;
id: string;
}
export function insertNext<T extends Identified>(
@@ -1,3 +0,0 @@
import { createToggle } from '../state/toggle.svelte';
export const queuePanel = createToggle();
+25
View File
@@ -0,0 +1,25 @@
async function readErrorMessage(res: Response): Promise<string> {
try {
const body = (await res.json()) as { message?: string };
return body?.message || `Error ${res.status}`;
} catch {
return `Error ${res.status}`;
}
}
function withTicket(url: string, ticket: string): string {
if (!ticket) return url;
const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}t=${encodeURIComponent(ticket)}`;
}
export async function resolveStream(id: string): Promise<{ src: string; listenId: string | null }> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket, listenId } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
return { src: withTicket(manifestUrl, ticket), listenId: listenId ?? null };
}
@@ -0,0 +1,51 @@
import { afterNavigate } from '$app/navigation';
const HISTORY_INDEX = 'sveltekit:history';
const LABELS: [RegExp, string][] = [
[/^\/$/, 'Volver al inicio'],
[/^\/explore\/?$/, 'Volver a explorar'],
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
[/^\/playlists\/?$/, 'Volver a tus listas'],
[/^\/albums\/[^/]+/, 'Volver al álbum'],
[/^\/albums\/?$/, 'Volver a tus álbumes'],
[/^\/library\/?$/, 'Volver a tus canciones'],
[/^\/upload\/?$/, 'Volver a subir música'],
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
];
export interface PageRef {
url: string;
label: string;
}
const visited = new Map<number, PageRef>();
let index = $state(-1);
function labelFor(pathname: string): string | null {
for (const [pattern, label] of LABELS) {
if (pattern.test(pathname)) return label;
}
return null;
}
export function historyIndex(): number | null {
const value = history.state?.[HISTORY_INDEX];
return typeof value === 'number' ? value : null;
}
export function trackNavigation() {
afterNavigate((nav) => {
const at = historyIndex();
if (at === null) return;
const url = nav.to?.url;
const label = url ? labelFor(url.pathname) : null;
if (url && label) visited.set(at, { url: url.pathname + url.search, label });
else visited.delete(at);
index = at;
});
}
export function previousPage(): PageRef | null {
return visited.get(index - 1) ?? null;
}
+2 -4
View File
@@ -1,7 +1,4 @@
import {
contextMenuPosition,
type MenuPosition
} from '$lib/components/ui/overlay/ContextMenu.svelte';
import { contextMenuPosition, type MenuPosition } from '$lib/utils/menuPosition';
import { player } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
@@ -15,6 +12,7 @@ export function createTrackMenu() {
return state;
},
open(event: MouseEvent, track: Track) {
event.preventDefault();
state = { track, ...contextMenuPosition(event) };
},
close() {
@@ -1,125 +0,0 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
import ListMusic from '@lucide/svelte/icons/list-music';
import Heart from '@lucide/svelte/icons/heart';
import Disc from '@lucide/svelte/icons/disc-2';
import Folder from '@lucide/svelte/icons/folder';
import Upload from '@lucide/svelte/icons/upload';
import type { SessionUser } from '../types';
const HISTORY_INDEX = 'sveltekit:history';
const LABELS: [RegExp, string][] = [
[/^\/$/, 'Volver al inicio'],
[/^\/explore\/?$/, 'Volver a explorar'],
[/^\/playlists\/[^/]+/, 'Volver a la lista'],
[/^\/playlists\/?$/, 'Volver a tus listas'],
[/^\/albums\/[^/]+/, 'Volver al álbum'],
[/^\/albums\/?$/, 'Volver a tus álbumes'],
[/^\/library\/?$/, 'Volver a tus canciones'],
[/^\/upload\/?$/, 'Volver a subir música'],
[/^\/mixes\/[^/]+/, 'Volver a la mezcla']
];
export interface PageRef {
url: string;
label: string;
}
const visited = new Map<number, PageRef>();
let index = $state(-1);
function labelFor(pathname: string): string | null {
for (const [pattern, label] of LABELS) {
if (pattern.test(pathname)) return label;
}
return null;
}
function historyIndex(): number | null {
const value = history.state?.[HISTORY_INDEX];
return typeof value === 'number' ? value : null;
}
export function trackNavigation() {
afterNavigate((nav) => {
const at = historyIndex();
if (at === null) return;
const url = nav.to?.url;
const label = url ? labelFor(url.pathname) : null;
if (url && label) visited.set(at, { url: url.pathname + url.search, label });
else visited.delete(at);
index = at;
});
}
const scrollPositions = new Map<number, number>();
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
let current: number | null = null;
beforeNavigate(() => {
const scroller = getScroller();
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
});
afterNavigate((nav) => {
current = historyIndex();
const scroller = getScroller();
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
const saved =
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
scroller.scrollTop = saved ?? 0;
});
}
export function previousPage(): PageRef | null {
return visited.get(index - 1) ?? null;
}
export function isSectionActive(href: string, pathname: string, section?: string | null): boolean {
if (section !== undefined) return section === href;
return href === '/' ? pathname === '/' : pathname.startsWith(href);
}
export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
export function genreHref(genre: string): string {
return `/explore?genre=${encodeURIComponent(genre)}`;
}
export interface AppNavLink {
href: string;
label: string;
icon: LucideIcon;
count?: number;
primary: boolean;
}
export interface AppNavCounts {
playlists?: number;
liked?: number;
}
export function appNavLinks(user: SessionUser | null, counts: AppNavCounts = {}): AppNavLink[] {
if (!user) return [{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true }];
return [
{ href: '/', label: 'Inicio', icon: Home, primary: true },
{ href: '/explore', label: 'Descubrir', icon: Compass, primary: true },
{
href: '/playlists',
label: 'Playlists',
icon: ListMusic,
count: counts.playlists,
primary: true
},
{ href: '/liked', label: 'Me gusta', icon: Heart, count: counts.liked, primary: true },
{ href: '/albums', label: 'Mis álbumes', icon: Disc, primary: false },
{ href: '/library', label: 'Canciones subidas', icon: Folder, primary: false },
{ href: '/upload', label: 'Subir música', icon: Upload, primary: false }
];
}
@@ -1,3 +1,4 @@
import { createToggle } from './toggle.svelte';
export const queuePanel = createToggle();
export const createPlaylistModal = createToggle();
+22
View File
@@ -0,0 +1,22 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { historyIndex } from './history.svelte';
const scrollPositions = new Map<number, number>();
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
let current: number | null = null;
beforeNavigate(() => {
const scroller = getScroller();
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
});
afterNavigate((nav) => {
current = historyIndex();
const scroller = getScroller();
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
const saved =
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
scroller.scrollTop = saved ?? 0;
});
}
+49
View File
@@ -0,0 +1,49 @@
import { browser } from '$app/environment';
import { buildThemeTokens, applyThemeTokens } from './tokens';
import { extractAccentHue } from './palette';
import { ACCENT_HUE } from './color';
const FADE_MIN_MS = 2000;
const FADE_EXTRA_MS = 2000;
class AccentState {
hue = $state<number>(ACCENT_HUE);
#cache = new Map<string, number>();
async follow(coverUrl: string, isStillCurrent: () => boolean) {
const cached = this.#cache.get(coverUrl);
if (cached !== undefined) {
this.hue = cached;
return;
}
const extracted = await extractAccentHue(coverUrl);
if (!isStillCurrent()) return;
if (extracted !== null) this.#cache.set(coverUrl, extracted);
this.hue = extracted ?? ACCENT_HUE;
}
}
export const accent = new AccentState();
let displayedHue: number = ACCENT_HUE;
export function animateThemeHue(targetHue: number, mode: 'dark' | 'light'): () => void {
if (!browser) return () => {};
const fromHue = displayedHue;
let delta = targetHue - fromHue;
if (delta > 180) delta -= 360;
else if (delta < -180) delta += 360;
const start = performance.now();
const duration = FADE_MIN_MS + Math.random() * FADE_EXTRA_MS;
let rafId = 0;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
const hue = (((fromHue + delta * eased) % 360) + 360) % 360;
displayedHue = hue;
applyThemeTokens(buildThemeTokens(hue, mode));
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafId);
}
-4
View File
@@ -1,5 +1 @@
export const ACCENT_LIGHTNESS = 74;
export const ACCENT_CHROMA = 0.06;
export const ACCENT_HUE = 30;
export const DEFAULT_ACCENT = `oklch(${ACCENT_LIGHTNESS}% ${ACCENT_CHROMA} ${ACCENT_HUE})`;
+2 -11
View File
@@ -1,5 +1,4 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
function srgbToLinear(channel: number): number {
const c = channel / 255;
@@ -40,7 +39,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
const BUCKETS = 36;
export async function extractAccent(url: string): Promise<string | null> {
export async function extractAccentHue(url: string): Promise<number | null> {
if (!browser) return null;
let img: HTMLImageElement;
@@ -68,7 +67,6 @@ export async function extractAccent(url: string): Promise<string | null> {
const weight = new Array(BUCKETS).fill(0);
const hueSin = new Array(BUCKETS).fill(0);
const hueCos = new Array(BUCKETS).fill(0);
const chromaSum = new Array(BUCKETS).fill(0);
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3] < 128) continue;
@@ -81,7 +79,6 @@ export async function extractAccent(url: string): Promise<string | null> {
weight[bucket] += w;
hueSin[bucket] += Math.sin(rad) * w;
hueCos[bucket] += Math.cos(rad) * w;
chromaSum[bucket] += C * w;
}
let best = -1;
@@ -94,11 +91,5 @@ export async function extractAccent(url: string): Promise<string | null> {
}
if (best < 0) return null;
const hue = ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
const chroma = Math.min(0.11, Math.max(0.045, chromaSum[best] / weight[best]));
const h = hue.toFixed(1);
const c = chroma.toFixed(3);
return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
return ((Math.atan2(hueSin[best], hueCos[best]) * 180) / Math.PI + 360) % 360;
}
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { albumCover, genreHref, playlistCover, searchHref, trackCover } from './hrefs';
describe('hrefs', () => {
it('builds search and genre links', () => {
expect(searchHref('')).toBe('/explore');
expect(searchHref('a b')).toBe('/explore?q=a%20b');
expect(genreHref('hip hop')).toBe('/explore?genre=hip%20hop');
});
it('builds cover urls', () => {
expect(trackCover('t', 'small')).toBe('/api/tracks/t/cover?size=small');
expect(albumCover('a', 'large')).toBe('/api/albums/a/cover?size=large');
expect(playlistCover({ id: 'p', updatedAt: '2026-01-01T00:00:00Z' }, 'medium')).toBe(
'/api/playlists/p/cover?size=medium&v=2026-01-01T00%3A00%3A00Z'
);
});
});
+24
View File
@@ -0,0 +1,24 @@
type CoverSize = 'small' | 'medium' | 'large';
export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
export function genreHref(genre: string): string {
return `/explore?genre=${encodeURIComponent(genre)}`;
}
export function trackCover(id: string, size: CoverSize): string {
return `/api/tracks/${id}/cover?size=${size}`;
}
export function albumCover(id: string, size: CoverSize): string {
return `/api/albums/${id}/cover?size=${size}`;
}
export function playlistCover(
playlist: { id: string; updatedAt: string },
size: CoverSize
): string {
return `/api/playlists/${playlist.id}/cover?size=${size}&v=${encodeURIComponent(playlist.updatedAt)}`;
}
+15
View File
@@ -0,0 +1,15 @@
import { MENU_WIDTH, SUBMENU_WIDTH } from '$lib/config';
export interface MenuPosition {
x: number;
y: number;
openLeft: boolean;
}
export function contextMenuPosition(event: MouseEvent): MenuPosition {
return {
x: Math.max(8, Math.min(event.clientX, window.innerWidth - MENU_WIDTH - 8)),
y: Math.max(8, Math.min(event.clientY, window.innerHeight - 60)),
openLeft: event.clientX + MENU_WIDTH + SUBMENU_WIDTH + 16 > window.innerWidth
};
}