forked from Keelrleek/SoulStream
dark glass, the whole app: frost on every pane, and the periscope pattern for every glass bar the frost sits under - backdrop-filter black-flattens over see-through ground (measured 3-vs-40), so bars over frost blur a scroll-synced clone behind a static band cut instead; the bar moved out of the scroller (pane shell, inner .content keeping the closest-contract, the old negative-margin flow replayed by a spacer after the artist page's sticky physics named the scrollport padding sacred); painted pages declare data-painted and keep the real backdrop bar, which is how the artist hero runs under the glass again; np wash rides the scroll away and dissolves to frost like the album banner; perf measured to vsync - 142px blur wraps (27.9ms frames down to 6.9), idle scroll-gated reclones, clip-path band, content-visibility on library rows, async cover decode; mini frosts over the mini:glass channel and the rest-frame flags ride window config because the env-var lane dies on some webview2 runtimes
This commit is contained in:
1 parent
225c9139aa
commit
6ba1864498
16 files changed
+821
-117
No files matched your search
@@ -327,6 +327,136 @@
|
||||
return () => (active = false);
|
||||
});
|
||||
|
||||
// ── the head periscope (dark glass only) — the sidebar's pattern ────
|
||||
// Under glass the head's backdrop-filter black-flattens the moment the
|
||||
// wash scrolls out from under it (frost backdrop = opaque black slab;
|
||||
// see Sidebar.svelte for the measurements). So under glass: the head
|
||||
// drops backdrop-filter, the scroller's originals are masked out of the
|
||||
// head band, and a cloneNode copy under a NORMAL blur paints them back
|
||||
// as fog — wash included, so the head keeps its record tint at rest and
|
||||
// the colors still seep and shift as you scroll. Canvas children clone
|
||||
// blank (the waveform leaves no fog) — accepted.
|
||||
let npGlassy = $state(document.documentElement.classList.contains("glass"));
|
||||
let npScrollEl = $state(null);
|
||||
let npPeriEl = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
const mo = new MutationObserver(() => {
|
||||
npGlassy = document.documentElement.classList.contains("glass");
|
||||
});
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => mo.disconnect();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!npGlassy || !npScrollEl || !npPeriEl) return;
|
||||
const view = npScrollEl;
|
||||
const peri = npPeriEl;
|
||||
let pending = 0;
|
||||
let lastScroll = 0;
|
||||
let lastNodes = 500;
|
||||
const sync = () => {
|
||||
lastScroll = performance.now();
|
||||
const c = peri.firstElementChild?.firstElementChild;
|
||||
if (c) c.style.transform = `translateY(${-view.scrollTop}px)`;
|
||||
};
|
||||
const reclone = () => {
|
||||
// The blur lives on an OVERSCAN wrapper extending 48px past the
|
||||
// clip on every side: a gaussian fades toward transparent at its
|
||||
// element's bounds (~2× the radius), and with the bounds out
|
||||
// there the falloff is clipped away instead of vignetting the
|
||||
// band edges (Kyle's catch; the wash img does the same job with
|
||||
// scale(1.08)). The clone is pinned back inside by margins at
|
||||
// its measured width, so layout and alignment are unchanged.
|
||||
// height 142 = overscan + band + overscan: the filter rasters
|
||||
// this box, never the full cloned scroller.
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"position:absolute;top:-48px;left:-48px;right:-48px;height:142px;" +
|
||||
"overflow:hidden;filter:blur(17px) saturate(1.3);will-change:transform;" +
|
||||
"pointer-events:none;";
|
||||
const c = view.cloneNode(true);
|
||||
c.style.position = "relative";
|
||||
c.style.margin = "48px 0 0 48px";
|
||||
c.style.width = view.clientWidth + "px";
|
||||
c.style.height = "auto";
|
||||
c.style.overflow = "visible";
|
||||
c.querySelectorAll("canvas").forEach((n) => n.remove());
|
||||
lastNodes = c.querySelectorAll("*").length;
|
||||
// The overscan clears the FILTER's falloff, but the blur still
|
||||
// mixes with emptiness wherever the CONTENT ends at the clip
|
||||
// line — and the wash is the one full-bleed layer, so its edges
|
||||
// vignetted the band (Kyle's second catch). Stretch the cloned
|
||||
// wash into the overscan margins so the band edge samples wash
|
||||
// against wash. Rows keep their own padding; nothing else
|
||||
// reaches the edge.
|
||||
const bleed = c.querySelector(".scroll-bleed");
|
||||
if (bleed) {
|
||||
bleed.style.top = "-48px";
|
||||
bleed.style.left = "-48px";
|
||||
bleed.style.right = "-48px";
|
||||
}
|
||||
wrap.appendChild(c);
|
||||
const fresh = !peri.firstElementChild;
|
||||
if (fresh) {
|
||||
wrap.style.opacity = "0";
|
||||
wrap.style.transition = "opacity 160ms ease";
|
||||
}
|
||||
peri.replaceChildren(wrap);
|
||||
if (fresh) requestAnimationFrame(() => (wrap.style.opacity = "1"));
|
||||
sync();
|
||||
};
|
||||
// Trailing debounce; no `class` in the filter — hover flips were
|
||||
// re-cloning per mouse move, and the fog doesn't read hover states.
|
||||
// Delay scales with pane size and the clone runs at idle (see the
|
||||
// GlassBar note: big pages were janking frames with reclone tasks).
|
||||
const queueReclone = () => {
|
||||
clearTimeout(pending);
|
||||
const delay = Math.min(600, 120 + lastNodes / 40);
|
||||
pending = setTimeout(() => {
|
||||
// Never clone mid-scroll, never while hidden (see GlassBar).
|
||||
if (document.hidden) return;
|
||||
if (performance.now() - lastScroll < 250) return queueReclone();
|
||||
if ("requestIdleCallback" in window) {
|
||||
requestIdleCallback(() => reclone(), { timeout: 800 });
|
||||
} else {
|
||||
reclone();
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
const mo = new MutationObserver((records) => {
|
||||
for (const r of records) {
|
||||
if (r.target === view && (r.addedNodes.length || r.removedNodes.length)) {
|
||||
peri.replaceChildren();
|
||||
break;
|
||||
}
|
||||
}
|
||||
queueReclone();
|
||||
});
|
||||
mo.observe(view, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["src"],
|
||||
});
|
||||
const ro = new ResizeObserver(queueReclone);
|
||||
ro.observe(view);
|
||||
const onVis = () => {
|
||||
if (!document.hidden) queueReclone();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
view.addEventListener("scroll", sync, { passive: true });
|
||||
reclone();
|
||||
return () => {
|
||||
mo.disconnect();
|
||||
ro.disconnect();
|
||||
document.removeEventListener("visibilitychange", onVis);
|
||||
view.removeEventListener("scroll", sync);
|
||||
clearTimeout(pending);
|
||||
peri.replaceChildren();
|
||||
};
|
||||
});
|
||||
|
||||
const bufferPct = $derived(
|
||||
player.streamTotal > 0
|
||||
? Math.min(100, (player.streamReceived / player.streamTotal) * 100)
|
||||
@@ -1037,10 +1167,10 @@
|
||||
{/snippet}
|
||||
|
||||
<aside class="nowplaying" class:collapsed={!ui.nowPlaying} class:dragging>
|
||||
<!-- The record washes the whole panel, expanded or collapsed, and the glass
|
||||
bars below sit on it exactly as the app's glass bar sits on a page hero.
|
||||
Blurred hard, because it is a backdrop for text and icons — not a
|
||||
second showing of the artwork. -->
|
||||
<!-- The record washing the RAIL (the expanded view carries its own
|
||||
.scroll-bleed inside the scroller, so the wash rides the scroll away
|
||||
like the album page's hero banner). Blurred hard, because it is a
|
||||
backdrop for text and icons — not a second showing of the artwork. -->
|
||||
{#if artUrl}
|
||||
<div class="bleed" aria-hidden="true">
|
||||
<img src={artUrl} alt="" draggable="false" />
|
||||
@@ -1104,6 +1234,9 @@
|
||||
waste of the only permanently visible line it has. -->
|
||||
<div class="headzone">
|
||||
<header class="np-head glass" data-scrollhead>
|
||||
{#if npGlassy}
|
||||
<div class="peri" bind:this={npPeriEl} aria-hidden="true"></div>
|
||||
{/if}
|
||||
<button class="np-icon" title="Collapse panel" aria-label="Collapse Now Playing" onclick={toggleNowPlaying}>
|
||||
<Icon name="chevron-right" size={16} />
|
||||
</button>
|
||||
@@ -1153,7 +1286,16 @@
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<div class="np-scroll" use:floatScroll>
|
||||
<div class="np-scroll" use:floatScroll bind:this={npScrollEl}>
|
||||
<!-- The record's wash, anchored to the CONTENT: it rides the scroll
|
||||
away like the album page's hero banner (Kyle, 2026-08-26). The
|
||||
pane-level .bleed above now backgrounds the RAIL alone. -->
|
||||
{#if artUrl}
|
||||
<div class="scroll-bleed" aria-hidden="true">
|
||||
<img src={artUrl} alt="" draggable="false" />
|
||||
<span class="bleed-fade"></span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if panelContentVisible}
|
||||
{#if currentItem}
|
||||
<!-- glowing cover art -->
|
||||
@@ -1578,6 +1720,12 @@
|
||||
antialiased once and the wash runs full-bleed to the very edge —
|
||||
no inset, no mask, no visible cost (the 1% dim is nothing). */
|
||||
opacity: 0.99;
|
||||
transition: opacity 0.28s ease;
|
||||
}
|
||||
/* Expanded, the pane-level wash yields to the scroller's own (rail keeps
|
||||
this one). 0 ← 0.99, never up — the flatten note above stands. */
|
||||
.nowplaying:not(.collapsed) .bleed {
|
||||
opacity: 0;
|
||||
}
|
||||
.bleed img {
|
||||
width: 100%;
|
||||
@@ -1597,6 +1745,105 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* The expanded view's wash, anchored to the scroller's CONTENT so it
|
||||
rides the scroll away — the album page's hero-banner grammar. Height
|
||||
tracks the pane width (the hero is width-driven), the fade span above
|
||||
lands on solid --bg-player before the box ends, so solid mode is
|
||||
seamless against the pane ground. Same flatten discipline as .bleed. */
|
||||
.scroll-bleed {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
aspect-ratio: 3 / 5;
|
||||
z-index: -1; /* np-scroll is a stacking context (z-index: 1) */
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
opacity: 0.99;
|
||||
}
|
||||
.scroll-bleed img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: blur(17px) saturate(1.25);
|
||||
opacity: 0.85;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
/* Dark glass: the pane joins the frost. The base thins like the sidebar
|
||||
(nets ~0.71 over the .app canvas tint), and the bleed's fade thins
|
||||
ONLY in its tail — it lands on solid --bg-player, which would keep
|
||||
the lower panel opaque. The upper stops stay IDENTICAL to solid mode:
|
||||
thinning them brightened the wash behind the glass header and the
|
||||
cover read straight through it (Kyle, 2026-08-26 — "don't change the
|
||||
glass topbar", the fix belongs to the wash, not the bar). Tail 0.85
|
||||
is the mini Wash vocabulary (its fades end near 0.92). Stops stay
|
||||
inside the flattened .bleed group, so the corner-arc story above is
|
||||
untouched. */
|
||||
:global(:root.glass) .nowplaying {
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
:global(:root.glass) .bleed-fade {
|
||||
background: linear-gradient(
|
||||
rgba(24, 24, 24, 0.15) 0,
|
||||
rgba(24, 24, 24, 0.75) 38%,
|
||||
rgba(24, 24, 24, 0.85) 78%
|
||||
);
|
||||
}
|
||||
/* The wash ends the way the album page's hero banner does (Kyle,
|
||||
2026-08-26): art and fade dissolve together below the hero zone and
|
||||
the lower pane is pure frost, the same 0.35 ground the sidebar and
|
||||
playbar stand on. Solid mode needs none of this: its fade already
|
||||
lands on opaque --bg-player. Masking a flattened group is one alpha
|
||||
op on one surface, so the corner-arc story above is untouched.
|
||||
(.bleed = the rail's wash, pane-relative %; .scroll-bleed = the
|
||||
expanded wash, self-relative — it also must reach nothing at its box
|
||||
end, where the glass fade tail would otherwise seam against frost.) */
|
||||
:global(:root.glass) .bleed {
|
||||
mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 45%, transparent 72%);
|
||||
}
|
||||
:global(:root.glass) .scroll-bleed {
|
||||
mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, #000 50%, transparent 96%);
|
||||
}
|
||||
|
||||
/* ── the head periscope, dark glass only (see the script note) ──────
|
||||
The head drops its backdrop-filter (over frost it black-flattens
|
||||
into a slab — Sidebar.svelte holds the measurements), the scroller's
|
||||
originals are masked out of the 46px band (direct-child selector:
|
||||
the clone inside .peri also wears .np-scroll and must not inherit
|
||||
it), and the clone paints the band as fog — wash included, so the
|
||||
record tint survives at rest and shifts as you scroll. */
|
||||
:global(:root.glass) .np-head {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
/* clip-path, not mask: rectangle clips ride the compositor's fast
|
||||
path (see App.svelte's band-cut note). */
|
||||
:global(:root.glass) .full-view > .np-scroll {
|
||||
clip-path: inset(46px 0 0 0);
|
||||
}
|
||||
.peri {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* The clone's filter and geometry are inline on its overscan wrapper —
|
||||
built in the reclone() above; the band mask can't reach the clone
|
||||
(direct-child selector), so no cancel rule is needed here. */
|
||||
/* The head's real content rides above the fog; the hairline is already
|
||||
absolute and only needs the lift. */
|
||||
.np-head > :global(:not(.peri):not(.hairline)) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.np-head .hairline {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Both views stack in the same box and cross-fade, on the left sidebar's
|
||||
own label clock — the collapse itself is the width animation above. */
|
||||
.rail-view,
|
||||
@@ -1630,6 +1877,7 @@
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
|
||||
/* The transfer, as a hairline along the glass bar's bottom edge — the same
|
||||
object in the rail and in the expanded header, so a download reads the
|
||||
same whichever state the panel is in. */
|
||||
|
||||
Reference in new issue
Block a user