forked from Keelrleek/SoulStream
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.
248 lines
11 KiB
Svelte
248 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 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>
|