Web player general changes #22

Merged
NotS1mp merged 27 commits from web-player-general-changes into master 2026-09-23 22:37:30 +00:00
10 changed files with 61 additions and 42 deletions
Showing only changes of commit cfdc61a4b8 - Show all commits
+5 -1
View File
@@ -57,9 +57,13 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## App shell
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding.
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" class="scrollbar-stable">
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -31,7 +31,7 @@
</script>
<aside
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="mb-4">
@@ -73,7 +73,7 @@
{/if}
</div>
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow
@@ -18,7 +18,7 @@
</script>
<nav
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
@@ -17,7 +17,7 @@
</script>
<div
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden"
class="animate-enter pointer-events-auto flex items-center gap-icon-md px-4 py-3 glass-bar sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
@@ -26,7 +26,7 @@
</div>
<div
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
>
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo />
@@ -13,9 +13,7 @@
const hasTrack = $derived(player.currentId !== null);
</script>
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
>
<div class="flex shrink-0 flex-col">
{#if hasTrack}
<PlayerBar />
{/if}
@@ -43,8 +43,8 @@
inert={!queuePanel.open}
>
<aside
class="flex shrink-0 flex-col border-l border-hairline px-4 pt-5"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
style="width:var(--mf-queue-w)"
>
<div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
@@ -82,7 +82,7 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex flex-1 flex-col gap-px overflow-y-auto"
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
ondragover={(event) => dragFrom !== null && event.preventDefault()}
ondrop={onDrop}
>
+21 -1
View File
@@ -1,4 +1,4 @@
import { afterNavigate } from '$app/navigation';
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';
@@ -55,6 +55,26 @@ export function trackNavigation() {
});
}
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;
}
+9 -5
View File
@@ -19,12 +19,15 @@
import { ACCENT_HUE } from '$lib/theme/color';
import { buildThemeTokens, applyThemeTokens, tokensToCss } from '$lib/theme/tokens';
import { themeMode } from '$lib/theme/mode.svelte';
import { trackNavigation } from '$lib/state/navigation.svelte';
import { trackNavigation, restoreScroll } from '$lib/state/navigation.svelte';
import { page } from '$app/state';
let { children, data } = $props();
let scroller = $state<HTMLElement>();
trackNavigation();
restoreScroll(() => scroller);
$effect(() => {
if (data.likedTracks?.length) liked.hydrate(data.likedTracks);
@@ -35,7 +38,6 @@
});
const isAuthPage = $derived(page.url.pathname === '/auth');
const hasTrack = $derived(player.currentId !== null);
const initialThemeCss = `:root{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'dark'))}}:root[data-theme='light']{${tokensToCss(buildThemeTokens(ACCENT_HUE, 'light'))}}`;
@@ -101,17 +103,18 @@
{@render children()}
</div>
{:else}
<div class="relative flex min-h-screen text-fg antialiased">
<div class="relative flex h-dvh overflow-hidden text-fg antialiased">
<div class="app-backdrop"></div>
<div class="app-vignette"></div>
<Sidebar user={data.user} playlists={data.sidebarPlaylists ?? []} />
<div class="flex min-w-0 flex-1">
<div class="flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1">
<div bind:this={scroller} class="flex min-w-0 flex-1 app-scroll flex-col">
<MobileHeader user={data.user} accountUrl={data.accountUrl} />
<TopBar user={data.user} accountUrl={data.accountUrl} />
<main class="flex-1 app-main" data-has-track={hasTrack ? '' : undefined}>
<main class="flex-1">
{#key page.url.pathname}
<div class="animate-fade">{@render children()}</div>
{/key}
@@ -122,6 +125,7 @@
</div>
<PlayerDock user={data.user} />
</div>
<Modal
open={createPlaylistModal.open}
+6 -13
View File
@@ -98,10 +98,6 @@
}
}
@utility scrollbar-stable {
scrollbar-gutter: stable;
}
@utility text-display-1 {
@apply font-display text-3xl font-medium tracking-tight sm:text-4xl;
}
@@ -131,7 +127,6 @@
transition-duration: var(--default-transition-duration);
}
@utility glass-bar {
background-color: var(--mf-bg);
border-top: 1px solid var(--mf-hairline);
}
@utility grid-cards {
@@ -224,14 +219,12 @@
margin-bottom: --spacing(14);
}
}
@utility app-main {
--mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
transition: padding var(--mf-motion-slow) var(--mf-ease);
&[data-has-track] {
--mf-dock-h: var(--mf-player-h);
}
@utility app-scroll {
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--mf-scrollbar) transparent;
}
@utility genre-tile {
background: linear-gradient(