426 lines
21 KiB
Svelte
426 lines
21 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 FirstRun from "./components/FirstRun.svelte";
|
||
import { firstRun } from "./lib/firstRun.svelte.js";
|
||
import UpdateToast from "./components/UpdateToast.svelte";
|
||
import { startUpdaterWatch } from "./lib/updater.svelte.js";
|
||
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 Community from "./views/Community.svelte";
|
||
import Library from "./views/Library.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 ResolverToast from "./components/ResolverToast.svelte";
|
||
import UploadToast from "./components/UploadToast.svelte";
|
||
import ProposalToast from "./components/ProposalToast.svelte";
|
||
import FollowNewsToast from "./components/FollowNewsToast.svelte";
|
||
import WhoHasSheet from "./components/WhoHasSheet.svelte";
|
||
import SetlistScanChip from "./components/SetlistScanChip.svelte";
|
||
import {
|
||
ui,
|
||
initEvents,
|
||
autoConnect,
|
||
refreshLibrary,
|
||
refreshPlaylists,
|
||
refreshRecents,
|
||
restoreSession,
|
||
} from "./lib/state.svelte.js";
|
||
import { startInboxWatch } from "./lib/community.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,
|
||
// The Soulseek hub absorbed the old Search and Downloads views —
|
||
// their ids stay routable so the download ring, "get on soulseek"
|
||
// hand-offs, history entries and restored sessions all land on the
|
||
// hub's right tab (the alias → tab sync lives in Soulseek.svelte).
|
||
// The hub is fused into Community (2026-09-02); its ids still route
|
||
// so the download ring, "get on soulseek" hand-offs and restored
|
||
// sessions land on the right pill (Community reads the alias).
|
||
soulseek: Community,
|
||
search: Community,
|
||
downloads: Community,
|
||
community: Community,
|
||
library: Library,
|
||
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 Soulseek hub is a "start BELOW the bar" page (it takes
|
||
// padding-top: --pane-pad itself); its two alias ids ride along so
|
||
// the bar never blinks when an old caller routes through them.
|
||
"soulseek",
|
||
"search",
|
||
"downloads",
|
||
// Community is the hub's sibling: same "start BELOW the bar" shape.
|
||
"community",
|
||
// 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) => {
|
||
if (s?.glass_mode) applyGlass(true);
|
||
// A fresh install (no setup_done in its config) meets the
|
||
// first-run tour before anything else; FirstRun.svelte stamps
|
||
// the flag when it is finished or skipped.
|
||
if (s && !s.setup_done) firstRun.open = 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();
|
||
// The updater's status and finds arrive as events; the backend checks
|
||
// on its own schedule and never installs without the button.
|
||
startUpdaterWatch();
|
||
autoConnect();
|
||
// Sealed cargo arrives on the inbox check, and that has to keep happening
|
||
// wherever the user is — not only while the Community page is mounted.
|
||
void startInboxWatch();
|
||
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}
|
||
class:setup-open={firstRun.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 || firstRun.open || undefined}>
|
||
<GlassBar hidden={!BAR_VIEWS.has(ui.view)} />
|
||
<div class="content" use:floatScroll>
|
||
<CurrentView />
|
||
</div>
|
||
</main>
|
||
<NowPlaying />
|
||
<PlayerBar />
|
||
<FollowToast />
|
||
<ImportToast />
|
||
<ResolverToast />
|
||
<UploadToast />
|
||
<ProposalToast />
|
||
<FollowNewsToast />
|
||
<UpdateToast />
|
||
<WhoHasSheet />
|
||
<SetlistScanChip />
|
||
<PreviewOverlay />
|
||
<LyricsSyncEditor />
|
||
<ArtworkViewer />
|
||
<ImportWindow />
|
||
<LoginModal />
|
||
<!-- Above the login modal: the tour has its own sign-in step, and a
|
||
failed auto-connect at boot must not raise the modal over it. -->
|
||
<FirstRun />
|
||
<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),
|
||
.app.setup-open :global(.sidebar),
|
||
.app.setup-open :global(.nowplaying),
|
||
.app.setup-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;
|
||
/* Publishes the scroller's timeline to the glass bar's fog, which
|
||
is a SIBLING of the scroller — the fog's ride animates against
|
||
this instead of chasing scroll events on the main thread (the
|
||
chase lagged visibly on the 60Hz laptop; see GlassBar). */
|
||
timeline-scope: --pane-scroll;
|
||
}
|
||
|
||
.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;
|
||
/* The named timeline the fog rides (scope on .pane-shell above).
|
||
The artist page overwrites this with its own INLINE
|
||
scroll-timeline for its hero rides — that page is painted, has
|
||
no fog, and needs no pane timeline. */
|
||
scroll-timeline: --pane-scroll block;
|
||
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>
|