web-player: split player, navigation and accent state into focused modules
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' }]
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -1,3 +1,4 @@
|
||||
import { createToggle } from './toggle.svelte';
|
||||
|
||||
export const queuePanel = createToggle();
|
||||
export const createPlaylistModal = createToggle();
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { albumCover } from '$lib/utils/hrefs';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Disc from '@lucide/svelte/icons/disc-3';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
@@ -42,7 +43,7 @@
|
||||
title={album.title}
|
||||
subtitle={albumMeta(album.releaseYear, album.trackCount)}
|
||||
trackIds={album.coverTrackIds}
|
||||
src="/api/albums/{album.id}/cover?size=large"
|
||||
src={albumCover(album.id, 'large')}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { albumCover } from '$lib/utils/hrefs';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
@@ -59,7 +61,7 @@
|
||||
>
|
||||
{#snippet cover()}
|
||||
<Artwork
|
||||
src="/api/albums/{album.id}/cover?size=large"
|
||||
src={albumCover(album.id, 'large')}
|
||||
trackIds={tracks.map((track) => track.id)}
|
||||
size="hero"
|
||||
alt={album.title}
|
||||
@@ -116,7 +118,7 @@
|
||||
initialTitle={album.title}
|
||||
initialDescription={album.description ?? ''}
|
||||
initialReleaseYear={album.releaseYear ?? undefined}
|
||||
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
|
||||
coverFallbackUrl={albumCover(album.id, 'medium')}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Guardar"
|
||||
submittingLabel="Guardando…"
|
||||
|
||||
@@ -18,19 +18,21 @@
|
||||
import Chip from '$lib/components/ui/primitives/Chip.svelte';
|
||||
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
|
||||
import { EXPLORE_PAGE_SIZE } from '$lib/config';
|
||||
import { genreHref } from '$lib/state/navigation.svelte';
|
||||
import { genreHref, playlistCover } from '$lib/utils/hrefs';
|
||||
import { createTrackMenu } from '$lib/state/menus.svelte';
|
||||
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
|
||||
import { contextMenuPosition } from '$lib/utils/menuPosition';
|
||||
import ListPlus from '@lucide/svelte/icons/list-plus';
|
||||
import ListEnd from '@lucide/svelte/icons/list-end';
|
||||
import { appendUnique } from '$lib/data/collections';
|
||||
import { appendUnique } from '$lib/utils/collections';
|
||||
import { genreInfo, genreTiles } from '$lib/data/genres';
|
||||
import {
|
||||
type SearchFilter,
|
||||
showGroup,
|
||||
capped,
|
||||
matchPlaylists,
|
||||
searchCounts,
|
||||
SEARCH_LABELS,
|
||||
searchTotal,
|
||||
searchChips,
|
||||
findTopResult
|
||||
} from '$lib/data/search';
|
||||
@@ -48,7 +50,7 @@
|
||||
|
||||
const trackMenu = createTrackMenu();
|
||||
let albumMenu = $state<AlbumMenuState | null>(null);
|
||||
let sfilter = $state<SearchFilter>('Todo');
|
||||
let sfilter = $state<SearchFilter>('all');
|
||||
|
||||
let localItems = $state<Track[]>([]);
|
||||
let localPage = $state(1);
|
||||
@@ -58,7 +60,7 @@
|
||||
$effect(() => {
|
||||
trackMenu.close();
|
||||
albumMenu = null;
|
||||
sfilter = data.genre ? 'Canciones' : 'Todo';
|
||||
sfilter = data.genre ? 'tracks' : 'all';
|
||||
localItems = data.tracks.items;
|
||||
localPage = data.tracks.pageNumber;
|
||||
hasMore = data.tracks.hasNextPage;
|
||||
@@ -82,18 +84,14 @@
|
||||
!!data.query && items.length === 0 && !hasAlbums && !hasUsers && !hasPlaylists
|
||||
);
|
||||
|
||||
const counts = $derived(
|
||||
searchCounts({
|
||||
const counts = $derived({
|
||||
tracks: data.tracks.totalItemCount,
|
||||
albums: data.albumsTotal,
|
||||
playlists: playlistMatches.length,
|
||||
users: data.usersTotal
|
||||
})
|
||||
);
|
||||
});
|
||||
const chips = $derived(searchChips(counts));
|
||||
const totalHits = $derived(
|
||||
counts.Canciones + counts.Álbumes + counts.Playlists + counts.Usuarios
|
||||
);
|
||||
const totalHits = $derived(searchTotal(counts));
|
||||
|
||||
type TopResult = NonNullable<
|
||||
ReturnType<
|
||||
@@ -107,7 +105,7 @@
|
||||
>;
|
||||
|
||||
const topResult = $derived<TopResult | null>(
|
||||
sfilter === 'Todo' && data.query
|
||||
sfilter === 'all' && data.query
|
||||
? findTopResult({
|
||||
query: data.query,
|
||||
tracks: items,
|
||||
@@ -135,6 +133,7 @@
|
||||
}
|
||||
|
||||
function openAlbumMenu(event: MouseEvent, albumId: string) {
|
||||
event.preventDefault();
|
||||
albumMenu = { ...contextMenuPosition(event), albumId };
|
||||
}
|
||||
|
||||
@@ -206,11 +205,11 @@
|
||||
/>
|
||||
|
||||
<div class="mb-7 flex flex-wrap gap-2 sm:mb-8">
|
||||
{#each chips as chip (chip.label)}
|
||||
{#each chips as chip (chip.filter)}
|
||||
<Chip
|
||||
selected={sfilter === chip.label}
|
||||
selected={sfilter === chip.filter}
|
||||
count={chip.count}
|
||||
onclick={() => (sfilter = chip.label)}
|
||||
onclick={() => (sfilter = chip.filter)}
|
||||
>
|
||||
{chip.label}
|
||||
</Chip>
|
||||
@@ -230,9 +229,7 @@
|
||||
<Artwork trackIds={topResult.album.coverTrackIds} size="xl" class="shrink-0" />
|
||||
{:else if topResult.kind === 'playlist'}
|
||||
<Artwork
|
||||
src="/api/playlists/{topResult.playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
topResult.playlist.updatedAt
|
||||
)}"
|
||||
src={playlistCover(topResult.playlist, 'large')}
|
||||
trackIds={topResult.playlist.coverTrackIds}
|
||||
size="xl"
|
||||
class="shrink-0"
|
||||
@@ -322,10 +319,10 @@
|
||||
|
||||
{#if (data.query || data.genre) && !nothingFound}
|
||||
<div class="mt-9 flex flex-col gap-9">
|
||||
{#if showGroup(sfilter, 'Canciones')}
|
||||
{#if showGroup(sfilter, 'tracks')}
|
||||
<div>
|
||||
{#if items.length > 0}
|
||||
<SectionHeading title="Canciones" count={counts.Canciones} />
|
||||
<SectionHeading title={SEARCH_LABELS.tracks} count={counts.tracks} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, items) as track, i (track.id)}
|
||||
<li>
|
||||
@@ -355,7 +352,7 @@
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if sfilter === 'Canciones' && hasMore}
|
||||
{#if sfilter === 'tracks' && hasMore}
|
||||
<InfiniteScroll onLoadMore={loadMore} {hasMore} loading={loadingMore} />
|
||||
{/if}
|
||||
{:else}
|
||||
@@ -369,9 +366,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasAlbums && showGroup(sfilter, 'Álbumes')}
|
||||
{#if hasAlbums && showGroup(sfilter, 'albums')}
|
||||
<div>
|
||||
<SectionHeading title="Álbumes" count={counts.Álbumes} />
|
||||
<SectionHeading title={SEARCH_LABELS.albums} count={counts.albums} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, albums) as album (album.id)}
|
||||
<li>
|
||||
@@ -394,9 +391,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasUsers && showGroup(sfilter, 'Usuarios')}
|
||||
{#if hasUsers && showGroup(sfilter, 'users')}
|
||||
<div>
|
||||
<SectionHeading title="Usuarios" count={counts.Usuarios} />
|
||||
<SectionHeading title={SEARCH_LABELS.users} count={counts.users} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, users) as u (u.id)}
|
||||
<li>
|
||||
@@ -407,9 +404,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if hasPlaylists && showGroup(sfilter, 'Playlists')}
|
||||
{#if hasPlaylists && showGroup(sfilter, 'playlists')}
|
||||
<div>
|
||||
<SectionHeading title="Playlists" count={counts.Playlists} />
|
||||
<SectionHeading title={SEARCH_LABELS.playlists} count={counts.playlists} />
|
||||
<ul class="flex flex-col gap-1">
|
||||
{#each capped(sfilter, playlistMatches) as playlist (playlist.id)}
|
||||
<li>
|
||||
@@ -418,9 +415,7 @@
|
||||
subtitle={playlist.description}
|
||||
href="/playlists/{playlist.id}"
|
||||
size="lg"
|
||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
coverSrc={playlistCover(playlist, 'medium')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
/>
|
||||
</li>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Heart from '@lucide/svelte/icons/heart';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/actions';
|
||||
import { fmtTime, plural } from '$lib/utils/format';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import Button from '$lib/components/ui/primitives/Button.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import { fmtDurationLong, plural, playlistMeta } from '$lib/utils/format';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -42,9 +43,7 @@
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(playlist.trackCount)}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
src={playlistCover(playlist, 'large')}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import X from '@lucide/svelte/icons/x';
|
||||
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle } from '$lib/player/actions';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
|
||||
import Modal from '$lib/components/ui/overlay/Modal.svelte';
|
||||
@@ -56,9 +58,7 @@
|
||||
>
|
||||
{#snippet cover()}
|
||||
<Artwork
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
src={playlistCover(playlist, 'large')}
|
||||
trackIds={tracks.map((t) => t.id)}
|
||||
size="hero"
|
||||
alt={playlist.name}
|
||||
@@ -110,9 +110,7 @@
|
||||
initialName={playlist.name}
|
||||
initialDescription={playlist.description ?? ''}
|
||||
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
|
||||
coverFallbackUrl="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
coverFallbackUrl={playlistCover(playlist, 'medium')}
|
||||
formMessage={form?.message}
|
||||
submitLabel="Guardar"
|
||||
submittingLabel="Guardando…"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import Page from '$lib/components/ui/layout/Page.svelte';
|
||||
import MediaCard from '$lib/components/ui/media/MediaCard.svelte';
|
||||
@@ -62,9 +63,7 @@
|
||||
title={playlist.name}
|
||||
subtitle={playlistMeta(playlist.trackCount)}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
src={playlistCover(playlist, 'large')}
|
||||
index={i}
|
||||
/>
|
||||
{/each}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
|
||||
import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte';
|
||||
import UserRow from '$lib/components/ui/media/UserRow.svelte';
|
||||
import { appendUnique } from '$lib/data/collections';
|
||||
import { appendUnique } from '$lib/utils/collections';
|
||||
import { plural } from '$lib/utils/format';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { LayoutData } from './$types';
|
||||
import '@fontsource-variable/sora/index.css';
|
||||
import '@fontsource-variable/plus-jakarta-sans/index.css';
|
||||
import '$lib/theme/theme.css';
|
||||
@@ -13,16 +15,23 @@
|
||||
import DialogHost from '$lib/components/ui/overlay/DialogHost.svelte';
|
||||
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { queuePanel } from '$lib/player/queuePanel.svelte';
|
||||
import { queuePanel } from '$lib/state/panels.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/panels.svelte';
|
||||
import { ACCENT_HUE } from '$lib/theme/color';
|
||||
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
||||
import { buildThemeTokens, tokensToCss } from '$lib/theme/tokens';
|
||||
import { accent, animateThemeHue } from '$lib/theme/accent.svelte';
|
||||
import { themeMode } from '$lib/theme/mode.svelte';
|
||||
import { trackNavigation, restoreScroll } from '$lib/state/navigation.svelte';
|
||||
import { trackNavigation } from '$lib/state/history.svelte';
|
||||
import { restoreScroll } from '$lib/state/scroll';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children, data } = $props();
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
data: LayoutData;
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
let scroller = $state<HTMLElement>();
|
||||
|
||||
@@ -60,35 +69,7 @@
|
||||
player.toggle();
|
||||
}
|
||||
|
||||
function parseHue(value: string): number | null {
|
||||
const m = /oklch\(\s*[\d.]+%?\s+[\d.]+\s+([\d.]+)/.exec(value);
|
||||
return m ? parseFloat(m[1]) : null;
|
||||
}
|
||||
|
||||
let displayedHue = ACCENT_HUE;
|
||||
let rafId = 0;
|
||||
|
||||
$effect(() => {
|
||||
const targetHue = parseHue(player.accent) ?? ACCENT_HUE;
|
||||
const mode = themeMode.current;
|
||||
cancelAnimationFrame(rafId);
|
||||
const fromHue = displayedHue;
|
||||
let dh = targetHue - fromHue;
|
||||
if (dh > 180) dh -= 360;
|
||||
else if (dh < -180) dh += 360;
|
||||
const start = performance.now();
|
||||
const duration = 2000 + Math.random() * 2000;
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - start) / duration);
|
||||
const eased = 1 - Math.pow(1 - t, 3);
|
||||
const hue = (((fromHue + dh * eased) % 360) + 360) % 360;
|
||||
displayedHue = hue;
|
||||
applyThemeTokens(buildThemeTokens(hue, mode));
|
||||
if (t < 1) rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
rafId = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
});
|
||||
$effect(() => animateThemeHue(accent.hue, themeMode.current));
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onWindowKeydown} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { playlistCover } from '$lib/utils/hrefs';
|
||||
import { goto } from '$app/navigation';
|
||||
import History from '@lucide/svelte/icons/history';
|
||||
import Shuffle from '@lucide/svelte/icons/shuffle';
|
||||
import ListMusic from '@lucide/svelte/icons/list-music';
|
||||
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
|
||||
import { player } from '$lib/player/player.svelte';
|
||||
import { isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/actions';
|
||||
import type { Track } from '$lib/types';
|
||||
import { mergeRecentlyPlayed } from '$lib/data/recentlyPlayed';
|
||||
import { fetchPlaylistTracks } from '$lib/data/albums';
|
||||
@@ -165,9 +167,7 @@
|
||||
class="flex w-fit items-center gap-5 self-start sm:gap-6"
|
||||
>
|
||||
<Artwork
|
||||
src="/api/playlists/{spotlight.id}/cover?size=large&v={encodeURIComponent(
|
||||
spotlight.updatedAt
|
||||
)}"
|
||||
src={playlistCover(spotlight, 'large')}
|
||||
trackIds={spotlight.coverTrackIds}
|
||||
size="2xl"
|
||||
alt={spotlight.name}
|
||||
@@ -261,9 +261,7 @@
|
||||
variant="card"
|
||||
title={playlist.name}
|
||||
subtitle={playlist.description}
|
||||
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
|
||||
playlist.updatedAt
|
||||
)}"
|
||||
coverSrc={playlistCover(playlist, 'medium')}
|
||||
trackIds={playlist.coverTrackIds}
|
||||
style="--i:{i}"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user