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;