Files
SoulStream/src/components/PreviewOverlay.svelte
T
KeelrLeek 793f78370e feat: crossfade preview suite, loudness normalization, debug overlay
Preview modal audio, five rounds:
- True track-change crossfade: scheduleSettle no longer silences the
  outgoing clip at t=0; preview_load's equal-power fade owns both ramps,
  gated on the incoming voice's first Playing event (audible_marks), with
  failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's
  card-slide so audio and visuals land together.
- Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache
  (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits
  never re-download.
- Adaptive settle (isolated steps fire immediately, bursts coalesce) and
  anticipatory auto-advance (step at clip end minus 400 ms) so auto-play
  crossfades over the tail instead of gapping.
- Session resume memory: revisited tracks continue where they left off
  (decoder-level start offset via play_local_from/resume_at); finished
  tracks restart from the top.
- Open/close crossfades with the MAIN player: fade-pause with the
  desired-paused latch landing at ramp end, fade-resume from the paused
  position; MAIN_FADE_GEN guards; focus handshake relaxation documented
  in focusBridge.

Volume normalization (Settings > Playback, default on):
- Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting,
  400 ms gated blocks, incremental) + analyzer that reads the same growing
  file playback streams from — first plays are measured at download speed.
- Gain layered under user volume; audible start GATED on a preliminary
  measurement (10 s of audio or 800 ms budget) so the level is right from
  the first sample and never changes mid-track; prelim boosts capped 3 dB,
  peak-aware clipping guard, persistent loudness-cache.json.
- Gapless boundaries get their own sample-accurate gain via
  SetNextNormGainDb, fed by preload analysis.

Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set +
private-commit RAM / network rates / FPS / current-track download progress
read from the backend's own StreamProgress / live normalization state /
preview session phase. Polls only while visible.

131 JS + player + app tests green; every path verified live over CDP.
2026-07-23 16:05:14 -05:00

1328 lines
50 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 {
playableManifestIndices,
playableCursor,
PHASE,
} from "../lib/preview/session.js";
import { accentFromImage } from "../lib/color.js";
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, 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("");
let bleedSlot = $state(0);
$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;
});
});
// ------------------------------------------------------------- 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 -----
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);
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;
drawWave();
});
function drawWave() {
if (!waveCanvas || !waveWrap || !activeItem) return;
const dpr = window.devicePixelRatio || 1;
const W = waveWrap.clientWidth;
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();
}
</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"
class:is-playing={playing}
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">
<img class="bleed" class:show={bleedSlot === 1 && bleedA} src={bleedA || undefined} alt="" />
<img class="bleed" class:show={bleedSlot === 0 && bleedB} src={bleedB || undefined} alt="" />
<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="Open album (soon)" aria-label="Open album" disabled>
<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}>
{#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)}
<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}&nbsp;· {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. */
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 {
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 {
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): 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;
}
.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. */
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. */
@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 {
width: 2.5px;
height: 5px;
background: #fff;
border-radius: 1.5px;
animation: eq 0.9s ease-in-out infinite;
}
.eq i:nth-child(2) { animation-delay: 0.15s; }
.eq i:nth-child(3) { animation-delay: 0.3s; }
@keyframes eq {
0%, 100% { height: 4px; }
50% { height: 12px; }
}
/* --------------------------------------------------------------- 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 {
flex: 1;
height: 3px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.16);
transition: background 0.25s ease, height 0.25s ease;
}
.rail i.done { background: rgba(255, 255, 255, 0.34); }
.rail i.now { background: var(--pv-accent); height: 5px; }
.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; }
.modal.is-playing .cover-glow { animation: none; }
.eq i { animation: none; height: 8px; }
.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>