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,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({
tracks: data.tracks.totalItemCount,
albums: data.albumsTotal,
playlists: playlistMatches.length,
users: data.usersTotal
})
);
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();
+15 -34
View File
@@ -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} />
+5 -7
View File
@@ -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}"
/>