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.
1526 lines
60 KiB
Svelte
1526 lines
60 KiB
Svelte
<script>
|
||
// Preview tracks modal — the approved mockup
|
||
// (TODO-Feature-Docs/scrollable-track-previews/mockups/artist-preview-modal.html)
|
||
// reproduced exactly, per the recorded fidelity rule: geometry, colours,
|
||
// masks, easings and width relationships are the mockup's; only the
|
||
// typography comes from the app's own font stack.
|
||
//
|
||
// Every visual decision here has an entry in the design doc's "Confirmed
|
||
// visual decisions" table: twin-glow bleed, box-feathered artwork bloom,
|
||
// edge treatment J, 74% neighbours, Bump −50%, H44 waveform scrubber,
|
||
// R1b capsule, T3 directional metadata swaps, folded control row with
|
||
// the P2 ghost pill, F3 footer.
|
||
import { tick, untrack } from "svelte";
|
||
import { fade, scale } from "svelte/transition";
|
||
import { cubicOut } from "svelte/easing";
|
||
import {
|
||
previewUi,
|
||
previewCommands,
|
||
closePreview,
|
||
} from "../lib/preview/previewSession.svelte.js";
|
||
import { player } from "../lib/state.svelte.js";
|
||
import {
|
||
playableManifestIndices,
|
||
playableCursor,
|
||
PHASE,
|
||
} from "../lib/preview/session.js";
|
||
import { accentFromImage } from "../lib/color.js";
|
||
|
||
// Preview audio follows the APP volume — mute means silent previews
|
||
// too. Mirrored live while the modal is open (the session also sets it
|
||
// once at open, before the first load).
|
||
$effect(() => {
|
||
if (!previewUi.open) return;
|
||
previewCommands.setVolume(player.muted ? 0 : player.volume);
|
||
});
|
||
|
||
const st = $derived(previewUi.snapshot);
|
||
const manifest = $derived(st?.manifest ?? null);
|
||
const items = $derived(manifest?.items ?? []);
|
||
const activeIndex = $derived(st?.activeManifestIndex ?? null);
|
||
const activeItem = $derived(activeIndex != null ? items[activeIndex] : null);
|
||
const playing = $derived(st?.phase === PHASE.PLAYING);
|
||
const ended = $derived(st?.phase === PHASE.ENDED);
|
||
const failed = $derived(st?.phase === PHASE.ITEM_FAILED);
|
||
// The play button shows INTENT, not the transport's instantaneous phase:
|
||
// during a step the session passes through resolving/buffering for a few
|
||
// hundred ms, and flipping the glyph there reads as a stutter (user
|
||
// report 2026-07-23). togglePlay() parks from those phases, so the pause
|
||
// glyph is honest — clicking it stops the audio either way.
|
||
const wantsPlay = $derived(
|
||
playing ||
|
||
(st?.desiredPlaying &&
|
||
(st?.phase === PHASE.RESOLVING_MEDIA || st?.phase === PHASE.BUFFERING))
|
||
);
|
||
const playable = $derived(st ? playableManifestIndices(st) : []);
|
||
const cursor = $derived(st ? playableCursor(st) : null);
|
||
const unavailableCount = $derived(items.length - playable.length);
|
||
|
||
// ------------------------------------------------------------ accent --
|
||
// Panel-scoped accent (--pv-accent) extracted from the active cover with
|
||
// the app's own extractor. The GLOBAL accent belongs to the track that is
|
||
// really playing and is never touched from here.
|
||
let pvAccent = $state("#1f9ad6");
|
||
let pvHover = $state("#41b7ee");
|
||
const accentCache = new Map();
|
||
$effect(() => {
|
||
const art = activeItem?.artwork;
|
||
if (!art) return;
|
||
if (accentCache.has(art)) {
|
||
const c = accentCache.get(art);
|
||
pvAccent = c.accent;
|
||
pvHover = c.hover;
|
||
return;
|
||
}
|
||
let live = true;
|
||
accentFromImage(art).then((c) => {
|
||
if (!c) return;
|
||
accentCache.set(art, c);
|
||
if (live && activeItem?.artwork === art) {
|
||
pvAccent = c.accent;
|
||
pvHover = c.hover;
|
||
}
|
||
});
|
||
return () => (live = false);
|
||
});
|
||
|
||
// ----------------------------------------------------- backdrop slots --
|
||
// 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(() => {
|
||
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;
|
||
});
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------------- reel ----
|
||
// Virtualized: only a window of cards around the active index renders,
|
||
// positioned WINDOW-RELATIVE (offset from the active index, not from
|
||
// manifest index 0), so a 500-item manifest costs the same as a 10-item
|
||
// one — the Phase 2 exit gate — and no coordinate ever exceeds
|
||
// ±RENDER_RADIUS × stride.
|
||
const RENDER_RADIUS = 4;
|
||
const GAP = 30;
|
||
const windowStart = $derived(Math.max(0, (activeIndex ?? 0) - RENDER_RADIUS));
|
||
const windowEnd = $derived(Math.min(items.length - 1, (activeIndex ?? 0) + RENDER_RADIUS));
|
||
const visibleCards = $derived(
|
||
items.slice(windowStart, windowEnd + 1).map((item, k) => ({
|
||
item,
|
||
index: windowStart + k,
|
||
}))
|
||
);
|
||
|
||
// Stride comes from the RESOLVED `--cover` (a clamp/vh the JS cannot
|
||
// predict), measured off the live reel. CSS calc with an inherited
|
||
// unitless var proved unreliable here, so JS owns the arithmetic — but
|
||
// from the real pixel value, which is what made the earlier viewport-guess
|
||
// version drift.
|
||
let reelEl = $state(null);
|
||
let stride = $state(220);
|
||
function measureStride() {
|
||
if (!reelEl) return;
|
||
// The reel is `--cover + 2*--bleed-room(70px)` wide, so the resolved
|
||
// cover is reel width minus 140 — always available, transform-immune.
|
||
const cover = reelEl.clientWidth - 140;
|
||
if (cover > 0) stride = cover + GAP;
|
||
}
|
||
$effect(() => {
|
||
if (!reelEl) return;
|
||
measureStride();
|
||
const ro = new ResizeObserver(measureStride);
|
||
ro.observe(reelEl);
|
||
return () => ro.disconnect();
|
||
});
|
||
// Window-relative positioning: each card sits at (index − activeIndex) ×
|
||
// stride from the reel centre, so the ACTIVE card is always exactly at the
|
||
// centre with NO track translate. The slide is each card's own small `top`
|
||
// transitioning; coordinates never exceed ±RENDER_RADIUS × stride, which
|
||
// kills two bugs at once — the giant absolute tops at index 500, and the
|
||
// transitioned track translateY that Chromium landed at 0 (a transition on
|
||
// a percent-translated zero-height track dropped the Y component; see
|
||
// HANDOFF-PHASE2C.md for the measurements).
|
||
function cardOffset(index) {
|
||
return (index - (activeIndex ?? 0)) * stride;
|
||
}
|
||
|
||
function availabilityOf(item) {
|
||
return st?.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
||
}
|
||
|
||
// ------------------------------------------------------ bump replay ----
|
||
// Re-key the active card on each generation so the arrival keyframe
|
||
// replays. Written under untrack() and driven strictly off the generation.
|
||
let bumpKey = $state(0);
|
||
let lastBumpGen = -1;
|
||
$effect(() => {
|
||
const gen = st?.requestGeneration ?? -1;
|
||
untrack(() => {
|
||
if (gen !== lastBumpGen) {
|
||
lastBumpGen = gen;
|
||
bumpKey += 1;
|
||
}
|
||
});
|
||
});
|
||
|
||
// ----------------------------------------------- T3 metadata swaps -----
|
||
// One element per row, two phases, per-row generation guard, no
|
||
// same-text skip — the exact semantics fixed in the mockup after the
|
||
// shared-counter bug stranded rows at opacity 0.
|
||
let titleEl = $state(null);
|
||
let artistEl = $state(null);
|
||
let albumEl = $state(null);
|
||
let stepDir = 1;
|
||
let lastGeneration = -1;
|
||
const swapGen = new Map();
|
||
let firstPaint = true;
|
||
|
||
const artistLine = $derived(activeItem ? activeItem.artists.join(", ") : "");
|
||
const albumLine = $derived(
|
||
activeItem
|
||
? [activeItem.album, fmtMs(activeItem.fullDurationMs)].filter(Boolean).join(" · ")
|
||
: ""
|
||
);
|
||
|
||
function fmtMs(ms) {
|
||
if (!Number.isFinite(ms)) return "";
|
||
const s = Math.round(ms / 1000);
|
||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
||
}
|
||
|
||
function setRow(node, text) {
|
||
if (!node) return;
|
||
if (firstPaint || matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||
node.textContent = text;
|
||
return;
|
||
}
|
||
const dir = stepDir > 0 ? "up" : "down";
|
||
const gen = (swapGen.get(node) ?? 0) + 1;
|
||
swapGen.set(node, gen);
|
||
node.classList.remove("swap-in-up", "swap-in-down", "swap-out-up", "swap-out-down");
|
||
void node.offsetWidth;
|
||
node.classList.add(`swap-out-${dir}`);
|
||
setTimeout(() => {
|
||
if (swapGen.get(node) !== gen) return; // a newer step owns this row
|
||
node.textContent = text;
|
||
node.classList.remove(`swap-out-${dir}`);
|
||
void node.offsetWidth;
|
||
node.classList.add(`swap-in-${dir}`);
|
||
}, 150);
|
||
}
|
||
|
||
$effect(() => {
|
||
if (!activeItem) return;
|
||
const gen = st.requestGeneration;
|
||
const title = activeItem.title;
|
||
const artists = artistLine;
|
||
const album = albumLine;
|
||
const posText = `Preview ${cursor != null ? cursor + 1 : "—"} of ${playable.length}.`;
|
||
// The DOM writes and `announce`/`lastGeneration` updates happen after
|
||
// a tick and under untrack(), so this effect depends only on the
|
||
// active item's data — never on anything it itself writes.
|
||
tick().then(() =>
|
||
untrack(() => {
|
||
if (gen === lastGeneration) return;
|
||
lastGeneration = gen;
|
||
setRow(titleEl, title);
|
||
setRow(artistEl, artists);
|
||
setRow(albumEl, album);
|
||
firstPaint = false;
|
||
announce = `${title}, ${artists}. ${posText}`;
|
||
})
|
||
);
|
||
});
|
||
|
||
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);
|
||
}
|
||
function activate(item, index) {
|
||
if (index === activeIndex) return;
|
||
stepDir = index > activeIndex ? 1 : -1;
|
||
previewCommands.activateKey(item.sessionKey);
|
||
}
|
||
|
||
// ------------------------------------------------------------- wheel ---
|
||
let wheelLock = 0;
|
||
function onWheel(e) {
|
||
e.preventDefault();
|
||
// Two gates (mockup-verbatim): magnitude kills trackpad noise, the
|
||
// time gate absorbs momentum after a real flick.
|
||
if (Math.abs(e.deltaY) < 12) return;
|
||
const now = performance.now();
|
||
if (now - wheelLock < 300) return;
|
||
wheelLock = now;
|
||
step(e.deltaY > 0 ? 1 : -1);
|
||
}
|
||
|
||
// ---------------------------------------------------------- keyboard ---
|
||
function onKeydown(e) {
|
||
if (!previewUi.open || e.defaultPrevented) return;
|
||
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName ?? "");
|
||
switch (e.key) {
|
||
case "Escape":
|
||
e.preventDefault();
|
||
void closePreview();
|
||
break;
|
||
case "ArrowDown":
|
||
case "PageDown":
|
||
e.preventDefault();
|
||
step(1);
|
||
break;
|
||
case "ArrowUp":
|
||
case "PageUp":
|
||
e.preventDefault();
|
||
step(-1);
|
||
break;
|
||
case "Home":
|
||
e.preventDefault();
|
||
if (playable.length) {
|
||
stepDir = -1;
|
||
previewCommands.activateKey(items[playable[0]].sessionKey);
|
||
}
|
||
break;
|
||
case "End":
|
||
e.preventDefault();
|
||
if (playable.length) {
|
||
stepDir = 1;
|
||
previewCommands.activateKey(items[playable[playable.length - 1]].sessionKey);
|
||
}
|
||
break;
|
||
case " ":
|
||
if (inField) return;
|
||
e.preventDefault();
|
||
void previewCommands.togglePlay();
|
||
break;
|
||
case "Tab":
|
||
trapTab(e);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Focus containment: Tab cycles inside the dialog only.
|
||
let dialogEl = $state(null);
|
||
function trapTab(e) {
|
||
if (!dialogEl) return;
|
||
const focusables = dialogEl.querySelectorAll(
|
||
'button:not([disabled]), [href], canvas[tabindex], [tabindex]:not([tabindex="-1"])'
|
||
);
|
||
if (!focusables.length) return;
|
||
const first = focusables[0];
|
||
const last = focusables[focusables.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) {
|
||
e.preventDefault();
|
||
last.focus();
|
||
} else if (!e.shiftKey && document.activeElement === last) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
} else if (!dialogEl.contains(document.activeElement)) {
|
||
e.preventDefault();
|
||
first.focus();
|
||
}
|
||
}
|
||
$effect(() => {
|
||
if (previewUi.open && dialogEl) {
|
||
tick().then(() => dialogEl?.querySelector(".close")?.focus());
|
||
}
|
||
});
|
||
|
||
// ------------------------------------------------------- scrubber ------
|
||
// The PlayerBar waveform recipe at modal height (H 44 / maxAmp 15).
|
||
// Peaks are deterministic pseudo-peaks per item until the backend ships
|
||
// real clip peaks (it already holds the bytes; tracked for Phase 7).
|
||
const WAVE_H = 44;
|
||
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);
|
||
let seed = 9301;
|
||
for (const ch of key) seed = (seed * 33 + ch.charCodeAt(0)) % 4294967296;
|
||
const rnd = () => ((seed = (seed * 1664525 + 1013904223) % 4294967296) / 4294967296);
|
||
const out = Array.from({ length: 200 }, (_, k) => {
|
||
const env = Math.sin((k / 200) * Math.PI) * 0.55 + 0.45;
|
||
return Math.min(1, (0.35 + rnd() * 0.65) * env);
|
||
});
|
||
peaksCache.set(key, out);
|
||
return out;
|
||
}
|
||
|
||
$effect(() => {
|
||
// deps: position, item, accent, size
|
||
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 = waveW;
|
||
if (W <= 0) return;
|
||
const pw = Math.max(1, Math.round(W * dpr));
|
||
const ph = Math.max(1, Math.round(WAVE_H * dpr));
|
||
if (waveCanvas.width !== pw || waveCanvas.height !== ph) {
|
||
waveCanvas.width = pw;
|
||
waveCanvas.height = ph;
|
||
}
|
||
const ctx = waveCanvas.getContext("2d");
|
||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
const pk = peaksFor(activeItem.sessionKey);
|
||
const N = pk.length;
|
||
const mid = WAVE_H / 2;
|
||
const amp = (v) => Math.max(0.7, v * MAX_AMP);
|
||
const path = new Path2D();
|
||
path.moveTo(0, mid - amp(pk[0]));
|
||
for (let i = 0; i < N; i++) path.lineTo((i / (N - 1)) * W, mid - amp(pk[i]));
|
||
for (let j = N - 1; j >= 0; j--) path.lineTo((j / (N - 1)) * W, mid + amp(pk[j]));
|
||
path.closePath();
|
||
|
||
const clip = st?.clipDurationMs ?? 30_000;
|
||
const playX = Math.max(0, Math.min(W, ((st?.positionMs ?? 0) / clip) * W));
|
||
ctx.clearRect(0, 0, W, WAVE_H);
|
||
const fillClip = (x0, x1, color) => {
|
||
if (x1 <= x0) return;
|
||
ctx.save();
|
||
ctx.beginPath();
|
||
ctx.rect(x0, 0, x1 - x0, WAVE_H);
|
||
ctx.clip();
|
||
ctx.fillStyle = color;
|
||
ctx.fill(path);
|
||
ctx.restore();
|
||
};
|
||
fillClip(0, W, "rgba(255,255,255,0.24)");
|
||
fillClip(0, playX, pvAccent);
|
||
ctx.fillStyle = "#ffffff";
|
||
ctx.fillRect(playX - 0.75, (WAVE_H - 38) / 2, 1.5, 38);
|
||
}
|
||
|
||
function onWaveClick(e) {
|
||
if (!waveWrap) return;
|
||
const r = waveWrap.getBoundingClientRect();
|
||
const clip = st?.clipDurationMs ?? 30_000;
|
||
previewCommands.seek(Math.max(0, Math.min(clip, ((e.clientX - r.left) / r.width) * clip)));
|
||
}
|
||
|
||
const posLabel = $derived(fmtMs(st?.positionMs ?? 0) || "0:00");
|
||
const clipLabel = $derived(fmtMs(st?.clipDurationMs ?? 30_000) || "0:30");
|
||
|
||
// Shared by the transport buttons and the reel-side arrows.
|
||
const prevDisabled = $derived(
|
||
cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0
|
||
);
|
||
const nextDisabled = $derived(
|
||
cursor == null
|
||
? activeIndex != null && !playable.some((i) => i > activeIndex)
|
||
: cursor >= playable.length - 1
|
||
);
|
||
|
||
async function onPlayFull() {
|
||
if (!previewUi.playFull || !activeItem) return;
|
||
const starter = previewUi.playFull;
|
||
await previewCommands.playFullTrack(async (item) => starter(item));
|
||
await closePreview();
|
||
}
|
||
|
||
// "Open album" goes to the record the PREVIEWED TRACK declares, which on a
|
||
// top-tracks session is often a single or a compilation rather than the
|
||
// artist's studio album — that is the record the metadata line above is
|
||
// already naming, and the id comes from the provider rather than a title
|
||
// match, so the two can never disagree.
|
||
//
|
||
// An album session is already standing on that record, so the action is
|
||
// offered only when it would actually take you somewhere.
|
||
const albumTarget = $derived(
|
||
activeItem?.actions.openAlbum && activeItem.albumId ? activeItem.albumId : null,
|
||
);
|
||
const atOwnAlbum = $derived(
|
||
albumTarget != null && manifest?.context.contextKey === `album:${albumTarget}`,
|
||
);
|
||
const canOpenAlbum = $derived(
|
||
Boolean(previewUi.openAlbum) && albumTarget != null && !atOwnAlbum,
|
||
);
|
||
const openAlbumTitle = $derived(
|
||
atOwnAlbum
|
||
? "You're already on this album"
|
||
: albumTarget == null
|
||
? "No album to open for this track"
|
||
: "Open album",
|
||
);
|
||
|
||
async function onOpenAlbum() {
|
||
if (!canOpenAlbum) return;
|
||
// Both are read BEFORE the close, which nulls the callback and drops
|
||
// the snapshot the active item came from.
|
||
const go = previewUi.openAlbum;
|
||
const item = activeItem;
|
||
// Close first: the session holds the audio focus, and navigating under
|
||
// an open dialog would land the new page behind the scrim.
|
||
await closePreview();
|
||
go(item);
|
||
}
|
||
</script>
|
||
|
||
<svelte:window onkeydown={onKeydown} />
|
||
|
||
{#if previewUi.open && st}
|
||
<!-- Scale & fade (user pick 2026-07-23, variant A of the motion mockup):
|
||
240 ms in from 96%, 190 ms out to 97%, scrim fading alongside. -->
|
||
<div
|
||
class="scrim"
|
||
onclick={() => closePreview()}
|
||
aria-hidden="true"
|
||
in:fade={{ duration: 240 }}
|
||
out:fade={{ duration: 190 }}
|
||
></div>
|
||
<div
|
||
class="modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={manifest ? `Track previews: ${manifest.context.title}` : "Track previews"}
|
||
style="--pv-accent: {pvAccent}; --pv-accent-hover: {pvHover};"
|
||
bind:this={dialogEl}
|
||
in:scale={{ duration: 240, start: 0.96, easing: cubicOut }}
|
||
out:scale={{ duration: 190, start: 0.97 }}
|
||
>
|
||
<div class="bg" aria-hidden="true">
|
||
<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>
|
||
|
||
<div class="m-top">
|
||
<div class="m-context">
|
||
<span>Previewing ·</span>
|
||
{manifest?.context.title}
|
||
{#if manifest?.context.subtitle}<span> · {manifest.context.subtitle}</span>{/if}
|
||
</div>
|
||
<div class="m-tools">
|
||
<button
|
||
class:on={st.muted}
|
||
title={st.muted ? "Unmute previews" : "Mute previews"}
|
||
aria-label={st.muted ? "Unmute previews" : "Mute previews"}
|
||
aria-pressed={st.muted}
|
||
onclick={() => previewCommands.setMuted(!st.muted)}
|
||
>
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" fill="currentColor" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 5.5a9 9 0 0 1 0 13" /></svg>
|
||
</button>
|
||
<button class="close" title="Close (Esc)" aria-label="Close previews" onclick={() => closePreview()}>
|
||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if st.phase === "empty"}
|
||
<div class="m-empty">No previews are available for this collection.</div>
|
||
{:else}
|
||
<div class="m-body">
|
||
<div class="m-left">
|
||
{#if previewUi.focusWaiting}
|
||
<div class="focus-wait" role="status">Waiting for the player to hand over audio…</div>
|
||
{/if}
|
||
<div class="title-slot"><h2 class="m-title" bind:this={titleEl}></h2></div>
|
||
<div class="m-artists" bind:this={artistEl}></div>
|
||
<div class="m-album" bind:this={albumEl}></div>
|
||
|
||
{#if failed}
|
||
<div class="m-error" role="alert">
|
||
This preview could not be loaded.
|
||
<button class="retry" onclick={() => previewCommands.replay()}>Retry</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="m-clip">
|
||
<span class="m-time">{posLabel}</span>
|
||
<span
|
||
class="m-wave"
|
||
bind:this={waveWrap}
|
||
onclick={onWaveClick}
|
||
role="slider"
|
||
aria-label="Preview position"
|
||
aria-valuemin="0"
|
||
aria-valuemax={Math.round((st.clipDurationMs ?? 30000) / 1000)}
|
||
aria-valuenow={Math.round((st.positionMs ?? 0) / 1000)}
|
||
tabindex="0"
|
||
>
|
||
<canvas bind:this={waveCanvas}></canvas>
|
||
</span>
|
||
<span class="m-time">{clipLabel}</span>
|
||
</div>
|
||
|
||
<div class="m-transport">
|
||
<button
|
||
class="m-step"
|
||
title="Previous preview (↑)"
|
||
aria-label="Previous preview"
|
||
disabled={prevDisabled}
|
||
onclick={() => step(-1)}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2.2v12H6zm3.8 6l8.2 6V6z" /></svg>
|
||
</button>
|
||
<button
|
||
class="m-play"
|
||
title={wantsPlay ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
||
aria-label={wantsPlay ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
||
onclick={() => previewCommands.togglePlay()}
|
||
>
|
||
{#if wantsPlay}
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z" /></svg>
|
||
{:else if ended}
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7" /><path d="M3 4v5h5" /></svg>
|
||
{:else}
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||
{/if}
|
||
</button>
|
||
<button
|
||
class="m-step"
|
||
title="Next preview (↓)"
|
||
aria-label="Next preview"
|
||
disabled={nextDisabled}
|
||
onclick={() => step(1)}
|
||
>
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.8 6H18v12h-2.2zM6 6l8.2 6L6 18z" /></svg>
|
||
</button>
|
||
<span class="t-sep" aria-hidden="true"></span>
|
||
<button class="full" title="Play full track" aria-label="Play full track" disabled={!previewUi.playFull || !activeItem?.actions.playFull} onclick={onPlayFull}>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z" /></svg>
|
||
Full track
|
||
</button>
|
||
<button class="act" title="Save to library (soon)" aria-label="Save to library" disabled>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14M5 12h14" /></svg>
|
||
</button>
|
||
<button class="act" title="Add to playlist (soon)" aria-label="Add to playlist" disabled>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M3 6h12M3 12h12M3 18h8M17 14v6M14 17h6" /></svg>
|
||
</button>
|
||
<button
|
||
class="act"
|
||
title={openAlbumTitle}
|
||
aria-label="Open album"
|
||
disabled={!canOpenAlbum}
|
||
onclick={onOpenAlbum}
|
||
>
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="8.5" /><circle cx="12" cy="12" r="2.3" /></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="reel-col">
|
||
<div class="reel" bind:this={reelEl} onwheel={onWheel}>
|
||
<div class="reel-track">
|
||
{#each visibleCards as { item, index } (item.sessionKey)}
|
||
{@const avail = availabilityOf(item)}
|
||
<div
|
||
class="card"
|
||
class:active={index === activeIndex}
|
||
class:gone={avail !== "available"}
|
||
class:hit={index === activeIndex && bumpKey > 0}
|
||
style="top: {cardOffset(index)}px; --idle-scale: {index === activeIndex ? 1 : 0.74};"
|
||
onclick={() => avail === "available" && activate(item, index)}
|
||
role={index === activeIndex ? undefined : "button"}
|
||
aria-hidden={index !== activeIndex}
|
||
tabindex="-1"
|
||
>
|
||
{#if item.artwork}
|
||
<img class="cover" src={item.artwork} alt="" draggable="false" />
|
||
{:else}
|
||
<div class="cover cover-fallback"></div>
|
||
{/if}
|
||
{#if index === activeIndex && playing}
|
||
<div class="eq" aria-hidden="true"><i></i><i></i><i></i></div>
|
||
{/if}
|
||
{#if index !== activeIndex}
|
||
<div class="cap">{item.title}</div>
|
||
{/if}
|
||
{#if avail !== "available"}
|
||
<div class="flag">
|
||
{avail === "unreleased" ? "Not released yet" : "No preview from any source"}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
<div class="reel-arrows">
|
||
<button
|
||
class="reel-arrow"
|
||
title="Previous preview (↑)"
|
||
aria-label="Previous preview"
|
||
disabled={prevDisabled}
|
||
onclick={() => step(-1)}
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 14.5l6-6 6 6" /></svg>
|
||
</button>
|
||
<button
|
||
class="reel-arrow"
|
||
title="Next preview (↓)"
|
||
aria-label="Next preview"
|
||
disabled={nextDisabled}
|
||
onclick={() => step(1)}
|
||
>
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9.5l6 6 6-6" /></svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="m-foot">
|
||
<span class="src-chip"><span class="src-dot"></span>Deezer preview</span>
|
||
<span class="rail" aria-hidden="true">
|
||
{#each items as item, i (item.sessionKey)}
|
||
<i
|
||
class:done={playable.includes(i) && activeIndex != null && i < activeIndex}
|
||
class:now={i === activeIndex}
|
||
class:gone={!playable.includes(i)}
|
||
></i>
|
||
{/each}
|
||
</span>
|
||
<span class="m-pos">
|
||
{#if cursor != null}
|
||
<!-- Reserve the widest digit count so stepping 9→10
|
||
never widens the label and reflows the flex rail. -->
|
||
<span class="pos-n" style="min-width: {String(playable.length).length}ch">{cursor + 1}</span> of {playable.length}
|
||
{:else}
|
||
unavailable · {(activeIndex ?? 0) + 1} of {items.length}
|
||
{/if}
|
||
{#if unavailableCount > 0} · {unavailableCount} unavailable{/if}
|
||
</span>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="sr-announce" aria-live="polite">{announce}</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.scrim {
|
||
position: fixed;
|
||
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.
|
||
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);
|
||
}
|
||
|
||
.modal {
|
||
/* A centred, window-relative panel — not an inset-26 near-fullscreen
|
||
sheet. Capped so it never dominates a large monitor while the two
|
||
columns still keep their room on smaller windows. */
|
||
position: fixed;
|
||
/* Safe frame: the modal centers (margin auto) inside a fixed inset
|
||
box whose 48px top ALWAYS clears the 30px app titlebar — the modal
|
||
can never cover it, whatever the window size — with matching
|
||
gutters on the sides and bottom. The width/height caps keep it a
|
||
real modal on a 4K monitor (1660×1000) while the calc() terms
|
||
guarantee it never outgrows the frame on small windows. */
|
||
inset: 48px;
|
||
margin: auto;
|
||
width: min(1660px, 90vw, calc(100vw - 96px));
|
||
height: min(1000px, calc(100vh - 96px));
|
||
/* vw-based headline: small windows sit at the 38px floor and only
|
||
wide screens grow it. */
|
||
--pv-title: clamp(38px, 2.4vw, 52px);
|
||
z-index: 61;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
background: var(--bg-elevated);
|
||
box-shadow: 0 40px 120px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||
display: grid;
|
||
grid-template-rows: auto 1fr auto;
|
||
font-family: var(--app-font);
|
||
}
|
||
|
||
/* Twin glow (revised): wide dim bleed of the active cover, crossfading
|
||
across two slots, under a single 90° scrim. Panel-scoped. */
|
||
.bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 0;
|
||
overflow: hidden;
|
||
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%;
|
||
opacity: 0;
|
||
transition: opacity 0.42s ease;
|
||
transform: scale(1.25);
|
||
}
|
||
.bleed.show {
|
||
opacity: 0.88;
|
||
}
|
||
.scrim-x {
|
||
position: absolute;
|
||
inset: 0;
|
||
background:
|
||
linear-gradient(90deg, rgba(8, 8, 10, 0.9) 30%, rgba(8, 8, 10, 0.42) 100%),
|
||
linear-gradient(180deg, transparent 74%, rgba(8, 8, 10, 0.55));
|
||
}
|
||
.modal > :not(.bg) {
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* ------------------------------------------------------------ chrome */
|
||
.m-top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px 0;
|
||
}
|
||
.m-context {
|
||
font-size: 13.5px;
|
||
font-weight: 800;
|
||
}
|
||
.m-context span {
|
||
color: var(--text-secondary);
|
||
font-weight: 600;
|
||
}
|
||
.m-tools {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.m-tools button {
|
||
padding: 7px;
|
||
border-radius: 6px;
|
||
background: none;
|
||
border: 0;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.m-tools button:hover {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
.m-tools button.on {
|
||
color: var(--pv-accent);
|
||
}
|
||
|
||
.m-empty {
|
||
display: grid;
|
||
place-items: center;
|
||
font-size: 14px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.focus-wait {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.m-error {
|
||
margin-top: 10px;
|
||
font-size: 12.5px;
|
||
color: var(--text-secondary);
|
||
}
|
||
.m-error .retry {
|
||
margin-left: 8px;
|
||
border: 1px solid #6a6a6a;
|
||
border-radius: 999px;
|
||
padding: 3px 12px;
|
||
font-size: 12px;
|
||
color: #fff;
|
||
background: none;
|
||
cursor: pointer;
|
||
}
|
||
|
||
/* -------------------------------------------------------------- body */
|
||
.m-body {
|
||
display: grid;
|
||
grid-template-columns: minmax(320px, 680px) auto;
|
||
justify-content: center;
|
||
gap: 36px;
|
||
padding: 0 20px;
|
||
min-height: 0;
|
||
}
|
||
.m-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
min-width: 0;
|
||
}
|
||
|
||
/* Fixed two-line title slot, BOTTOM-aligned: a one-line title sits against
|
||
the artist row instead of floating a line above it. */
|
||
.title-slot {
|
||
/* Two lines of the (now responsive) title at 1.08 line-height. */
|
||
height: calc(var(--pv-title, 38px) * 1.08 * 2);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
margin-bottom: 8px;
|
||
}
|
||
.m-title {
|
||
font-size: var(--pv-title, 38px);
|
||
font-weight: 800;
|
||
line-height: 1.08;
|
||
letter-spacing: -0.8px;
|
||
margin: 0;
|
||
text-wrap: balance;
|
||
max-height: calc(2 * 1.08em);
|
||
overflow: hidden;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
}
|
||
.m-artists {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
height: 1.4em;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.m-album {
|
||
font-size: 13px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
height: 1.4em;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
/* T3 directional swaps */
|
||
@keyframes t-out-up { to { opacity: 0; transform: translateY(-9px); } }
|
||
@keyframes t-in-up { from { opacity: 0; transform: translateY(11px); } to { opacity: 1; transform: none; } }
|
||
@keyframes t-out-down { to { opacity: 0; transform: translateY(9px); } }
|
||
@keyframes t-in-down { from { opacity: 0; transform: translateY(-11px); } to { opacity: 1; transform: none; } }
|
||
.m-left :global(.swap-out-up) { animation: t-out-up 0.16s ease forwards; }
|
||
.m-left :global(.swap-out-down) { animation: t-out-down 0.16s ease forwards; }
|
||
.m-left :global(.swap-in-up) { animation: t-in-up 0.26s cubic-bezier(0.22, 0.9, 0.28, 1) both; }
|
||
.m-left :global(.swap-in-down) { animation: t-in-down 0.26s cubic-bezier(0.22, 0.9, 0.28, 1) both; }
|
||
|
||
/* ---------------------------------------------------------- scrubber */
|
||
.m-clip {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 11px;
|
||
margin-top: 28px;
|
||
/* Spans the same width as the control row beneath it. */
|
||
max-width: 560px;
|
||
}
|
||
.m-wave {
|
||
flex: 1;
|
||
height: 44px;
|
||
cursor: pointer;
|
||
position: relative;
|
||
}
|
||
.m-wave canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
.m-time {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
/* --------------------------------------------------------- transport */
|
||
.m-transport {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}
|
||
.m-transport button {
|
||
background: none;
|
||
border: 0;
|
||
color: inherit;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
/* Scoped under .m-transport so it outranks the `.m-transport button`
|
||
reset (0,1,1) that was blanking the accent fill and the black glyph —
|
||
the mockup only had a plain `button{}` reset, which lost to `.m-play`. */
|
||
.m-transport .m-play {
|
||
width: 46px;
|
||
height: 46px;
|
||
border-radius: 50%;
|
||
background: var(--pv-accent);
|
||
color: #000;
|
||
display: grid;
|
||
place-items: center;
|
||
transition: background 0.35s ease;
|
||
flex: none;
|
||
}
|
||
.m-transport .m-play:hover {
|
||
transform: scale(1.05);
|
||
}
|
||
.m-step {
|
||
color: var(--text-secondary);
|
||
padding: 8px;
|
||
border-radius: 6px;
|
||
}
|
||
.m-step:hover:not(:disabled) {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
.m-step:disabled {
|
||
opacity: 0.3;
|
||
cursor: default;
|
||
}
|
||
.t-sep {
|
||
width: 1px;
|
||
height: 22px;
|
||
background: rgba(255, 255, 255, 0.14);
|
||
margin: 0 4px;
|
||
flex: none;
|
||
}
|
||
/* P2 ghost pill: the one labelled control — the action that LEAVES
|
||
preview mode. The row keeps exactly one solid accent control. */
|
||
.full {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
height: 36px;
|
||
padding: 0 15px;
|
||
flex: none;
|
||
border: 1px solid color-mix(in srgb, var(--pv-accent) 65%, transparent) !important;
|
||
border-radius: 18px;
|
||
color: #fff;
|
||
font-size: 12.5px;
|
||
font-weight: 800;
|
||
white-space: nowrap;
|
||
transition: border-color 0.35s ease, background 0.2s ease;
|
||
}
|
||
.full svg {
|
||
color: var(--pv-accent);
|
||
transition: color 0.35s ease;
|
||
}
|
||
.full:hover:not(:disabled) {
|
||
border-color: var(--pv-accent) !important;
|
||
background: color-mix(in srgb, var(--pv-accent) 14%, transparent);
|
||
}
|
||
.full:disabled {
|
||
opacity: 0.45;
|
||
cursor: default;
|
||
}
|
||
.act {
|
||
color: var(--text-secondary);
|
||
padding: 8px;
|
||
border-radius: 8px;
|
||
}
|
||
.act:hover:not(:disabled) {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
}
|
||
.act:disabled {
|
||
opacity: 0.4;
|
||
cursor: default;
|
||
}
|
||
|
||
/* --------------------------------------------------------------- reel */
|
||
.reel-col {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 0;
|
||
height: 100%;
|
||
}
|
||
.reel {
|
||
/* Single source of geometry: everything below is calc() off these. */
|
||
/* max(34vh, 22vw) so wide screens (4K) grow the cover via the vw term
|
||
while smaller windows stay on 34vh exactly as before — 22vw only
|
||
overtakes 34vh once the viewport is very wide, so the 4:3 window is
|
||
untouched and the 4K cover jumps from 340 up to the 540 cap. */
|
||
--cover: clamp(190px, max(34vh, 22vw), 540px);
|
||
--gap: 30px;
|
||
--stride: calc(var(--cover) + var(--gap));
|
||
--bleed-room: 70px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
width: calc(var(--cover) + var(--bleed-room) * 2);
|
||
height: 100%;
|
||
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||
mask-image: linear-gradient(180deg, transparent 0%, #000 18%, #000 82%, transparent 100%);
|
||
}
|
||
/* Manual step chevrons beside the art (user request 2026-07-23): bare
|
||
glyphs like the reference, no chrome until hover. They sit in the
|
||
reel's 70px bleed gutter — negative margin tucks them against the
|
||
cover's edge without widening the column. */
|
||
.reel-arrows {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 26px;
|
||
margin-left: -30px;
|
||
position: relative;
|
||
z-index: 2;
|
||
}
|
||
.reel-arrow {
|
||
width: 40px;
|
||
height: 40px;
|
||
border-radius: 50%;
|
||
display: grid;
|
||
place-items: center;
|
||
background: none;
|
||
border: 0;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||
}
|
||
.reel-arrow:hover:not(:disabled) {
|
||
color: #fff;
|
||
background: rgba(255, 255, 255, 0.08);
|
||
transform: scale(1.08);
|
||
}
|
||
.reel-arrow:active:not(:disabled) {
|
||
transform: scale(0.96);
|
||
}
|
||
.reel-arrow:disabled {
|
||
opacity: 0.25;
|
||
cursor: default;
|
||
}
|
||
|
||
.reel-track {
|
||
/* A fixed anchor at the reel centre — it never moves. Cards position
|
||
themselves relative to it at (index − activeIndex) × stride and
|
||
animate their own `top`; a transitioned translateY on this track is
|
||
exactly what Chromium collapsed to 0 (see the file-top comment). */
|
||
position: absolute;
|
||
z-index: 1;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: var(--cover);
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
/* 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. 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;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
opacity: 0.38;
|
||
filter: brightness(0.58);
|
||
/* Centred on the card's own `top` line, then scaled. Scale is visual
|
||
only — the layout stride the virtualizer uses is the untransformed
|
||
box, so idle covers at 74% cannot perturb centring. */
|
||
transform: translateY(-50%) scale(var(--idle-scale, 0.74));
|
||
transition:
|
||
top 340ms cubic-bezier(0.26, 1.28, 0.6, 1),
|
||
transform 340ms cubic-bezier(0.26, 1.28, 0.6, 1),
|
||
opacity 0.3s ease,
|
||
filter 0.3s ease,
|
||
box-shadow 0.3s ease;
|
||
}
|
||
.card:not(.active):hover {
|
||
opacity: 0.62;
|
||
filter: brightness(0.78);
|
||
}
|
||
/* Edge treatment J: ring + lift; the bloom is the artwork copy behind. */
|
||
.card.active {
|
||
opacity: 1;
|
||
filter: none;
|
||
cursor: default;
|
||
box-shadow:
|
||
0 0 0 1px rgba(255, 255, 255, 0.14),
|
||
0 24px 56px -30px rgba(0, 0, 0, 0.75);
|
||
}
|
||
/* 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 — 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); }
|
||
100% { transform: translateY(-50%) scale(1); }
|
||
}
|
||
.card.active.hit {
|
||
animation: cover-bump 360ms cubic-bezier(0.26, 1.28, 0.6, 1);
|
||
}
|
||
.card .cover {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.cover-fallback {
|
||
background: var(--bg-highlight);
|
||
}
|
||
.card .cap {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 10px 12px;
|
||
font-size: 12.5px;
|
||
font-weight: 700;
|
||
background: linear-gradient(0deg, rgba(0, 0, 0, 0.8), transparent);
|
||
opacity: 0;
|
||
transition: opacity 0.18s ease;
|
||
}
|
||
.card:not(.active):hover .cap {
|
||
opacity: 1;
|
||
}
|
||
.card.gone .cover {
|
||
filter: grayscale(1) brightness(0.42);
|
||
}
|
||
.card .flag {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 20px;
|
||
text-align: center;
|
||
background: rgba(10, 10, 10, 0.55);
|
||
font-size: 12.5px;
|
||
font-weight: 700;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
/* R1b accent-glass capsule: the one literal "playing" mark on the art. */
|
||
.eq {
|
||
position: absolute;
|
||
z-index: 2;
|
||
top: 8px;
|
||
left: 8px;
|
||
height: 22px;
|
||
padding: 0 8px;
|
||
border-radius: 11px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2.5px;
|
||
background: color-mix(in srgb, var(--pv-accent) 42%, rgba(16, 16, 20, 0.3));
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
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: 12px;
|
||
background: #fff;
|
||
border-radius: 1.5px;
|
||
transform: scaleY(0.333);
|
||
animation: eq 0.9s steps(5, jump-none) infinite;
|
||
}
|
||
/* 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% { transform: scaleY(0.333); }
|
||
50% { transform: scaleY(1); }
|
||
}
|
||
|
||
/* --------------------------------------------------------------- foot */
|
||
.m-foot {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 18px;
|
||
padding: 14px 20px 16px 40px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
.src-chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
height: 24px;
|
||
padding: 0 11px;
|
||
flex: none;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
border-radius: 12px;
|
||
background: rgba(255, 255, 255, 0.05);
|
||
color: rgba(255, 255, 255, 0.78);
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
white-space: nowrap;
|
||
}
|
||
.src-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--pv-accent);
|
||
transition: background 0.35s ease;
|
||
}
|
||
/* F3 collection rail: one segment per manifest entry. */
|
||
.rail {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
/* Fixed box height: the active segment animates 3↔5px, so without a
|
||
pinned height the rail's box shrank mid-step and the centred rail
|
||
bounced ~0.5px vertically on every track change. Now the segments
|
||
grow inside a constant-height box and nothing reflows. */
|
||
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: 5px;
|
||
border-radius: 2px;
|
||
background: rgba(255, 255, 255, 0.16);
|
||
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); 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;
|
||
flex: none;
|
||
}
|
||
.pos-n {
|
||
display: inline-block;
|
||
text-align: right;
|
||
}
|
||
|
||
.sr-announce {
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
overflow: hidden;
|
||
clip-path: inset(50%);
|
||
}
|
||
|
||
/* ---------------------------------------------------------- narrow ---- */
|
||
@media (max-width: 1000px) {
|
||
.m-body {
|
||
grid-template-columns: minmax(0, 1fr);
|
||
grid-template-rows: minmax(0, 1fr) auto;
|
||
justify-items: center;
|
||
align-content: center;
|
||
gap: 16px;
|
||
}
|
||
.reel-col { grid-row: 1; min-height: 0; }
|
||
.m-left { grid-row: 2; align-items: center; text-align: center; }
|
||
.m-clip { width: 100%; max-width: 320px; }
|
||
.m-transport { justify-content: center; flex-wrap: wrap; }
|
||
.m-title { font-size: 27px; }
|
||
.title-slot { height: 59px; }
|
||
.m-foot {
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
gap: 4px 14px;
|
||
text-align: center;
|
||
padding: 10px 16px 12px;
|
||
}
|
||
}
|
||
|
||
/* --------------------------------------------------- reduced motion --- */
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.card { transition: opacity 0.3s ease, filter 0.3s ease; }
|
||
.card.active.hit { animation: none; }
|
||
.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),
|
||
.m-left :global(.swap-in-down) {
|
||
animation: none;
|
||
}
|
||
}
|
||
</style>
|