forked from Keelrleek/SoulStream
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.
1328 lines
50 KiB
Svelte
1328 lines
50 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 {
|
||
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} · {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>
|