forked from Keelrleek/SoulStream
SURFACES: panes add black, surfaces INSIDE panes add white. Every pane stands on 0.7075 black over the acrylic (.content 0.35 over .app 0.55), so a translucent black card nets 0.81 - darker than its own pane and inside the ~0.85 band already rejected once on the artist page. Solid mode's ladder is measured UPWARD from #121212 (+6/+18/+24), so glass elevates with WHITE: new --glass-surface/-hover/-press/-hairline/-well/-ring under :root.glass (0.03/0.08/0.11/0.10/0.18), which is independently the vocabulary .btn-ghost:hover and .ax-ghost:hover already shipped. The four solid tokens are NEVER redefined - they are consumed inside color-mix() and gradient stops where an alpha multiplies through and kills the call site (0.74 x 0.03 = an invisible chip; the album row's text-overflow fade works by BEING opaque), and they paint modal chrome that has to stay solid. 129 additive :global(:root.glass) declarations across 18 files, all 129 verified inside a glass block by walking the diff - solid mode byte-identical, confirmed structurally, by computed style with the class off, and visually. Three of them are not colour swaps: the album row's .rowbtns shim was OCCLUDING the last ~19px of the listeners bar and nothing translucent can occlude, so .tcount masks out just before the buttons instead; Playlist's banner scrim and the discography's pinned ambience both landed on opaque --bg-base and now dissolve like the album banner; and the 0.9 black hero tint on Home/CatalogSearch/Discography paints straight onto the pane when there is no artwork, so :first-child (the {#if} renders no element) flips it to white. Menus, dialogs, toasts and the import surfaces stay opaque deliberately, and the Settings copy for the mode now says so instead of claiming cards do. Measured with a known ground substituted behind the transparent window, since CDP never sees the acrylic: library toolbar chroma +2 as shipped, +16 with a white fill AND its backdrop-filter dropped, +30 for the bare pane beside it. RESIZE: Windows drops the legacy accent-policy acrylic for the whole of a live resize and the window stays transparent through it, so the desktop punches through sharp - measured by toggling window_glass alone, desktop icon labels readable through the titlebar. The blur cannot be kept (documented since Win10 2004, and the modern DWM backdrop was already rejected for the overlay-promotion blink), so armGlassResizeGuard grounds the canvas to --bg-sidebar for the length of the gesture. STALLS: "choppy but the FPS says 144" was 200-750ms MAIN-THREAD TASKS, not frame pacing - idle-scheduled work fires at scroll pauses by definition. Twin bake moved to display resolution and off the ready task (was a same-task 4K WebGL readback plus software blur, 100-170ms per rotation AND per artist entry); gl.finish() -> gl.flush() in buildMaster, which had been draining the whole 4K EASU/RCAS/grade chain on the main thread; copyCanvas realloc guarded; one app-wide scroll clock in scrollbar.js (markAppScroll/appScrollIdleFor) so all three periscopes stop cloning mid-gesture on ANY pane rather than only their own; bounded reclone with the querySelectorAll census deleted, the gate widened 250->1000ms, and the build/attach split across an idle callback and a rAF behind a generation guard; the hero rotation tick gated on scroll recency and its payload cache sized to the working set (2->4 entries, 24->32MiB, 32->64MiB); sessionTick no longer force-rewrites every 12s while merely PAUSED. Scroll storm went from 109-616ms long tasks to zero. Hover recalc and GC were measured innocent - do not re-chase them. 98/98 tests throughout.
365 lines
18 KiB
Svelte
365 lines
18 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 LyricsSyncEditor from "./components/LyricsSyncEditor.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 Lyrics from "./views/Lyrics.svelte";
|
||
import FollowToast from "./components/FollowToast.svelte";
|
||
import ImportToast from "./components/ImportToast.svelte";
|
||
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
||
import {
|
||
ui,
|
||
initEvents,
|
||
autoConnect,
|
||
refreshLibrary,
|
||
refreshPlaylists,
|
||
refreshRecents,
|
||
restoreSession,
|
||
} from "./lib/state.svelte.js";
|
||
import { floatScroll } from "./lib/scrollbar.js";
|
||
import { initMiniBridge } from "./lib/miniBridge.svelte.js";
|
||
import { applyGlass, armGlassResizeGuard } from "./lib/glass.js";
|
||
import { getSettings } from "./lib/api.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,
|
||
lyrics: Lyrics,
|
||
};
|
||
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",
|
||
// The playlist editor follows the album-page rule: its hero banner
|
||
// runs under the frost and the page takes padding-top: --pane-pad.
|
||
"playlist",
|
||
// The playlist shelf follows the Library 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.
|
||
"playlists",
|
||
// The lyrics room follows the Library exception too: its full-bleed
|
||
// duotone backdrop pins behind the glass from the very pane top.
|
||
"lyrics",
|
||
]);
|
||
|
||
initEvents();
|
||
// The mini player's channel to this window — this window stays the one
|
||
// brain while the mini is up (see miniBridge.svelte.js).
|
||
initMiniBridge();
|
||
// Dark-glass mode is persisted in config; the window boots solid and
|
||
// glasses up as soon as the setting arrives (no-op when off).
|
||
void getSettings()
|
||
.then((s) => s?.glass_mode && applyGlass(true))
|
||
.catch(() => {});
|
||
// Windows drops the acrylic blur for the length of a live resize and the
|
||
// window stays transparent through it, so the desktop punches through
|
||
// sharp. The guard grounds the canvas for the gesture — see the note on
|
||
// armGlassResizeGuard. Armed unconditionally: the CSS gates on .glass, and
|
||
// this way the mode can be toggled mid-drag without leaving stale state.
|
||
armGlassResizeGuard();
|
||
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 />
|
||
<!-- The pane is a SHELL with the bar floating absolute over an inner
|
||
scroller — the same bar-over-scroller relationship the sidebar's
|
||
gbar and the np head have, which is what lets the dark-glass band
|
||
mask sit statically on the scroller (a bar INSIDE the scroller
|
||
would be masked with it; the per-frame page-mask this replaces
|
||
never tracked cleanly). The inner div KEEPS the .content class:
|
||
every page resolves its scroll pane via closest(".content"). -->
|
||
<main class="pane-shell" class:barless={!BAR_VIEWS.has(ui.view)} inert={previewUi.open || undefined}>
|
||
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||
<div class="content" use:floatScroll>
|
||
<CurrentView />
|
||
</div>
|
||
</main>
|
||
<NowPlaying />
|
||
<PlayerBar />
|
||
<FollowToast />
|
||
<ImportToast />
|
||
<SetlistScanChip />
|
||
<PreviewOverlay />
|
||
<LyricsSyncEditor />
|
||
<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;
|
||
}
|
||
|
||
/* Dark-glass mode: the CANVAS alone — the black the panes float on (grid
|
||
gaps, edge padding, the titlebar strip) — goes translucent over the OS
|
||
acrylic. The panes above it all paint their own opaque surfaces and
|
||
must stay untouched. */
|
||
:global(:root.glass) .app {
|
||
background: rgba(0, 0, 0, 0.55);
|
||
}
|
||
|
||
/* While the window is being resized, the canvas GROUNDS itself.
|
||
Windows drops the acrylic blur for the whole of a live resize — that is
|
||
a documented limitation of the legacy accent-policy backdrop the OS half
|
||
uses (see window_glass in src-tauri/src/commands.rs), not something the
|
||
app can switch off. The window stays transparent through it, so what the
|
||
eye gets is not "less blur" but the raw desktop, sharp: wallpaper,
|
||
icons, other windows. Measured 2026-08-27 by toggling window_glass on
|
||
its own — with the acrylic off you can read desktop icon labels straight
|
||
through the titlebar strip and the sidebar.
|
||
The blur cannot be kept, so the transparency stops being used for the
|
||
length of the gesture. One opaque canvas is enough: every pane above it
|
||
is translucent over THIS, so grounding here grounds all of them.
|
||
--bg-sidebar is exactly what solid mode paints, so the drag looks like
|
||
the app with glass simply switched off, which is what it is.
|
||
.resizing is written by armGlassResizeGuard (src/lib/glass.js) and is
|
||
gated on .glass here, so solid mode never sees it. */
|
||
:global(:root.glass.resizing) .app {
|
||
background: var(--bg-sidebar);
|
||
}
|
||
|
||
/* Dark glass, the content pane (TEST, album page first — 2026-08-26):
|
||
the pane joins the frost at the shared 0.35. The top-highlight
|
||
gradient is a solid-mode grounding for hero seams; under glass the
|
||
hero tails are thinned instead (see CatalogAlbum), so the frost is
|
||
flat here. GlassBar drops its backdrop under glass for the same
|
||
measured reason as every other bar (slab over frost). */
|
||
:global(:root.glass) .content {
|
||
background: rgba(0, 0, 0, 0.35);
|
||
}
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* The shell: grid cell, corner clip, and the two named lengths — on the
|
||
SHELL so both the bar (its child now) and every page (descendants of
|
||
the scroller) inherit them. Purely structural; all paint and scroll
|
||
behavior stays on the inner .content below. */
|
||
.pane-shell {
|
||
grid-area: content;
|
||
position: relative;
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
/* The pane's top padding, named because three other rules are tied to
|
||
it: the glass bar's height pairs with it 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;
|
||
}
|
||
|
||
.content {
|
||
position: absolute;
|
||
inset: 0;
|
||
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;
|
||
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;
|
||
/* The ORIGINAL padding, all views: the artist page's pinned/sticky
|
||
physics are tuned against a scrollport with this exact top
|
||
padding — zeroing it shifted every sticky offset 24px and the
|
||
page collapsed into itself (2026-08-26). The bar's old job of
|
||
cancelling this padding for what follows (so hero pages start
|
||
flush at the pane top) is reproduced by the spacer below. */
|
||
padding: var(--pane-pad) 28px;
|
||
}
|
||
/* The absolute bar contributes no flow, so this zero-height spacer
|
||
replays its old net-flow trick: on bar views, pull everything after
|
||
it up by exactly --pane-pad — the page's box starts flush at the
|
||
pane top, and each hero page restores its own inset, precisely the
|
||
pre-restructure contract. Barless views keep the classic inset. */
|
||
:global(.pane-shell:not(.barless) > .content)::before {
|
||
content: "";
|
||
display: block;
|
||
height: 0;
|
||
margin-bottom: calc(-1 * var(--pane-pad));
|
||
}
|
||
|
||
/* Dark glass: the band cut, the sidebar's exact move — static, in the
|
||
scroller's own box coordinates, with the bar safely OUTSIDE as a
|
||
sibling. clip-path, NOT mask-image: the cut is a hard edge, and a
|
||
rectangle clip stays on the compositor's fast path where an alpha
|
||
mask forced the 7k-node library through ~14ms raster frames
|
||
(measured 2026-08-26). Skipped on barless views. */
|
||
:global(:root.glass) .pane-shell:not(.barless) > .content {
|
||
clip-path: inset(var(--bar-h) 0 0 0);
|
||
}
|
||
/* Painted pages (data-painted roots) keep their content running under
|
||
the bar for the real backdrop to sample — GlassBar's periscope effect
|
||
lifts this clip INLINE when it toggles .painted-page (an inline write
|
||
outranks this rule without a specificity war). */
|
||
</style>
|