Kyle picked this off an interactive prototype (research/library/) over a long evening of passes, and the settings he landed on are baked in rather than left as choices: records no catalogue knows about are ordinary citizens with no marks at all, artwork is generated from a record's own name when there is none, the albums you do not have are one collapsed line that opens into full-size dimmed cards, an artist's shelf splits by release type, the toolbar is a floating card, the index rail is always on, the List view's column header is welded to that card, the entrance is a wave of records, and cards are the artist page's own 150px. The artists accordion is gone. In its place: three views (Shelves, Grid, List), six groupings (Artist, Decade, Format, Type, Added, None), thirteen sorts that are independent of the grouping, a live filter that moves every number in the header, and a stat row that is derived rather than written down. A library page has to say what the files ARE, and library_albums only carries id/artist/title/year/art/count/added. So the library crate gained album_stats: one pass over tracks per call, aggregating length, bytes, average bitrate, bit depth, sample rate and the dominant file extension per album, plus the newest play that met a seconds floor the caller passes (the same 45s the recents list uses — under it a play is a skip, and a page that sorts by "never played first" must not count skips as listening). Aggregated in Rust rather than SQL because the dominant format needs the path's extension counted per album, which SQLite cannot do without string gymnastics, and a library is a few thousand rows. Filing a record as an album, an EP, a single, a live set, a compilation, a soundtrack, a remix record or bonus material is not a new opinion: localReleaseType.js carries CatalogArtist's own title heuristics verbatim — the regexes are the tuned artifact — reduced to the branches whose evidence a LOCAL record can carry. A file on disk has no MusicBrainz release group, no editorial lead sentence and usually no record_type, so the MB and description-hint branches are absent because their inputs cannot exist. Under it sits the floor the prototype settled on: when the classifier lands on "other" and the row carries no provider type, the two facts a file always has decide — three tracks or under ten minutes is a single, seven or under twenty-five an EP — with the session/demo check re-run inside it so a five-track practice tape stays bonus material. It is a copy, and it is marked as one at every function; unifying it with the artist page is follow-up work, not something to smuggle into this. Clicking a record opens CatalogArtist's inline expander, class for class, with its numbers unchanged: the 460ms slide whose height is read live per frame and kept monotonic (a captured height animates to a stale number once the tracklist lands), the staggered waterfall that fades each part to its OWN resting opacity, per-row entrances at min(i*16, 180), and a same-row switch that keeps both panels alive while the stage's height eases between them. What differs is the content, because a library knows different facts: where the artist page shows Last.fm plays and listeners, the rail shows size on disk and bitrate, where it offers Save an owned record offers its folder, and each track carries what its file is. Three things about the chrome are measured rather than typed, and each one is a bug the prototype found first. The bar's height is read off the bar, because a typed 50px parks anything pinned below it behind a bar whose controls have wrapped; the bar's own minimum is a constant, because publishing a measurement into the property that decides it is a ratchet. The index rail's 34px gutter is reserved whenever the rail is on, drawn or not, so no click on Group can change the width of the content column. And the table's column template lives on the page rather than on the rows, because a 1fr column floors at min-content and RECORD is wider than an ellipsized title, so identical declarations on two elements resolve to different widths; columns drop by tier from the page's measured width so a record's name is never squeezed out. The entrance is timed by POSITION, not by index. Indexing the document spends the whole stagger on sections nobody is looking at — 38 shelves means every visible one hits the cap and they all arrive in the same frame, which has no relative motion in it and reads as no animation at all. Delay comes from how far down and slightly across the pane a thing sits, normalized to the topmost thing that is both moving and on screen, and the delays are pulled forward over the next few frames as content-visibility geometry lands (only forward, except while a thing is still hidden, where a correction is invisible). The hero, the toolbar and the column header never move: they are the page's furniture, and dealing them in animated the whole window instead of the part that changed. A rail click corrects until its target stops moving. content-visibility makes the height of everything unpainted an estimate, so one scroll to a section overshoots — measured on the prototype at up to 1143px, with the page's own height moving 2260px during the jump. Two bugs fixed on the way, both the same mistake: `>>` on an unsigned 32-bit hash goes negative above 2^31, so a generated track name indexed past its word list and a generated cover's second hue produced an invalid gradient. Both are `>>>`. Deliberately not here. There is no Import button because there is no backend command to import a folder — Rescan re-reads the library and its stats, and a real importer is a backend feature. The "N of M released" count and the missing-albums line appear only for artists you FOLLOW: a follow carries an anchored artist id, and matching a local artist to a provider by name is exactly what the identity rules forbid, so an unfollowed shelf says what it holds and nothing more. Verified against the running app on real files: 7 albums, 72 tracks, 1.8 GB on disk off the new command, and Maladroit opening to 13 tracks, 33:37, 279 MB, 1019k, FLAC 16/44.1 with per-track rates, its notch under the card it belongs to and its row landing 10px under the pinned bar. Verified headless against the mock library at scale: all eighteen view-by-grouping combinations at one content width, the toolbar pinned with zero drift, the joined header's columns matching the rows on both edges, the expander opening from a card and from a row, morphing in place within a row and closing without leaving its reserve behind, and the wave moving 74 things with 34 distinct arrival times across 391ms while the chrome stayed still.
244 lines
11 KiB
Svelte
244 lines
11 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 Playlists from "./views/Playlists.svelte";
|
||
import CatalogSearch from "./views/CatalogSearch.svelte";
|
||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||
import CatalogDiscography from "./views/CatalogDiscography.svelte";
|
||
import FollowToast from "./components/FollowToast.svelte";
|
||
import {
|
||
ui,
|
||
initEvents,
|
||
autoConnect,
|
||
refreshLibrary,
|
||
refreshPlaylists,
|
||
refreshRecents,
|
||
restoreSession,
|
||
} 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,
|
||
playlists: Playlists,
|
||
catalog: CatalogSearch,
|
||
catalogArtist: CatalogArtist,
|
||
catalogAlbum: CatalogAlbum,
|
||
catalogDiscography: CatalogDiscography,
|
||
};
|
||
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.
|
||
|
||
`catalogDiscography` is the one exception to the padding rule above, and
|
||
deliberately: it pins a blurred ambience layer behind the glass, and that
|
||
layer has to begin at the very top of the pane. A padding-top here would
|
||
start it a --pane-pad down — halfway through the bar. It takes the inset
|
||
on its header's margin instead, which comes to the same place. */
|
||
const BAR_VIEWS = new Set([
|
||
"catalogArtist",
|
||
"catalogAlbum",
|
||
"catalog",
|
||
"catalogDiscography",
|
||
// The Library follows the discography's exception: its blurred
|
||
// ambience pins behind the glass and must begin at the very top of
|
||
// the pane, so the page carries its own top inset instead of taking
|
||
// padding-top here.
|
||
"library",
|
||
// Home is a "start BELOW the bar" page (App's search-page rule): it
|
||
// has no artwork to run under the frost, so it takes back
|
||
// --bar-h + air rather than --pane-pad. Its ambience wash is the one
|
||
// exception and deliberately runs to the pane top behind the glass.
|
||
"home",
|
||
]);
|
||
|
||
initEvents();
|
||
autoConnect();
|
||
refreshLibrary();
|
||
refreshPlaylists();
|
||
refreshRecents();
|
||
// Last, and after initEvents: the restore asks the audio thread what it is
|
||
// doing, and re-opens the page the last session was on. See the session
|
||
// section at the foot of state.svelte.js.
|
||
void restoreSession();
|
||
|
||
</script>
|
||
|
||
<div
|
||
class="app"
|
||
class:np-open={ui.nowPlaying}
|
||
class:sidebar-collapsed={ui.sidebarCollapsed}
|
||
class:player-expanded={ui.playerExpanded}
|
||
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 />
|
||
<FollowToast />
|
||
<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;
|
||
}
|
||
|
||
/* NOTE (2026-08-01 perf audit): do NOT try to move the preview scrim's
|
||
backdrop blur onto these panels as `filter: blur(6px)`. It was tried:
|
||
filtered panels do not get cached render passes here (the content
|
||
pane's canvas/composited subtree defeats caching), so every presented
|
||
frame re-rastered five blurred panels — measured ~2× the cost of the
|
||
single backdrop-filter on the scrim. The cheap lever is the modal's
|
||
PRESENT RATE (see the eq animation note in PreviewOverlay.svelte),
|
||
not where the blur lives. */
|
||
|
||
.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;
|
||
/* The player row grows UPWARD because the grid's total height is the
|
||
window: the bar's own track gets taller and `1fr` — the content —
|
||
gives up exactly that much. Nothing below the bar can move, so its
|
||
bottom edge is pinned without a rule saying so. */
|
||
transition:
|
||
grid-template-columns 0.28s cubic-bezier(0.4, 0, 0.2, 1),
|
||
grid-template-rows 350ms cubic-bezier(0.22, 1, 0.36, 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;
|
||
}
|
||
|
||
/* Expanded player: the same one-line gesture the sidebar makes, on the row
|
||
instead of the column. 90px was chosen on
|
||
research/playbar-redesign/expand-collapse.html — enough for the 34px
|
||
transport, a 10px gap and the 24px lane stacked and centred (68px of
|
||
content, 11px of air top and bottom), and no more. */
|
||
.app.player-expanded {
|
||
--player-height: 90px;
|
||
}
|
||
|
||
/* 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;
|
||
/* The glass bar's height, declared HERE rather than on the bar so pages
|
||
can clear it. A page with a hero (artist, album) wants its artwork to
|
||
run underneath the bar and only takes --pane-pad back; a page with no
|
||
artwork behind the bar (search) has to start below it, or its first
|
||
heading sits under the frost at rest. */
|
||
--bar-h: 46px;
|
||
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>
|