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:
KeelrLeek committed 2026-07-23 16:05:14 -05:00
1 parent 5e877c0b0a
commit 793f78370e
27 files changed
+2834 -178

No files matched your search

+7 -2
View File
@@ -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");
+29 -9
View File
@@ -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) => {
+12 -1
View File
@@ -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),
+18 -2
View File
@@ -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");
+12 -6
View File
@@ -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
View File
@@ -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;
},
};
+33 -2
View File
@@ -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;
+4 -1
View File
@@ -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);
},
});