The resize grip. The panel already left the 0.28s ease for a drag, but the grid column it lives in did not -- and an eased track list cannot follow a pointer, because the grip moves the target every frame and each move restarts the ease from wherever it had crawled to. Measured on the real gesture: the column ran 122.6px behind the pointer and the panel overran the window on 29 of 30 frames, which is why the release grid was sliced off at the right edge. With the column leaving the ease alongside the panel: 0px lag, 0 frames clipped, 4.6ms worst frame. onGripUp is bound to lostpointercapture as well, because a release the grip never sees would leave the app root pinned at transition:none and kill every collapse animation for the session. The header status re-ran its 300ms entry slide on every progress event. It was keyed on the text, and the text carries the percentage, rate and ETA -- so it replayed several times a second and the header flashed. Keyed on the transfer PHASE now: 2 rebuilds across a real transfer instead of one per tick. The waveform lane was unmounted while peaks were absent. player.waveform empties the instant a track changes and a partial FLAC decode can take seconds, so the times, the chips and the whole pane below jumped 54px up and then back on every track change. It holds its space as a flat baseline, with the same bar COUNT either way so peaks arriving restyle the existing elements rather than replacing them. The bar count is derived from the panel width at a 6px pitch as well -- a fixed 78 bars was a 310px block that overflowed the 272px panel and left a third of the 560px one empty. Now 41 bars at 272 and 89 at 560, never more than 4px of the lane unused. Fans also like was a fixed row of four circles spaced evenly: every name truncated to "The Sma..." however wide the panel got, because the circles stayed 64px and only the gaps grew. Name-sized pills that wrap use whatever width there is, carry eight artists in less height, and truncate nothing at any width. Show all releases dumped 68 records in one flat date-sorted grid. It is filed by the artist page's own ten categories now, empty ones dropped, editions folded into their base, as dense rows with the year printed only where it changes. NOTE: this panel classifies with less evidence than the artist page -- no description hints, no MusicBrainz type map -- so a record whose kind is only knowable from its description will file differently here until those are routed through. Commented at the source. Full-screen artwork from the panel opened playerArtSource, a string rewrite of the provider URL to 1000x1000, with no coverUpgrades lookup at all -- while the same record opened from a page gave the master. On the Red Album that is 1000x1000 against Apple's 3000x3000. It opens on the best it has and swaps the master in when it resolves, guarded on the viewer still showing what it opened. Releases grid: cards align on their artwork again (a button centres its content, so beside a four-line title the short card's whole stack sat lower), titles clamp to two reserved lines, the hover outline samples the record's own colour, and clicking one opens the album page instead of streaming it. The artist chip's name was centred because a button centres text while the stats row below is flex-aligned; it reads as left-aligned with them now. waveDragging is $state, so class:scrubbing actually applies. The pane never scrolls sideways.
179 lines
7.8 KiB
Svelte
179 lines
7.8 KiB
Svelte
<script>
|
|
import Sidebar from "./components/Sidebar.svelte";
|
|
import TitleBar from "./components/TitleBar.svelte";
|
|
import PlayerBar from "./components/PlayerBar.svelte";
|
|
import NowPlaying from "./components/NowPlaying.svelte";
|
|
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
|
import LoginModal from "./components/LoginModal.svelte";
|
|
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
|
import DebugOverlay from "./components/DebugOverlay.svelte";
|
|
import GlassBar from "./components/GlassBar.svelte";
|
|
import { previewUi } from "./lib/preview/previewSession.svelte.js";
|
|
import Home from "./views/Home.svelte";
|
|
import Search from "./views/Search.svelte";
|
|
import Library from "./views/Library.svelte";
|
|
import Downloads from "./views/Downloads.svelte";
|
|
import Settings from "./views/Settings.svelte";
|
|
import Playlist from "./views/Playlist.svelte";
|
|
import CatalogSearch from "./views/CatalogSearch.svelte";
|
|
import CatalogArtist from "./views/CatalogArtist.svelte";
|
|
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
|
import {
|
|
ui,
|
|
initEvents,
|
|
autoConnect,
|
|
refreshLibrary,
|
|
refreshPlaylists,
|
|
} from "./lib/state.svelte.js";
|
|
import { floatScroll } from "./lib/scrollbar.js";
|
|
|
|
const views = {
|
|
home: Home,
|
|
search: Search,
|
|
library: Library,
|
|
downloads: Downloads,
|
|
settings: Settings,
|
|
playlist: Playlist,
|
|
catalog: CatalogSearch,
|
|
catalogArtist: CatalogArtist,
|
|
catalogAlbum: CatalogAlbum,
|
|
};
|
|
const CurrentView = $derived(views[ui.view] ?? Home);
|
|
|
|
/* The glass bar is app chrome, not part of a page, so it is mounted ONCE
|
|
here and lives above whichever view is current — see the note at the top
|
|
of GlassBar.svelte for what a per-view instance cost. Views that do not
|
|
show it yet hide it; the component is never unmounted, so its crumb
|
|
memory survives every navigation, including out to Home and back.
|
|
|
|
A page added here MUST also take `padding-top: var(--pane-pad)`: the bar
|
|
cancels the pane's top padding so a page's artwork can run underneath it,
|
|
and the page restores that inset for its own content. Without it the page
|
|
renders 24px too high; with it but not listed here, 24px too low. */
|
|
const BAR_VIEWS = new Set(["catalogArtist", "catalogAlbum"]);
|
|
|
|
initEvents();
|
|
autoConnect();
|
|
refreshLibrary();
|
|
refreshPlaylists();
|
|
|
|
</script>
|
|
|
|
<div
|
|
class="app"
|
|
class:np-open={ui.nowPlaying}
|
|
class:sidebar-collapsed={ui.sidebarCollapsed}
|
|
class:np-dragging={ui.nowPlayingDragging}
|
|
class:preview-open={previewUi.open}
|
|
style="--np-open-width: {ui.nowPlayingWidth}px"
|
|
>
|
|
<TitleBar />
|
|
<!-- While the preview modal is open the page is inert and the flanking
|
|
panels + player bar are pointer-inert under the scrim (user decision
|
|
2026-07-23: the bar stays VISIBLE, dimmed, like Spotify — un-rendering
|
|
it left a black hole in the grid's bottom row). There is still no UI
|
|
path to command main audio behind the focus coordinator's back: the
|
|
scrim intercepts pointer input and the dialog traps the keyboard.
|
|
The titlebar stays live for window chrome. -->
|
|
<Sidebar />
|
|
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
|
<!-- First child of the scroll pane: the bar's sticky pin and its
|
|
negative bleed margins are measured against THIS element's
|
|
padding. It consumes no flow height of its own AND cancels the
|
|
pane's top padding, so the page after it begins flush with the top
|
|
of the pane and its artwork runs under the bar. -->
|
|
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
|
<CurrentView />
|
|
</main>
|
|
<NowPlaying />
|
|
<PlayerBar />
|
|
<PreviewOverlay />
|
|
<ArtworkViewer />
|
|
<LoginModal />
|
|
<DebugOverlay />
|
|
</div>
|
|
|
|
<style>
|
|
/* Frameless window: the titlebar row is part of the app background —
|
|
no separate chrome color, the panels float beneath it. */
|
|
/* While the preview modal is open: keyboard is contained by the dialog's
|
|
focus trap and aria-modal, the content pane is `inert`, and the flanking
|
|
panels + player bar stop taking pointer input here — the bar stays
|
|
visible (dimmed under the scrim) but cannot command main audio. */
|
|
.app.preview-open :global(.sidebar),
|
|
.app.preview-open :global(.nowplaying),
|
|
.app.preview-open :global(.player) {
|
|
pointer-events: none;
|
|
}
|
|
|
|
.app {
|
|
display: grid;
|
|
grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width);
|
|
grid-template-rows: 30px 1fr var(--player-height);
|
|
grid-template-areas:
|
|
"titlebar titlebar titlebar"
|
|
"sidebar content nowplaying"
|
|
"player player player";
|
|
height: 100%;
|
|
gap: 8px;
|
|
padding: 0 8px 8px;
|
|
background: var(--bg-sidebar);
|
|
overflow: hidden;
|
|
transition: grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
|
/* Indirection so the dragged width can be an INLINE style without
|
|
outranking the collapse rule below: an inline --nowplaying-width
|
|
would beat `.app:not(.np-open)` and the panel could never collapse.
|
|
Both rules here are class-level, so the more specific one wins. */
|
|
--nowplaying-width: var(--np-open-width, 340px);
|
|
}
|
|
|
|
/* A drag has to track the pointer, and an eased track list cannot: the grip
|
|
moves the target every frame, each move restarts the 0.28s ease from
|
|
wherever it had crawled to, and the column ends up going almost nowhere —
|
|
measured 203px behind the pointer on a 220px drag, then racing to the
|
|
target after release. The panel already leaves the ease for a drag
|
|
(.nowplaying.dragging); if the column does not leave with it the panel
|
|
grows past its own track and the window clips it. */
|
|
.app.np-dragging {
|
|
transition: none;
|
|
}
|
|
|
|
/* Collapsed left nav: narrow the sidebar column to an icon rail. The
|
|
grid-template-columns transition animates the reflow. */
|
|
.app.sidebar-collapsed {
|
|
--sidebar-width: 64px;
|
|
}
|
|
|
|
/* Now Playing collapses to its RAIL, not to nothing — the same gesture the
|
|
left nav makes. The column animates between the user's dragged width and
|
|
the rail on the shared 0.28s curve above; the panel keeps a presence at
|
|
every point, so there is no off-screen slide any more. */
|
|
.app:not(.np-open) {
|
|
--nowplaying-width: var(--nowplaying-rail);
|
|
}
|
|
|
|
.content {
|
|
grid-area: content;
|
|
background: linear-gradient(var(--bg-highlight) 0, var(--bg-base) 240px);
|
|
/* The highlight wash belongs to the top of the PAGE, not the viewport:
|
|
anchored to the pane (default `scroll`), the gradient stays put
|
|
while content scrolls, so the hero's scrim — which dissolves into
|
|
pure bg-base — ends on a lighter backdrop mid-gradient and paints a
|
|
hard seam at the hero's bottom edge. `local` scrolls the gradient
|
|
away with the content, keeping the seam colors identical at every
|
|
scroll position. */
|
|
background-attachment: local;
|
|
/* The pane's top padding, named because three other rules are tied to
|
|
it: the glass bar bleeds up over it, cancels it for what follows, and
|
|
each page under the bar restores it as its own padding-top. */
|
|
--pane-pad: 24px;
|
|
border-radius: var(--radius);
|
|
overflow-y: auto;
|
|
overflow-anchor: none;
|
|
/* Main scroll pane: no elastic rubber-band at its edges. Inner regions
|
|
deliberately do NOT set this, so their wheel chains here. */
|
|
overscroll-behavior: none;
|
|
padding: var(--pane-pad) 28px;
|
|
}
|
|
</style>
|