Files
SoulStream/src/App.svelte
T
KeelrLeek e7d0971de2 import: the whole feature, and releases put back together
Everything the import grew over the last week, landing in one go — the
window, the picker, the audition pen, apply, and the review that stands
between them.

The fold is the new part. A release filed as a container of parts arrived
as N albums, because the scan groups audio by its immediate parent and the
container holds none of its own: Analord's eleven volumes were eleven
records, and six more releases split the same way. Records now fold into
the release above them, and apply is handed an explicit part list — never a
recursive walk, which is what let one stray file swallow 288 descendants
and duplicate 9 GB.

Where releases live is read off the collection rather than assumed, so a
genre-nested library moves the answer instead of breaking; below a dominant
majority at one depth the pass refuses entirely, because no folding beats
wrong folding. Three vetoes independent of depth catch a misread level.
Every fold is listed in the review with a way out.

research/import/fold-audit.mjs imports the shipping module, so the audited
code is the running code: 9 folds, 0 swallows across four library shapes,
and none of the 148 artist folders touched. It was that harness that killed
the tempting version of this rule — matching a part against its container
folded 40 artist folders, Mac Demarco's 8 albums into one, because album
folders carry the artist's name.

The regression test found a live bug on the way: parts number from 01 each,
so flattening two discs collided, and "same name, same size" read the
second file as already-imported and dropped it while still counting it.
That reuse now also requires that this run has not already written the
path.

Also here: the picker's accent crossfades between records instead of
cutting through the app's blue (ownership moved to the window, which
survives the per-record re-key), the fullscreen viewer wears the album's
accent instead of the one it left behind in the portal, Skip defers a
record without deciding it, and Keep mine is gone — with the cards picked
field by field it was Next under another name.
2026-08-10 07:55:59 -05:00

248 lines
11 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 ImportWindow from "./components/ImportWindow.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 ImportToast from "./components/ImportToast.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 />
<ImportToast />
<PreviewOverlay />
<ArtworkViewer />
<ImportWindow />
<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>