forked from Keelrleek/SoulStream
previews: the glow goes, the eq steps, and every clip lands before you scroll to it
The track preview modal could hold a machine at 25% GPU. Three separate causes, each measured in isolation with a kill-style ladder and, once the GPU-engine counter proved to be clock-relative (a lighter workload read HIGHER), with clock-immune process CPU-seconds. Idle drain. The scrim's backdrop-filter is re-executed on every presented frame -- a backdrop filter is never cached -- so its cost is presents/sec times window area. Two infinite animations kept the modal presenting at the display's refresh rate forever: the eq capsule's bars animated `height` (a layout property, so style/layout/paint on the main thread every vsync) and the cover glow's halo-breathe kept a blur(48px) copy recompositing. Either one alone kept the pipeline hot, which is why killing one changed nothing. The glow is deleted outright at Kyle's request; the eq bars now scale with a stepped transform whose three delays are multiples of the step interval, so all three jump on the same instants and one present serves them all. Playing steady state: 7.2-10.5% of a core down to 1.4-2.2%. Paused: 0.2%. Scrolling. The animations turned out to be innocent here -- reverting them moved the number not at all. The cost was per-step work: re-decoding the full artwork for the background wash, a main-thread accent scan, a new cover mount, three times a second. The wash is now a 96x58 canvas with the blur/saturate/brightness baked in at draw time and cached per artwork, and an overlay preloader walks the manifest once at open -- nearest to the active card first, capped at 60 -- filling the decoded-image, wash and accent caches ahead of the user. A rapid-nav jump-cut was tried and rejected on sight; the animations stay full at every rate. Backend. preview_resolve swept the clip directory on every call, and any landing outside the +/-1 prefetch window paid a full Deezer mint, download, validate, write and loudness measure. The fetch-store-measure core is factored out and a new preview_prewarm walks the session's track ids at open, paced 250ms on top of the existing Deezer throttle and starting 1.5s in so the first clip owns the network, halted by a generation guard on dispose. A burst landing on a never-visited track is now audible in 154ms off local disk. The sweep is throttled to ten minutes, and dispose deletes prewarmed files instead of orphaning them until the hourly reap. The debug overlay's GPU row was summing the counters machine-wide, so it read roughly double the app's share with Discord and DWM folded in. It now reports `app X% - sys Y%`, the app figure restricted to our process tree, matching Task Manager's per-process rows.
This commit is contained in:
1 parent
eba29824c9
commit
69f64c11eb
8 files changed
+440
-116
No files matched your search
@@ -110,6 +110,15 @@
|
||||
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);
|
||||
|
||||
@@ -135,7 +135,10 @@
|
||||
{#if stats}
|
||||
<div class="sec">
|
||||
<div><b>CPU</b> app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%</div>
|
||||
<div><b>GPU</b> {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}</div>
|
||||
<!-- app = OUR process tree (what Task Manager shows per
|
||||
process); sys = machine-wide, DWM and Discord included.
|
||||
Only the app number is this app's doing. -->
|
||||
<div><b>GPU</b> {stats.gpuAppPercent != null ? `app ${stats.gpuAppPercent.toFixed(1)}%` : "app n/a"} · sys {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}%` : "n/a"} (3D)</div>
|
||||
<div><b>RAM</b> ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB</div>
|
||||
<div class="dim">system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs</div>
|
||||
<div><b>NET</b> ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}</div>
|
||||
|
||||
@@ -85,21 +85,129 @@
|
||||
});
|
||||
|
||||
// ----------------------------------------------------- backdrop slots --
|
||||
// Two crossfading bleed layers, exactly the mockup's setBackdrop(). The
|
||||
// effect depends ONLY on the active artwork; the slot bookkeeping is read
|
||||
// and written under untrack() so it can never invalidate the effect
|
||||
// (a read-and-write of the same $state is a self-triggering loop).
|
||||
let bleedA = $state("");
|
||||
let bleedB = $state("");
|
||||
// Two crossfading bleed layers, the mockup's setBackdrop() — but drawn
|
||||
// as PRE-BLURRED tiny canvases rather than full-size imgs under a CSS
|
||||
// blur(70px). A live 70px blur is a render pass over the whole modal;
|
||||
// static it is cached, but through every step's 420ms crossfade BOTH
|
||||
// slots were live and the compositor re-ran two full-size blurs per
|
||||
// presented frame — the bulk of the stepping cost. The blur now happens
|
||||
// once per artwork on a 96×58 canvas (4px there ≈ 70px at panel scale;
|
||||
// the GPU's bilinear upscale supplies the rest of the softness), and the
|
||||
// crossfade blends two small cached textures. Saturate/brightness are
|
||||
// baked into the same draw. The effect depends ONLY on the active
|
||||
// artwork; slot bookkeeping is read and written under untrack() so it
|
||||
// can never invalidate the effect.
|
||||
const BLEED_W = 96;
|
||||
const BLEED_H = 58; // modal max aspect 1660×1000
|
||||
let bleedElA = $state(null);
|
||||
let bleedElB = $state(null);
|
||||
let bleedDrawnA = $state(false);
|
||||
let bleedDrawnB = $state(false);
|
||||
let bleedSlot = $state(0);
|
||||
let bleedShownArt = "";
|
||||
let bleedGen = 0;
|
||||
// art URL → pre-filtered 96×58 offscreen canvas. Stepping back to a
|
||||
// track becomes a tiny canvas blit instead of a full artwork re-decode —
|
||||
// the decode was the dominant per-step cost of rapid scrolling. Tainted
|
||||
// canvases (cross-origin art) blit fine; they just can't be read back.
|
||||
const bleedCache = new Map();
|
||||
async function renderBleed(art) {
|
||||
if (bleedCache.has(art)) return bleedCache.get(art);
|
||||
const img = new Image();
|
||||
img.decoding = "async";
|
||||
img.src = art;
|
||||
try {
|
||||
await img.decode();
|
||||
} catch {
|
||||
return null; // keep the previous wash rather than flash blank
|
||||
}
|
||||
const sw = img.naturalWidth;
|
||||
const sh = img.naturalHeight;
|
||||
if (!sw || !sh) return null;
|
||||
const off = document.createElement("canvas");
|
||||
off.width = BLEED_W;
|
||||
off.height = BLEED_H;
|
||||
// Cover-crop the artwork to the canvas aspect (the old object-fit).
|
||||
const scale = Math.max(BLEED_W / sw, BLEED_H / sh);
|
||||
const w = BLEED_W / scale;
|
||||
const h = BLEED_H / scale;
|
||||
const ctx = off.getContext("2d");
|
||||
ctx.filter = "saturate(1.2) brightness(0.82) blur(4px)";
|
||||
ctx.drawImage(img, (sw - w) / 2, (sh - h) / 2, w, h, 0, 0, BLEED_W, BLEED_H);
|
||||
bleedCache.set(art, off);
|
||||
return off;
|
||||
}
|
||||
async function drawBleed(canvas, art) {
|
||||
const off = await renderBleed(art);
|
||||
if (!off) return false;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.filter = "none";
|
||||
ctx.clearRect(0, 0, BLEED_W, BLEED_H);
|
||||
ctx.drawImage(off, 0, 0);
|
||||
return true;
|
||||
}
|
||||
// -------------------------------------------------------- preloader ----
|
||||
// Decode-once, at open: every step needs three things from the active
|
||||
// artwork — the card's decoded image, the 96×58 wash, the accent pair —
|
||||
// and none of them should be produced while the user is scrolling
|
||||
// (per-step decodes were the whole cost of rapid stepping, measured
|
||||
// 11.9% → 6.1% of a core just from caching). This walks the manifest
|
||||
// ONCE when a session opens, nearest-to-active first, and fills every
|
||||
// cache ahead of the user. One item at a time with a yield between, so
|
||||
// the open animation and first clip resolve are never starved; ~20
|
||||
// items are fully warm within a couple of seconds.
|
||||
$effect(() => {
|
||||
const m = manifest;
|
||||
if (!m || !previewUi.open) return;
|
||||
const start = untrack(() => activeIndex ?? 0);
|
||||
let cancelled = false;
|
||||
const arts = [];
|
||||
const seen = new Set();
|
||||
// nearest-first: start, start±1, start±2, … capped at 60 artworks
|
||||
// so a 500-item manifest doesn't churn for minutes — beyond the cap
|
||||
// the per-step lazy path (renderBleed's own cache) still applies.
|
||||
const CAP = 60;
|
||||
outer: for (let d = 0; d < m.items.length; d++) {
|
||||
for (const idx of d === 0 ? [start] : [start + d, start - d]) {
|
||||
const art = m.items[idx]?.artwork;
|
||||
if (art && !seen.has(art)) {
|
||||
seen.add(art);
|
||||
arts.push(art);
|
||||
if (arts.length >= CAP) break outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
(async () => {
|
||||
for (const art of arts) {
|
||||
if (cancelled) return;
|
||||
await renderBleed(art); // decodes the image, caches the wash
|
||||
if (cancelled) return;
|
||||
if (!accentCache.has(art)) {
|
||||
const c = await accentFromImage(art);
|
||||
if (c) accentCache.set(art, c);
|
||||
}
|
||||
// Yield so the warm-up never competes with a live step.
|
||||
await new Promise((r) => setTimeout(r, 60));
|
||||
}
|
||||
})();
|
||||
return () => (cancelled = true);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const art = activeItem?.artwork ?? "";
|
||||
untrack(() => {
|
||||
const showing = bleedSlot === 0 ? bleedB : bleedA;
|
||||
if (art === showing) return;
|
||||
if (bleedSlot === 0) bleedA = art;
|
||||
else bleedB = art;
|
||||
bleedSlot = bleedSlot === 0 ? 1 : 0;
|
||||
if (!art || art === bleedShownArt) return;
|
||||
const gen = ++bleedGen;
|
||||
// slot 0 shows B, so A is hidden and takes the incoming draw.
|
||||
const target = bleedSlot === 0 ? bleedElA : bleedElB;
|
||||
if (!target) return;
|
||||
drawBleed(target, art).then((ok) => {
|
||||
if (!ok || gen !== bleedGen) return;
|
||||
bleedShownArt = art;
|
||||
if (bleedSlot === 0) bleedDrawnA = true;
|
||||
else bleedDrawnB = true;
|
||||
bleedSlot = bleedSlot === 0 ? 1 : 0;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -244,6 +352,11 @@
|
||||
let announce = $state("");
|
||||
|
||||
// ------------------------------------------------------- direction -----
|
||||
// Steps keep their full animations at EVERY rate (a jump-cut fast path
|
||||
// was tried 2026-08-01 and rejected on sight). What makes rapid
|
||||
// stepping affordable instead is the session preloader below: by the
|
||||
// time a step lands, its wash, accent and artwork are already decoded,
|
||||
// so the step's cost is the (compositor-cheap) transitions themselves.
|
||||
function step(dir) {
|
||||
stepDir = dir;
|
||||
previewCommands.step(dir);
|
||||
@@ -346,6 +459,19 @@
|
||||
const MAX_AMP = 15;
|
||||
let waveCanvas = $state(null);
|
||||
let waveWrap = $state(null);
|
||||
// Width comes from a ResizeObserver, not a per-draw clientWidth read —
|
||||
// position events arrive 10×/s and each read was a forced layout.
|
||||
let waveW = $state(0);
|
||||
$effect(() => {
|
||||
if (!waveWrap) return;
|
||||
waveW = waveWrap.clientWidth;
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const w = entries[entries.length - 1]?.contentRect.width;
|
||||
if (w != null) waveW = Math.round(w);
|
||||
});
|
||||
ro.observe(waveWrap);
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
const peaksCache = new Map();
|
||||
function peaksFor(key) {
|
||||
if (peaksCache.has(key)) return peaksCache.get(key);
|
||||
@@ -365,13 +491,14 @@
|
||||
void st?.positionMs;
|
||||
void activeItem?.sessionKey;
|
||||
void pvAccent;
|
||||
void waveW;
|
||||
drawWave();
|
||||
});
|
||||
|
||||
function drawWave() {
|
||||
if (!waveCanvas || !waveWrap || !activeItem) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const W = waveWrap.clientWidth;
|
||||
const W = waveW;
|
||||
if (W <= 0) return;
|
||||
const pw = Math.max(1, Math.round(W * dpr));
|
||||
const ph = Math.max(1, Math.round(WAVE_H * dpr));
|
||||
@@ -489,7 +616,6 @@
|
||||
></div>
|
||||
<div
|
||||
class="modal"
|
||||
class:is-playing={playing}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
|
||||
@@ -499,8 +625,20 @@
|
||||
out:scale={{ duration: 190, start: 0.97 }}
|
||||
>
|
||||
<div class="bg" aria-hidden="true">
|
||||
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
|
||||
<img class="bleed" class:show={bleedSlot === 0 && bleedB} src={bleedB || undefined} alt="" />
|
||||
<canvas
|
||||
class="bleed"
|
||||
width={BLEED_W}
|
||||
height={BLEED_H}
|
||||
class:show={bleedSlot === 1 && bleedDrawnA}
|
||||
bind:this={bleedElA}
|
||||
></canvas>
|
||||
<canvas
|
||||
class="bleed"
|
||||
width={BLEED_W}
|
||||
height={BLEED_H}
|
||||
class:show={bleedSlot === 0 && bleedDrawnB}
|
||||
bind:this={bleedElB}
|
||||
></canvas>
|
||||
<div class="scrim-x"></div>
|
||||
</div>
|
||||
|
||||
@@ -621,9 +759,6 @@
|
||||
|
||||
<div class="reel-col">
|
||||
<div class="reel" bind:this={reelEl} onwheel={onWheel}>
|
||||
{#if activeItem?.artwork}
|
||||
<img class="cover-glow" src={activeItem.artwork} alt="" aria-hidden="true" />
|
||||
{/if}
|
||||
<div class="reel-track">
|
||||
{#each visibleCards as { item, index } (item.sessionKey)}
|
||||
{@const avail = availabilityOf(item)}
|
||||
@@ -715,7 +850,17 @@
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
/* Spotify-style: the page behind darkens clearly and blurs a touch,
|
||||
so the panel reads as a floating modal rather than a full takeover. */
|
||||
so the panel reads as a floating modal rather than a full takeover.
|
||||
PERF (2026-08-01): this backdrop blur is re-executed by the
|
||||
compositor on EVERY presented frame (backdrop filters are never
|
||||
cached), so its cost is presents/sec × window area. Moving the
|
||||
blur onto the page panels as a plain filter was tried and measured
|
||||
WORSE (~2×) — see the note in App.svelte. What keeps this cheap is
|
||||
the modal's low present rate: the eq is a stepped animation and
|
||||
the position ticks arrive at 10/s, so steady-state presents run at
|
||||
~10–40/s, not the display's refresh rate. If a future change adds
|
||||
any smooth infinite animation to the open modal, this blur is the
|
||||
first thing that will explode again. */
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
@@ -759,14 +904,17 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
.bleed {
|
||||
/* A 96×58 pre-blurred canvas stretched over the panel — the blur,
|
||||
saturate and brightness are baked at draw time (see drawBleed);
|
||||
a live blur(70px) here was two full-size render passes per frame
|
||||
through every step crossfade. The cover-crop happens in the draw,
|
||||
so the canvas simply stretches. */
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.42s ease;
|
||||
filter: blur(70px) saturate(1.2) brightness(0.82);
|
||||
transform: scale(1.25);
|
||||
}
|
||||
.bleed.show {
|
||||
@@ -1113,47 +1261,24 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* Artwork bloom (B2): a blurred copy of the active cover, box-feathered
|
||||
by two crossed linear gradients intersected — a radial mask can only
|
||||
ever make a disc. no-repeat stops the gradient tiling into blur spill. */
|
||||
.cover-glow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
z-index: 0;
|
||||
--glow-w: calc(var(--cover) + var(--bleed-room) * 2);
|
||||
width: var(--glow-w);
|
||||
height: var(--glow-w);
|
||||
margin: calc(var(--glow-w) / -2) 0 0 calc(var(--glow-w) / -2);
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
filter: blur(48px) saturate(1.3);
|
||||
--glow-fade: transparent 0%, rgba(0, 0, 0, 0.18) 10%, rgba(0, 0, 0, 0.5) 19%,
|
||||
rgba(0, 0, 0, 0.82) 27%, #000 36%, #000 64%, rgba(0, 0, 0, 0.82) 73%,
|
||||
rgba(0, 0, 0, 0.5) 81%, rgba(0, 0, 0, 0.18) 90%, transparent 100%;
|
||||
-webkit-mask-image: linear-gradient(90deg, var(--glow-fade)), linear-gradient(180deg, var(--glow-fade));
|
||||
mask-image: linear-gradient(90deg, var(--glow-fade)), linear-gradient(180deg, var(--glow-fade));
|
||||
-webkit-mask-composite: source-in;
|
||||
mask-composite: intersect;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
opacity: 0.55;
|
||||
transition: opacity 0.42s ease;
|
||||
}
|
||||
@keyframes halo-breathe {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.modal.is-playing .cover-glow {
|
||||
animation: halo-breathe 3.8s ease-in-out infinite;
|
||||
}
|
||||
/* Artwork bloom (B2) REMOVED 2026-08-01 (user request, perf): the
|
||||
blur(48px) copy + its halo-breathe opacity animation kept the
|
||||
compositor producing frames the whole time the modal played — the
|
||||
breathe alone measured ~3% of a core on a strong GPU, far more on
|
||||
weak ones. The bleed backdrop already carries the colour wash. */
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
/* top set inline from JS ((index − activeIndex) × measured stride);
|
||||
transitioning it is what produces the slide — the track itself
|
||||
never moves. */
|
||||
never moves. The slide must live on `top`, NOT inside transform:
|
||||
the arrival bump animation owns `transform` for its 360ms, so a
|
||||
transform-borne slide is overridden the instant a step lands and
|
||||
the incoming card TELEPORTS to centre instead of gliding (shipped
|
||||
by mistake 2026-08-01, caught by Kyle same day). The per-frame
|
||||
layout of a top transition measured as noise — nine absolutely
|
||||
positioned cards in a zero-height track. */
|
||||
width: var(--cover);
|
||||
height: var(--cover);
|
||||
border-radius: 8px;
|
||||
@@ -1187,7 +1312,9 @@
|
||||
}
|
||||
/* Bump −50%: arrival grows from the idle scale through 1.035 to rest.
|
||||
translateY(-50%) is preserved through the keyframe so the card stays
|
||||
centred while it scales. */
|
||||
centred while it scales — and the SLIDE rides `top` in parallel,
|
||||
which is why it must not move into transform (see .card). Keyframes
|
||||
stay literal: var() in keyframes forces main-thread animation. */
|
||||
@keyframes cover-bump {
|
||||
0% { transform: translateY(-50%) scale(0.74); }
|
||||
58% { transform: translateY(-50%) scale(1.035); }
|
||||
@@ -1255,17 +1382,33 @@
|
||||
transition: background 0.35s ease;
|
||||
}
|
||||
.eq i {
|
||||
/* Two perf rules meet in this one declaration:
|
||||
1. scaleY, never a height animation — height is layout, and the
|
||||
old height bounce ran style→layout→paint on the main thread
|
||||
every vsync for as long as a preview played.
|
||||
2. steps(), never a smooth glide — a smooth compositor animation
|
||||
makes the modal PRESENT at the display's refresh rate, and
|
||||
every present re-executes the scrim's backdrop blur over the
|
||||
whole window (~10% of a desktop GPU at 240Hz; far worse on
|
||||
weak machines). Stepped, each bar changes ~11×/s, presents
|
||||
drop to ~10–40/s, and the whole modal idles at ~2%. The
|
||||
5-level bounce reads as a VU meter, which is what it is. */
|
||||
width: 2.5px;
|
||||
height: 5px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border-radius: 1.5px;
|
||||
animation: eq 0.9s ease-in-out infinite;
|
||||
transform: scaleY(0.333);
|
||||
animation: eq 0.9s steps(5, jump-none) infinite;
|
||||
}
|
||||
.eq i:nth-child(2) { animation-delay: 0.15s; }
|
||||
.eq i:nth-child(3) { animation-delay: 0.3s; }
|
||||
/* Delays are MULTIPLES OF THE STEP INTERVAL (0.9s / 8 jumps = 112.5ms),
|
||||
so all three bars jump at the same instants — one present serves all
|
||||
three. 150/300ms put each bar on its own grid and tripled the present
|
||||
rate for a stagger the eye cannot tell from this one. */
|
||||
.eq i:nth-child(2) { animation-delay: 0.1125s; }
|
||||
.eq i:nth-child(3) { animation-delay: 0.225s; }
|
||||
@keyframes eq {
|
||||
0%, 100% { height: 4px; }
|
||||
50% { height: 12px; }
|
||||
0%, 100% { transform: scaleY(0.333); }
|
||||
50% { transform: scaleY(1); }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- foot */
|
||||
@@ -1313,14 +1456,18 @@
|
||||
height: 5px;
|
||||
}
|
||||
.rail i {
|
||||
/* Full 5px box scaled down — the 3↔5px grow was a height transition,
|
||||
which is layout work on every step; scaleY is compositor-only and
|
||||
reads the same at this size. */
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
height: 5px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
transition: background 0.25s ease, height 0.25s ease;
|
||||
transform: scaleY(0.6);
|
||||
transition: background 0.25s ease, transform 0.25s ease;
|
||||
}
|
||||
.rail i.done { background: rgba(255, 255, 255, 0.34); }
|
||||
.rail i.now { background: var(--pv-accent); height: 5px; }
|
||||
.rail i.now { background: var(--pv-accent); transform: none; }
|
||||
.rail i.gone { background: transparent; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16); }
|
||||
.m-pos {
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -1367,8 +1514,7 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.card { transition: opacity 0.3s ease, filter 0.3s ease; }
|
||||
.card.active.hit { animation: none; }
|
||||
.modal.is-playing .cover-glow { animation: none; }
|
||||
.eq i { animation: none; height: 8px; }
|
||||
.eq i { animation: none; transform: scaleY(0.667); }
|
||||
.m-left :global(.swap-out-up),
|
||||
.m-left :global(.swap-out-down),
|
||||
.m-left :global(.swap-in-up),
|
||||
|
||||
@@ -161,6 +161,10 @@ export const streamWaveform = (username, path, size, buckets) =>
|
||||
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
||||
// never crosses this boundary, only an opaque lease id does.
|
||||
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
||||
// Fire-and-forget session prewarm: pulls every listed clip onto disk ahead
|
||||
// of the user (paced, capped, cancelled by dispose). The invoke resolves
|
||||
// when the walk finishes — callers void it.
|
||||
export const previewPrewarm = (trackIds) => invoke("preview_prewarm", { trackIds });
|
||||
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
||||
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
|
||||
export const playerFadePauseAcked = (fadeMs) =>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// 4000-line state module, and the state module needs to import US for
|
||||
// gateway bumps — injection breaks what would otherwise be a cycle).
|
||||
|
||||
import * as api from "../api.js";
|
||||
import { createPreviewService } from "./service.js";
|
||||
import { createProductionResolver } from "./productionResolver.js";
|
||||
import { createNativeTransport } from "./nativeTransport.js";
|
||||
@@ -93,6 +94,21 @@ export async function openPreview(manifest, { playFull, openAlbum } = {}) {
|
||||
if (p) service.setVolume(p.muted ? 0 : (p.volume ?? 0.7));
|
||||
previewUi.open = true;
|
||||
await service.open({ sessionId: crypto.randomUUID(), manifest });
|
||||
// Session prewarm (backend twin of the overlay's artwork preloader):
|
||||
// pull every clip onto disk ahead of the user so any step resolves
|
||||
// locally, without the network. Fire-and-forget — the backend paces
|
||||
// itself, starts after the opening resolve has had the network to
|
||||
// itself, and halts on dispose.
|
||||
const ids = [
|
||||
...new Set(
|
||||
manifest.items
|
||||
.map((i) => i.sources?.[0])
|
||||
.filter((s) => s?.provider === "deezer")
|
||||
.map((s) => Number(s.providerTrackId))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
),
|
||||
];
|
||||
if (ids.length) void api.previewPrewarm(ids).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user