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.
This commit is contained in:
1 parent
5e877c0b0a
commit
793f78370e
27 files changed
+2834
-178
No files matched your search
+14
-10
@@ -6,6 +6,7 @@
|
||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||
import LoginModal from "./components/LoginModal.svelte";
|
||||
import PreviewOverlay from "./components/PreviewOverlay.svelte";
|
||||
import DebugOverlay from "./components/DebugOverlay.svelte";
|
||||
import { previewUi } from "./lib/preview/previewSession.svelte.js";
|
||||
import Home from "./views/Home.svelte";
|
||||
import Search from "./views/Search.svelte";
|
||||
@@ -52,21 +53,23 @@
|
||||
class:preview-open={previewUi.open}
|
||||
>
|
||||
<TitleBar />
|
||||
<!-- Previews take over the whole window (decision 2026-07-22): while the
|
||||
modal is open the page and the player bar are inert/not rendered —
|
||||
there is deliberately NO UI path to command main audio behind the
|
||||
focus coordinator's back. The titlebar stays live for window chrome. -->
|
||||
<!-- While the preview modal is open the page is inert and the flanking
|
||||
panels + player bar are pointer-inert under the scrim (user decision
|
||||
2026-07-23: the bar stays VISIBLE, dimmed, like Spotify — un-rendering
|
||||
it left a black hole in the grid's bottom row). There is still no UI
|
||||
path to command main audio behind the focus coordinator's back: the
|
||||
scrim intercepts pointer input and the dialog traps the keyboard.
|
||||
The titlebar stays live for window chrome. -->
|
||||
<Sidebar />
|
||||
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
||||
<CurrentView />
|
||||
</main>
|
||||
<NowPlaying />
|
||||
{#if !previewUi.open}
|
||||
<PlayerBar />
|
||||
{/if}
|
||||
<PlayerBar />
|
||||
<PreviewOverlay />
|
||||
<ArtworkViewer />
|
||||
<LoginModal />
|
||||
<DebugOverlay />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -74,10 +77,11 @@
|
||||
no separate chrome color, the panels float beneath it. */
|
||||
/* While the preview modal is open: keyboard is contained by the dialog's
|
||||
focus trap and aria-modal, the content pane is `inert`, and the flanking
|
||||
panels stop taking pointer input here. (The player bar is simply not
|
||||
rendered — see the markup note above.) */
|
||||
panels + player bar stop taking pointer input here — the bar stays
|
||||
visible (dimmed under the scrim) but cannot command main audio. */
|
||||
.app.preview-open :global(.sidebar),
|
||||
.app.preview-open :global(.nowplaying) {
|
||||
.app.preview-open :global(.nowplaying),
|
||||
.app.preview-open :global(.player) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<script>
|
||||
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
|
||||
// system + process-tree CPU/GPU/RAM, network rates, the active track's
|
||||
// download progress/speed and loudness-normalization state, renderer
|
||||
// FPS, and the preview session when one is open. Polls the backend only
|
||||
// while visible; costs nothing when hidden.
|
||||
import * as api from "../lib/api.js";
|
||||
import { player } from "../lib/state.svelte.js";
|
||||
import { previewUi } from "../lib/preview/previewSession.svelte.js";
|
||||
|
||||
let visible = $state(false);
|
||||
let stats = $state(null);
|
||||
let fps = $state(0);
|
||||
let dlSpeed = $state(0); // bytes/sec of the CURRENT track's stream
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === "D" || e.key === "d")) {
|
||||
e.preventDefault();
|
||||
visible = !visible;
|
||||
}
|
||||
}
|
||||
|
||||
// Poll + FPS only while visible.
|
||||
$effect(() => {
|
||||
if (!visible) return;
|
||||
let lastReceived = 0;
|
||||
let lastAt = performance.now();
|
||||
const tick = async () => {
|
||||
try {
|
||||
stats = await api.debugStats();
|
||||
} catch {
|
||||
stats = null;
|
||||
}
|
||||
// Download speed from the BACKEND's own progress handle — the
|
||||
// player store's mirror is window-activity-gated and can lag.
|
||||
const now = performance.now();
|
||||
const cur = stats?.dlReceived ?? 0;
|
||||
const dt = (now - lastAt) / 1000;
|
||||
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
|
||||
lastReceived = cur;
|
||||
lastAt = now;
|
||||
};
|
||||
void tick();
|
||||
const id = setInterval(tick, 1000);
|
||||
|
||||
let frames = 0;
|
||||
let fpsWindow = performance.now();
|
||||
let raf = requestAnimationFrame(function loop() {
|
||||
frames += 1;
|
||||
const now = performance.now();
|
||||
if (now - fpsWindow >= 500) {
|
||||
fps = Math.round((frames * 1000) / (now - fpsWindow));
|
||||
frames = 0;
|
||||
fpsWindow = now;
|
||||
}
|
||||
raf = requestAnimationFrame(loop);
|
||||
});
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
});
|
||||
|
||||
const mb = (bytes) => (bytes / 1048576).toFixed(bytes >= 10485760 ? 0 : 1);
|
||||
const gb = (bytes) => (bytes / 1073741824).toFixed(1);
|
||||
function rate(bps) {
|
||||
if (bps >= 1048576) return `${(bps / 1048576).toFixed(1)} MB/s`;
|
||||
return `${(bps / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
function fmtUptime(secs) {
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
return h > 0 ? `${h}h${String(m).padStart(2, "0")}m` : `${m}m${String(secs % 60).padStart(2, "0")}s`;
|
||||
}
|
||||
const dlTotal = $derived(stats?.dlTotal ?? 0);
|
||||
const dlDone = $derived(stats?.dlReceived ?? 0);
|
||||
const loud = $derived(stats?.loudness ?? null);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if visible}
|
||||
<aside class="debug-overlay" aria-hidden="true">
|
||||
<div class="hdr">DEBUG <span class="dim">Ctrl+Shift+D</span></div>
|
||||
{#if stats}
|
||||
<div class="sec">
|
||||
<div><b>CPU</b> app {stats.cpuProcess.toFixed(1)}% · sys {stats.cpuSystem.toFixed(1)}%</div>
|
||||
<div><b>GPU</b> {stats.gpuPercent != null ? `${stats.gpuPercent.toFixed(1)}% (3D)` : "n/a"}</div>
|
||||
<div><b>RAM</b> ws {mb(stats.memWorkingSet)} MB · reserved {mb(stats.memPrivate)} MB</div>
|
||||
<div class="dim">system {gb(stats.memSystemUsed)} / {gb(stats.memSystemTotal)} GB · {stats.processCount} procs</div>
|
||||
<div><b>NET</b> ↓ {rate(stats.netRxBps)} · ↑ {rate(stats.netTxBps)}</div>
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div><b>FPS</b> {fps}</div>
|
||||
<div><b>PLAYER</b> {player.status}{player.track ? ` · ${player.track.title}` : ""}</div>
|
||||
{#if dlTotal > 0}
|
||||
<div>
|
||||
<b>DL</b>
|
||||
{mb(dlDone)} / {mb(dlTotal)} MB ({((dlDone / dlTotal) * 100).toFixed(0)}%)
|
||||
{#if dlDone < dlTotal}· {rate(dlSpeed)}{:else}· done{/if}
|
||||
</div>
|
||||
<div class="bar"><i style="width:{Math.min(100, (dlDone / dlTotal) * 100)}%"></i></div>
|
||||
{:else}
|
||||
<div class="dim">DL — no active stream</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sec">
|
||||
<div>
|
||||
<b>NORM</b>
|
||||
{#if !stats.normalizationEnabled}off{:else if loud}
|
||||
{loud.lufs != null ? `${loud.lufs.toFixed(1)} LUFS` : "measuring…"}
|
||||
→ {loud.gainDb >= 0 ? "+" : ""}{loud.gainDb.toFixed(1)} dB
|
||||
<span class="dim">(target {stats.targetLufs})</span>
|
||||
{:else}on · idle{/if}
|
||||
</div>
|
||||
{#if stats.normalizationEnabled && loud}
|
||||
<div class="dim">
|
||||
peak {loud.peak.toFixed(2)}
|
||||
· {loud.cached ? "cache hit" : loud.analyzing ? "analyzing" : "measured this play"}
|
||||
</div>
|
||||
{/if}
|
||||
{#if previewUi.open}
|
||||
<div>
|
||||
<b>PREVIEW</b>
|
||||
{previewUi.snapshot?.phase ?? "—"} · gen {previewUi.snapshot?.requestGeneration ?? "—"} ·
|
||||
idx {previewUi.snapshot?.activeManifestIndex ?? "—"}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="dim ft">up {fmtUptime(stats.uptimeSecs)}</div>
|
||||
{:else}
|
||||
<div class="dim">waiting for backend…</div>
|
||||
{/if}
|
||||
</aside>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.debug-overlay {
|
||||
position: fixed;
|
||||
top: 40px;
|
||||
right: 12px;
|
||||
z-index: 2147483000;
|
||||
pointer-events: none;
|
||||
min-width: 300px;
|
||||
max-width: 380px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(8, 10, 14, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(4px);
|
||||
font-family: Consolas, "Cascadia Mono", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
color: #d7e0ea;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.hdr {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: #7fd0ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.sec {
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
b {
|
||||
color: #9fc1e0;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
min-width: 46px;
|
||||
}
|
||||
.dim {
|
||||
color: rgba(215, 224, 234, 0.55);
|
||||
}
|
||||
.ft {
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-top: 4px;
|
||||
}
|
||||
.bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
margin: 2px 0 1px;
|
||||
}
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: #7fd0ff;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,8 @@
|
||||
// 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,
|
||||
@@ -31,6 +33,16 @@
|
||||
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);
|
||||
@@ -399,6 +411,16 @@
|
||||
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;
|
||||
@@ -410,7 +432,15 @@
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
{#if previewUi.open && st}
|
||||
<div class="scrim" onclick={() => closePreview()} aria-hidden="true"></div>
|
||||
<!-- 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}
|
||||
@@ -419,6 +449,8 @@
|
||||
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="" />
|
||||
@@ -490,18 +522,18 @@
|
||||
class="m-step"
|
||||
title="Previous preview (↑)"
|
||||
aria-label="Previous preview"
|
||||
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i < activeIndex) : cursor <= 0}
|
||||
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={playing ? "Pause preview (Space)" : ended ? "Replay preview (Space)" : "Play preview (Space)"}
|
||||
aria-label={playing ? "Pause preview" : ended ? "Replay preview" : "Play preview"}
|
||||
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 playing}
|
||||
{#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>
|
||||
@@ -513,7 +545,7 @@
|
||||
class="m-step"
|
||||
title="Next preview (↓)"
|
||||
aria-label="Next preview"
|
||||
disabled={cursor == null ? activeIndex != null && !playable.some((i) => i > activeIndex) : cursor >= playable.length - 1}
|
||||
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>
|
||||
@@ -574,6 +606,26 @@
|
||||
{/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>
|
||||
|
||||
@@ -589,7 +641,13 @@
|
||||
{/each}
|
||||
</span>
|
||||
<span class="m-pos">
|
||||
{cursor != null ? `${cursor + 1} of ${playable.length}` : `unavailable · ${(activeIndex ?? 0) + 1} of ${items.length}`}
|
||||
{#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>
|
||||
@@ -604,14 +662,31 @@
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
/* 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;
|
||||
inset: 26px;
|
||||
/* 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;
|
||||
@@ -727,7 +802,7 @@
|
||||
/* -------------------------------------------------------------- body */
|
||||
.m-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(300px, 500px) auto;
|
||||
grid-template-columns: minmax(320px, 680px) auto;
|
||||
justify-content: center;
|
||||
gap: 36px;
|
||||
padding: 0 20px;
|
||||
@@ -743,13 +818,14 @@
|
||||
/* 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 {
|
||||
height: 82px;
|
||||
/* 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: 38px;
|
||||
font-size: var(--pv-title, 38px);
|
||||
font-weight: 800;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.8px;
|
||||
@@ -797,7 +873,7 @@
|
||||
gap: 11px;
|
||||
margin-top: 28px;
|
||||
/* Spans the same width as the control row beneath it. */
|
||||
max-width: 472px;
|
||||
max-width: 560px;
|
||||
}
|
||||
.m-wave {
|
||||
flex: 1;
|
||||
@@ -830,7 +906,10 @@
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.m-play {
|
||||
/* 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%;
|
||||
@@ -841,7 +920,7 @@
|
||||
transition: background 0.35s ease;
|
||||
flex: none;
|
||||
}
|
||||
.m-play:hover {
|
||||
.m-transport .m-play:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
.m-step {
|
||||
@@ -917,7 +996,11 @@
|
||||
}
|
||||
.reel {
|
||||
/* Single source of geometry: everything below is calc() off these. */
|
||||
--cover: clamp(190px, 34vh, 340px);
|
||||
/* 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;
|
||||
@@ -928,6 +1011,43 @@
|
||||
-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
|
||||
@@ -1134,6 +1254,11 @@
|
||||
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;
|
||||
@@ -1149,6 +1274,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
flex: none;
|
||||
}
|
||||
.pos-n {
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sr-announce {
|
||||
position: absolute;
|
||||
|
||||
+7
-2
@@ -136,8 +136,13 @@ export const streamWaveform = (username, path, size, buckets) =>
|
||||
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
||||
// never crosses this boundary, only an opaque lease id does.
|
||||
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
||||
export const previewLoad = (leaseId, startPaused) =>
|
||||
invoke("preview_load", { leaseId, startPaused });
|
||||
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
||||
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
|
||||
export const playerFadePauseAcked = (fadeMs, volume) =>
|
||||
invoke("player_fade_pause_acked", { fadeMs, volume });
|
||||
export const playerFadeResume = (fadeMs, volume) =>
|
||||
invoke("player_fade_resume", { fadeMs, volume });
|
||||
export const debugStats = () => invoke("debug_stats");
|
||||
export const previewControl = (action, value = null) =>
|
||||
invoke("preview_control", { action, value });
|
||||
export const previewDispose = () => invoke("preview_dispose");
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
// Production wiring for the Phase 1 focus coordinator: the real main player
|
||||
// on one side, the preview session on the other.
|
||||
//
|
||||
// One honest deviation from the pure model, documented here because it is a
|
||||
// real design decision: the renderer cannot know the NATIVE playback
|
||||
// generation ahead of time (it only travels in the PauseAck itself). The
|
||||
// production binding therefore replaces "ack.generation === snapshot's
|
||||
// native generation" with an equivalent renderer-side invariant:
|
||||
// Two honest deviations from the pure model, documented here because they
|
||||
// are real design decisions:
|
||||
//
|
||||
// granted ⇔ ack received (never assumed on timeout)
|
||||
// 1. The renderer cannot know the NATIVE playback generation ahead of time
|
||||
// (it only travels in the PauseAck itself), so the production binding
|
||||
// replaces "ack.generation === snapshot's native generation" with an
|
||||
// equivalent renderer-side invariant:
|
||||
//
|
||||
// granted ⇔ the main fade-to-silence is RUNNING (see requestSilence —
|
||||
// since the 2026-07-23 open-crossfade, silence is a ramp the
|
||||
// preview deliberately overlaps, not an acknowledged fact)
|
||||
// ∧ no renderer PLAYBACK generation change during negotiation
|
||||
// (a new Buffering = a new track start = a new generation)
|
||||
// ∧ no revision-gateway change during negotiation
|
||||
//
|
||||
// 2. The open/close crossfades trade the hard "silence before preview
|
||||
// audio" gate for a bounded overlap window (CROSSFADE_MS); the backend
|
||||
// fade thread still ends in pause + desired-paused latch, and its
|
||||
// failure mode is silence, never double audio.
|
||||
//
|
||||
// Every native source change reaches the renderer as an event that bumps the
|
||||
// gateway (wired in state.svelte.js), so a native-side surprise invalidates
|
||||
// the grant exactly as a user command does. The native ack generation is
|
||||
@@ -19,6 +28,7 @@
|
||||
|
||||
import * as api from "../api.js";
|
||||
import { createFocusCoordinator, createRevisionGateway } from "./focus.js";
|
||||
import { CROSSFADE_MS } from "./service.js";
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
@@ -48,7 +58,17 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
||||
};
|
||||
},
|
||||
requestSilence: async (generationAtSnapshot) => {
|
||||
const ack = await api.playerPauseAcked(); // throws on timeout
|
||||
// Open crossfade (user decision 2026-07-23): the main player
|
||||
// FADES to silence over the session crossfade instead of hard-
|
||||
// pausing, and this resolves as soon as the backend fade thread
|
||||
// is running — the first preview clip ramps in OVER the fading
|
||||
// main track. This deliberately relaxes the Phase 0.4 rule
|
||||
// ("acknowledged silence before any preview audio"): the
|
||||
// overlap is the feature. The pause + desired-paused latch land
|
||||
// at the fade's end inside the backend thread; its failure mode
|
||||
// is silence (volume left at 0), never double audio.
|
||||
const p = readPlayer();
|
||||
await api.playerFadePauseAcked(CROSSFADE_MS, p.muted ? 0 : (p.volume ?? 0.7));
|
||||
const generationNow = readPlaybackGeneration();
|
||||
return {
|
||||
// Applied only if no new main track started mid-negotiation;
|
||||
@@ -57,8 +77,8 @@ export function createProductionFocus({ readPlayer, readPlaybackGeneration, resu
|
||||
applied: true,
|
||||
generation:
|
||||
generationNow === generationAtSnapshot ? generationAtSnapshot : generationNow,
|
||||
nativeGeneration: ack.generation,
|
||||
latched: ack.latched,
|
||||
nativeGeneration: null,
|
||||
latched: true,
|
||||
};
|
||||
},
|
||||
resumeMain: (snapshot, opts) => {
|
||||
|
||||
@@ -14,6 +14,17 @@ function availabilityFromCatalogTrack(t) {
|
||||
return AVAILABILITY.NOT_PROVIDED;
|
||||
}
|
||||
|
||||
/** Deezer CDN cover URLs are size-addressed (`…/cover/<md5>/250x250-….jpg`).
|
||||
* Track payloads carry the 250px variant — right for list thumbnails, blurry
|
||||
* on the preview reel's large card — so rewrite to the 1000px variant of the
|
||||
* SAME asset the artist page's album grid (`cover_xl`) already uses. Only
|
||||
* the preview modal pays for the bigger image; non-Deezer URLs and anything
|
||||
* shaped unexpectedly pass through untouched. */
|
||||
function fullSizeArtwork(url) {
|
||||
if (typeof url !== "string" || !url.includes("dzcdn.net")) return url;
|
||||
return url.replace(/\/\d{2,4}x\d{2,4}(?=-)/, "/1000x1000");
|
||||
}
|
||||
|
||||
function itemFromCatalogTrack(t, ordinal, inherit = {}) {
|
||||
return makeItem({
|
||||
sessionKey: `s${ordinal}-dz${t.id}`,
|
||||
@@ -25,7 +36,7 @@ function itemFromCatalogTrack(t, ordinal, inherit = {}) {
|
||||
artists: [t.artist || inherit.artist || ""].filter(Boolean),
|
||||
album: t.album || inherit.album,
|
||||
albumId: t.album_id ?? inherit.albumId,
|
||||
artwork: t.cover ?? inherit.artwork,
|
||||
artwork: fullSizeArtwork(t.cover ?? inherit.artwork),
|
||||
fullDurationMs: Number.isFinite(t.duration_secs) ? t.duration_secs * 1000 : undefined,
|
||||
sources: [makeSourceRef({ provider: "deezer", providerTrackId: t.id, matchConfidence: "native" })],
|
||||
initialAvailability: availabilityFromCatalogTrack(t),
|
||||
|
||||
@@ -57,7 +57,7 @@ export function createNativeTransport(dispatch) {
|
||||
/** Accept a native lease and hand its clip to the preview player.
|
||||
* `startPaused` maps to the backend's desired-paused latch, so a
|
||||
* parked open can never leak audio (Phase 0.4). */
|
||||
async load(lease, { sessionId, generation, sessionKey }, { startPaused = false } = {}) {
|
||||
async load(lease, { sessionId, generation, sessionKey }, { startPaused = false, crossfadeMs = 0, startAtMs = 0 } = {}) {
|
||||
if (disposed) throw new Error("transport disposed");
|
||||
await ensureSubscribed();
|
||||
if (!isValidLease(lease) || lease.transport !== "native") {
|
||||
@@ -67,7 +67,14 @@ export function createNativeTransport(dispatch) {
|
||||
return;
|
||||
}
|
||||
ctx = { sessionId, generation, sessionKey };
|
||||
await api.previewLoad(lease.opaqueLeaseId, startPaused);
|
||||
// startAtMs rides into the DECODER (session resume): the clip
|
||||
// comes up already positioned, no post-load seek race.
|
||||
await api.previewLoad(
|
||||
lease.opaqueLeaseId,
|
||||
startPaused,
|
||||
crossfadeMs,
|
||||
startAtMs > 0 ? startAtMs / 1000 : null
|
||||
);
|
||||
},
|
||||
async play() {
|
||||
if (!ctx) return;
|
||||
@@ -84,6 +91,15 @@ export function createNativeTransport(dispatch) {
|
||||
async setVolume(v) {
|
||||
await api.previewControl("volume", v);
|
||||
},
|
||||
/** Crossfade out-half: ramp the current clip to silence instead of
|
||||
* cutting it. ctx is deliberately KEPT — the tail's events carry the
|
||||
* superseded generation and die at the reducer, and the next load
|
||||
* will restamp; clearing ctx here would also mute a failure event
|
||||
* racing the fade. */
|
||||
async fadeOut(ms) {
|
||||
if (!ctx) return;
|
||||
await api.previewControl("fade_out", ms);
|
||||
},
|
||||
async stop() {
|
||||
ctx = null; // events from the dying clip stamp nothing
|
||||
await api.previewControl("stop");
|
||||
|
||||
@@ -145,19 +145,25 @@ export async function closePreview() {
|
||||
demoTicker = null;
|
||||
}
|
||||
if (!service) return { restored: false };
|
||||
const out = await service.close();
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
// Detach first (re-entrancy: a second Escape lands on the early return),
|
||||
// and hide the modal BEFORE awaiting the close — service.close() now
|
||||
// holds the line for the close-crossfade tail (~CROSSFADE_MS), and the
|
||||
// visual out-transition must not wait on audio. The snapshot stays until
|
||||
// the close resolves so the dialog has content through its exit.
|
||||
const svc = service;
|
||||
service = null;
|
||||
previewUi.open = false;
|
||||
previewUi.snapshot = null;
|
||||
previewUi.focusWaiting = false;
|
||||
previewUi.playFull = null;
|
||||
const back = previewUi.returnFocusTo;
|
||||
previewUi.returnFocusTo = null;
|
||||
if (back && typeof back.focus === "function" && back.isConnected) {
|
||||
back.focus();
|
||||
}
|
||||
const out = await svc.close();
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
previewUi.snapshot = null;
|
||||
previewUi.focusWaiting = false;
|
||||
previewUi.playFull = null;
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
+173
-33
@@ -2,7 +2,7 @@
|
||||
//
|
||||
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
||||
// window after navigation, the resolve→load→play pipeline, the single
|
||||
// cache-bypass retry, one direction-aware lease prefetch, and the focus
|
||||
// cache-bypass retry, the two-neighbour lease prefetch, and the focus
|
||||
// handshake at the session edges. Everything time-like is injected
|
||||
// (scheduler, monotonic clock) so the whole loop runs under node:test with
|
||||
// manual ticks — no real timers anywhere in the tests.
|
||||
@@ -20,6 +20,33 @@ const defaultScheduler = {
|
||||
clear: (id) => clearTimeout(id),
|
||||
};
|
||||
|
||||
/** Equal-power crossfade between track previews. 340 ms — the EXACT length
|
||||
* of the reel's card-slide transition (`top 340ms` in PreviewOverlay), so
|
||||
* the audio cross and the visual slide start and land together (user
|
||||
* decision 2026-07-23, superseding the earlier 250 ms pick). The outgoing
|
||||
* clip keeps SOUNDING until the incoming one is loaded — the backend's
|
||||
* crossfade owns both ramps — so the two genuinely overlap. */
|
||||
export const CROSSFADE_MS = 340;
|
||||
|
||||
/** How long the outgoing clip may keep playing under the NEW card before we
|
||||
* give up waiting for the incoming load and fade it out anyway. Only reached
|
||||
* on a slow un-prefetched resolve; with the lease prefetch the load lands in
|
||||
* well under one crossfade. */
|
||||
const OUTGOING_OVERSTAY_MS = 1000;
|
||||
|
||||
/** Steps closer together than this are a burst (held arrow key, ~33 ms
|
||||
* repeats) and get the coalescing settle window; an ISOLATED step fires the
|
||||
* pipeline immediately — the user asked for audio "the instant I scroll",
|
||||
* and the wheel is already rate-gated upstream. */
|
||||
const BURST_WINDOW_MS = 250;
|
||||
|
||||
/** Start the auto-advance this far before the playing clip's end, so the
|
||||
* next clip crossfades in while the tail is still sounding — continuous
|
||||
* auto-play instead of end→silence→late fade-in. Sized for load latency
|
||||
* (~50 ms) + the full crossfade; MEDIA_ENDED remains the backstop for clips
|
||||
* that run shorter than their 30 s nominal duration. */
|
||||
const EARLY_ADVANCE_MS = CROSSFADE_MS + 60;
|
||||
|
||||
export function createPreviewService({
|
||||
resolver,
|
||||
// The service CONSTRUCTS the transport, binding it to its own dispatch —
|
||||
@@ -37,7 +64,12 @@ export function createPreviewService({
|
||||
let settleTimer = null;
|
||||
let focusToken = null;
|
||||
let focusWaiting = false;
|
||||
// sessionKey -> { lease, receiptAt } — the one direction-aware prefetch.
|
||||
// Burst detection for the adaptive settle window.
|
||||
let lastNavAt = -Infinity;
|
||||
// One early auto-advance per generation, however many position events
|
||||
// cross the threshold.
|
||||
let lastEarlyAdvanceGen = -1;
|
||||
// sessionKey -> { lease, receiptAt } — the two-neighbour prefetch (≤2).
|
||||
let prefetched = new Map();
|
||||
|
||||
const emit = () => {
|
||||
@@ -62,20 +94,56 @@ export function createPreviewService({
|
||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||
scheduleSettle();
|
||||
}
|
||||
maybeScheduleEarlyAdvance();
|
||||
}
|
||||
|
||||
/** Continuous auto-play: once the playing clip is within EARLY_ADVANCE_MS
|
||||
* of its end and a next playable exists, step forward NOW — the step's
|
||||
* crossfade then overlaps the tail instead of following the silence.
|
||||
* Latched per generation; the dispatch is deferred a tick (via the
|
||||
* scheduler) so a reducer transition never re-enters the reducer. */
|
||||
function maybeScheduleEarlyAdvance() {
|
||||
if (!state || state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return;
|
||||
const gen = state.requestGeneration;
|
||||
if (gen === lastEarlyAdvanceGen) return;
|
||||
const clip = state.clipDurationMs;
|
||||
if (!clip || state.positionMs < clip - EARLY_ADVANCE_MS) return;
|
||||
const playable = playableManifestIndices(state);
|
||||
const cur = playableCursor(state);
|
||||
if (cur == null || cur + 1 >= playable.length) return; // last clip ends naturally
|
||||
lastEarlyAdvanceGen = gen;
|
||||
const sessionId = state.sessionId;
|
||||
scheduler.set(() => {
|
||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== gen) return;
|
||||
if (state.phase !== PHASE.PLAYING || !state.desiredPlaying || state.muted) return;
|
||||
dispatch({ type: "STEP", sessionId, dir: 1 });
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function scheduleSettle() {
|
||||
if (settleTimer != null) scheduler.clear(settleTimer);
|
||||
const sessionId = state.sessionId;
|
||||
const generation = state.requestGeneration;
|
||||
// Stop the outgoing clip immediately — silence between cards is
|
||||
// correct; audio under the wrong artwork never is.
|
||||
void transport.stop();
|
||||
// Fade-capable transports (native) keep the outgoing clip SOUNDING
|
||||
// through settle + resolve: the crossfade inside load() ramps it out
|
||||
// against the incoming clip, which is the only way the two overlap.
|
||||
// Fading here at t=0 was the 2026-07-23 gap bug — the outgoing was
|
||||
// silent before the incoming even loaded. Transports without fades
|
||||
// (fixture/tests) keep the original hard stop; either way superseded
|
||||
// events carry a stale generation and land inert at the reducer.
|
||||
if (typeof transport.fadeOut !== "function") void transport.stop();
|
||||
// Adaptive settle: an isolated navigation fires the pipeline at once
|
||||
// (audio the instant you scroll); only a BURST — steps landing within
|
||||
// BURST_WINDOW_MS of each other, i.e. a held key — pays the
|
||||
// coalescing window so intermediates never load.
|
||||
const now = monotonicNow();
|
||||
const delay = now - lastNavAt > BURST_WINDOW_MS ? 0 : settleMs;
|
||||
lastNavAt = now;
|
||||
settleTimer = scheduler.set(() => {
|
||||
settleTimer = null;
|
||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||
void runPipeline(sessionId, generation);
|
||||
}, settleMs);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
async function runPipeline(sessionId, generation) {
|
||||
@@ -83,8 +151,35 @@ export function createPreviewService({
|
||||
state && state.sessionId === sessionId && state.requestGeneration === generation;
|
||||
if (!fresh() || state.activeItemKey == null) return;
|
||||
const item = state.manifest.items[state.activeManifestIndex];
|
||||
|
||||
// On fade transports the OUTGOING clip is still sounding right now —
|
||||
// scheduleSettle deliberately left it alone so load()'s crossfade can
|
||||
// ramp it out against the incoming clip. These guards are the two
|
||||
// cases where that handover never comes: the incoming load fails, or
|
||||
// it overstays (slow un-prefetched resolve) — then the outgoing gets
|
||||
// the standalone fade to silence instead of playing on under the
|
||||
// wrong card. Both are no-ops for stop-at-settle transports.
|
||||
let overstayTimer = null;
|
||||
if (typeof transport.fadeOut === "function") {
|
||||
overstayTimer = scheduler.set(() => {
|
||||
overstayTimer = null;
|
||||
if (fresh()) void transport.fadeOut(CROSSFADE_MS);
|
||||
}, OUTGOING_OVERSTAY_MS);
|
||||
}
|
||||
const clearOverstay = () => {
|
||||
if (overstayTimer != null) {
|
||||
scheduler.clear(overstayTimer);
|
||||
overstayTimer = null;
|
||||
}
|
||||
};
|
||||
const retireOutgoing = () => {
|
||||
clearOverstay();
|
||||
if (typeof transport.fadeOut === "function") void transport.fadeOut(CROSSFADE_MS);
|
||||
};
|
||||
|
||||
const source = item.sources[0];
|
||||
if (!source) {
|
||||
retireOutgoing();
|
||||
dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" });
|
||||
return;
|
||||
}
|
||||
@@ -100,14 +195,15 @@ export function createPreviewService({
|
||||
|
||||
if (!lease) {
|
||||
let result = await resolver.resolve(source, { generation });
|
||||
if (!fresh()) return;
|
||||
if (!fresh()) return clearOverstay();
|
||||
if (!result.ok && result.retryable) {
|
||||
// The capped retry: exactly one cache-bypass re-mint per
|
||||
// user intent, per the resolver contract. Never a loop.
|
||||
result = await resolver.resolve(source, { generation, bypassCache: true });
|
||||
if (!fresh()) return;
|
||||
if (!fresh()) return clearOverstay();
|
||||
}
|
||||
if (!result.ok) {
|
||||
retireOutgoing();
|
||||
dispatch({
|
||||
type: "ITEM_FAILED",
|
||||
sessionId,
|
||||
@@ -128,42 +224,66 @@ export function createPreviewService({
|
||||
sessionKey: item.sessionKey,
|
||||
clipDurationMs: lease.clipDurationMs,
|
||||
});
|
||||
if (!fresh()) return;
|
||||
if (!fresh()) return clearOverstay();
|
||||
|
||||
// The load owns the handover from here (crossfade or hard swap).
|
||||
clearOverstay();
|
||||
const audible = state.desiredPlaying && focusToken != null && !state.muted;
|
||||
await transport.load(lease, { sessionId, generation, sessionKey: item.sessionKey }, { startPaused: !audible });
|
||||
// Session resume memory: a revisited clip picks up where it left
|
||||
// off (recorded by the reducer on departure; 0 after it finished).
|
||||
const resumeMs = state.itemRuntimeByKey[item.sessionKey]?.resumeMs ?? 0;
|
||||
await transport.load(
|
||||
lease,
|
||||
{ sessionId, generation, sessionKey: item.sessionKey },
|
||||
{ startPaused: !audible, crossfadeMs: audible ? CROSSFADE_MS : 0, startAtMs: resumeMs }
|
||||
);
|
||||
if (!fresh()) {
|
||||
void transport.stop();
|
||||
return;
|
||||
}
|
||||
if (resumeMs > 0) {
|
||||
// Paint the scrubber at the resume point immediately; the
|
||||
// transport's own position events take over within a tick.
|
||||
dispatch({ type: "MEDIA_POSITION", sessionId, generation, positionMs: resumeMs });
|
||||
}
|
||||
if (audible) await transport.play();
|
||||
|
||||
prefetchNeighbor(sessionId, generation);
|
||||
prefetchNeighbors(sessionId, generation);
|
||||
}
|
||||
|
||||
/** Resolve the NEXT playable item's lease in the background so stepping
|
||||
* forward usually skips the network entirely. One item, forward only —
|
||||
* the bounded mechanism the plan allows without byte prefetch. */
|
||||
function prefetchNeighbor(sessionId, generation) {
|
||||
/** Resolve BOTH playable neighbours' leases in the background so a step
|
||||
* in either direction usually skips the network entirely. Forward-only
|
||||
* prefetch was the 2026-07-23 asymmetry bug: down-steps crossfaded in
|
||||
* ~180 ms while up-steps sat through a full resolve. Bounded at the two
|
||||
* neighbours of the current position — still leases only, no byte
|
||||
* prefetch beyond what resolve itself stores. */
|
||||
function prefetchNeighbors(sessionId, generation) {
|
||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||
const playable = playableManifestIndices(state);
|
||||
const cur = playableCursor(state);
|
||||
if (cur == null || cur + 1 >= playable.length) return;
|
||||
const next = state.manifest.items[playable[cur + 1]];
|
||||
if (prefetched.has(next.sessionKey) || !next.sources[0]) return;
|
||||
void resolver
|
||||
.resolve(next.sources[0], { generation })
|
||||
.then((r) => {
|
||||
if (r.ok && state && state.sessionId === sessionId) {
|
||||
prefetched.set(next.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
|
||||
// bounded: keep at most one prefetched lease around
|
||||
if (prefetched.size > 1) {
|
||||
const oldest = prefetched.keys().next().value;
|
||||
if (oldest !== next.sessionKey) prefetched.delete(oldest);
|
||||
if (cur == null) return;
|
||||
const wanted = [];
|
||||
if (cur + 1 < playable.length) wanted.push(state.manifest.items[playable[cur + 1]]);
|
||||
if (cur - 1 >= 0) wanted.push(state.manifest.items[playable[cur - 1]]);
|
||||
// Prune anything that is no longer a neighbour — the map stays at ≤2.
|
||||
const wantedKeys = new Set(wanted.map((i) => i.sessionKey));
|
||||
for (const key of [...prefetched.keys()]) {
|
||||
if (!wantedKeys.has(key)) prefetched.delete(key);
|
||||
}
|
||||
for (const item of wanted) {
|
||||
if (prefetched.has(item.sessionKey) || !item.sources[0]) continue;
|
||||
// A settled failure stays settled: background prefetch must not
|
||||
// grow the pipeline's capped retry into a loop.
|
||||
if (state.itemRuntimeByKey[item.sessionKey]?.errorClass) continue;
|
||||
void resolver
|
||||
.resolve(item.sources[0], { generation })
|
||||
.then((r) => {
|
||||
if (r.ok && state && state.sessionId === sessionId) {
|
||||
prefetched.set(item.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- focus --
|
||||
@@ -236,6 +356,18 @@ export function createPreviewService({
|
||||
await transport.pause();
|
||||
return;
|
||||
}
|
||||
// Mid-transition (step still resolving/buffering) with listen
|
||||
// intent: the button shows a pause glyph, so a click must PARK —
|
||||
// drop the intent (the pipeline will load startPaused) and
|
||||
// silence whatever is still sounding (the crossfade's outgoing).
|
||||
if (
|
||||
state.desiredPlaying &&
|
||||
(state.phase === PHASE.RESOLVING_MEDIA || state.phase === PHASE.BUFFERING)
|
||||
) {
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false });
|
||||
await transport.pause();
|
||||
return;
|
||||
}
|
||||
if (state.phase === PHASE.ENDED) {
|
||||
dispatch({ type: "REPLAY", sessionId: sid });
|
||||
return;
|
||||
@@ -262,7 +394,10 @@ export function createPreviewService({
|
||||
},
|
||||
|
||||
/** Close: terminal for the session; transport torn down; main
|
||||
* restored only if we paused it and nothing changed since. */
|
||||
* restored only if we paused it and nothing changed since. On fade
|
||||
* transports the close is a CROSSFADE: the preview rings down while
|
||||
* the release's fade-resume brings the main track back up, and the
|
||||
* teardown waits for the tail so dispose cannot cut it off. */
|
||||
async close() {
|
||||
if (!state) return { restored: false };
|
||||
if (settleTimer != null) {
|
||||
@@ -271,11 +406,16 @@ export function createPreviewService({
|
||||
}
|
||||
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||
prefetched.clear();
|
||||
await transport.stop();
|
||||
await transport.dispose();
|
||||
const fades = typeof transport.fadeOut === "function";
|
||||
if (fades) void transport.fadeOut(CROSSFADE_MS);
|
||||
else await transport.stop();
|
||||
const token = focusToken;
|
||||
focusToken = null;
|
||||
const restored = token ? focus.coordinator.release(token) : { restored: false };
|
||||
if (fades) {
|
||||
await new Promise((resolve) => scheduler.set(resolve, CROSSFADE_MS + 80));
|
||||
}
|
||||
await transport.dispose();
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,13 +95,29 @@ function withRuntime(state, sessionKey, patch) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Leaving a clip with less than this remaining counts as FINISHING it — a
|
||||
* revisit starts from the top. Sized just past the service's anticipatory
|
||||
* auto-advance point (clip end − ~400 ms), so an auto-advanced track always
|
||||
* restarts while a mid-clip departure always resumes. */
|
||||
const RESUME_END_GUARD_MS = 500;
|
||||
|
||||
/** Activate the item at `manifestIndex`, bumping the generation — the
|
||||
* common core of first-pick, step, click and auto-advance. */
|
||||
function activate(state, manifestIndex, { keepDesired }) {
|
||||
const item = itemAt(state, manifestIndex);
|
||||
if (!item) return state;
|
||||
// Remember where the OUTGOING clip stopped so a revisit can pick it back
|
||||
// up. positionMs === 0 records nothing — a burst step that only passed
|
||||
// through must not wipe a position remembered from a real earlier visit.
|
||||
let runtime = state.itemRuntimeByKey;
|
||||
if (state.activeItemKey && state.activeItemKey !== item.sessionKey && state.positionMs > 0) {
|
||||
const clip = state.clipDurationMs ?? 30_000;
|
||||
const resumeMs = clip - state.positionMs > RESUME_END_GUARD_MS ? state.positionMs : 0;
|
||||
runtime = withRuntime(state, state.activeItemKey, { resumeMs });
|
||||
}
|
||||
return Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: runtime,
|
||||
phase: PHASE.RESOLVING_MEDIA,
|
||||
activeItemKey: item.sessionKey,
|
||||
activeManifestIndex: manifestIndex,
|
||||
@@ -178,8 +194,15 @@ export function reduce(state, action) {
|
||||
case "REPLAY": {
|
||||
if (!guard(state, action, { needGeneration: false })) return state;
|
||||
if (state.activeManifestIndex == null) return state;
|
||||
// Replay means "from the top" — drop any remembered position.
|
||||
const cleared = state.activeItemKey
|
||||
? Object.freeze({
|
||||
...state,
|
||||
itemRuntimeByKey: withRuntime(state, state.activeItemKey, { resumeMs: 0 }),
|
||||
})
|
||||
: state;
|
||||
return Object.freeze({
|
||||
...activate(state, state.activeManifestIndex, { keepDesired: true }),
|
||||
...activate(cleared, cleared.activeManifestIndex, { keepDesired: true }),
|
||||
desiredPlaying: true,
|
||||
});
|
||||
}
|
||||
@@ -237,7 +260,15 @@ export function reduce(state, action) {
|
||||
if (cur != null && cur + 1 < playable.length) {
|
||||
return activate(state, playable[cur + 1], { keepDesired: true });
|
||||
}
|
||||
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
|
||||
// The last clip finished: any revisit or replay starts fresh.
|
||||
return Object.freeze({
|
||||
...state,
|
||||
phase: PHASE.ENDED,
|
||||
desiredPlaying: false,
|
||||
itemRuntimeByKey: state.activeItemKey
|
||||
? withRuntime(state, state.activeItemKey, { resumeMs: 0 })
|
||||
: state.itemRuntimeByKey,
|
||||
});
|
||||
}
|
||||
case "ITEM_FAILED": {
|
||||
if (!guard(state, action, { needGeneration: true })) return state;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
bumpMainRevision,
|
||||
notePlaybackGeneration,
|
||||
} from "./preview/previewSession.svelte.js";
|
||||
import { CROSSFADE_MS as PREVIEW_CROSSFADE_MS } from "./preview/service.js";
|
||||
|
||||
export const app = $state({
|
||||
connection: "offline", // offline | connecting | online
|
||||
@@ -3567,7 +3568,9 @@ export function initEvents() {
|
||||
initPreviewBridge({
|
||||
readPlayer: () => player,
|
||||
resumeMain: () => {
|
||||
api.playerControl("resume");
|
||||
// Close crossfade: come back from the preview at volume 0 and
|
||||
// ramp to the user's level while the preview rings down.
|
||||
api.playerFadeResume(PREVIEW_CROSSFADE_MS, player.muted ? 0 : player.volume);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -38,6 +38,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function setVolumeNormalization(next) {
|
||||
if (next !== settings.volume_normalization) {
|
||||
await commit({ volumeNormalization: next });
|
||||
}
|
||||
}
|
||||
|
||||
async function chooseDownloadDir() {
|
||||
const dir = await api.pickFolder();
|
||||
if (dir) await commit({ downloadDir: dir });
|
||||
@@ -226,6 +232,21 @@
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="label">
|
||||
<div class="name">Normalize volume</div>
|
||||
<div class="hint">Plays every track at roughly the same loudness (−14 LUFS, like Spotify), so quiet and loud masters stop needing the volume knob. Streams are measured during their first play; boosts never clip.</div>
|
||||
</div>
|
||||
<select
|
||||
class="device-select"
|
||||
value={settings.volume_normalization ? "on" : "off"}
|
||||
disabled={saving}
|
||||
onchange={(e) => setVolumeNormalization(e.currentTarget.value === "on")}
|
||||
>
|
||||
<option value="on">On (recommended)</option>
|
||||
<option value="off">Off</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="muted small">Changes apply immediately and keep the current track near the same position.</p>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in new issue
Block a user