Opening the preview faded the main track out at focus-acquire time, but the first preview clip only becomes audible after resolve/load/decode — so on the way IN the 340 ms main fade completed into silence before the preview arrived (a gap the user heard; the exit was already fine). The main fade is now deferred: focusBridge.requestSilence only arms the commitment and grants; coordinator.beginMainSilence performs the actual fade-pause, and the service fires it from afterChange on the opening session's first audible clip (or ITEM_FAILED). Main-cos-out then overlaps preview-sin-in — a true cross. resumeMain skips the fade-resume when the fade never fired (close before audible), and beginMainSilence re-checks the main is sounding before fading. The focus coordinator's acquisition logic and tests are untouched. Verified live: preview audible at 275 ms, main played until then, faded ~343 ms, paused at 618 ms and held frozen; close resumed from the exact position in 15 ms.
450 lines
20 KiB
JavaScript
450 lines
20 KiB
JavaScript
// Preview session service — the effectful orchestrator over the pure parts.
|
|
//
|
|
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
|
// window after navigation, the resolve→load→play pipeline, the single
|
|
// 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.
|
|
//
|
|
// Staleness discipline: every async continuation re-checks BOTH the session
|
|
// id and the request generation before dispatching or touching the
|
|
// transport. The reducer would drop a stale dispatch anyway; checking here
|
|
// too keeps the transport from even being commanded on behalf of a dead
|
|
// generation (the "transport boundary" half of the Phase 2 exit gate).
|
|
|
|
import { reduce, createInitialState, playableManifestIndices, playableCursor, PHASE } from "./session.js";
|
|
|
|
const defaultScheduler = {
|
|
set: (fn, ms) => setTimeout(fn, ms),
|
|
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 —
|
|
// the transport's events (MEDIA_PLAYING, MEDIA_ENDED, …) flow through the
|
|
// same generation-guarded reducer as everything else. Passing an already-
|
|
// built transport is how the original draft silently lost that wiring.
|
|
transportFactory,
|
|
focus, // { coordinator } from focusBridge, or a test double
|
|
settleMs = 150,
|
|
scheduler = defaultScheduler,
|
|
monotonicNow = () => (globalThis.performance ?? Date).now(),
|
|
}) {
|
|
let state = null;
|
|
let listeners = new Set();
|
|
let settleTimer = null;
|
|
let focusToken = null;
|
|
let focusWaiting = false;
|
|
// Deferred open crossfade: true from a granted open until the first
|
|
// preview clip is audible (or definitively won't be), at which point the
|
|
// main track's fade-out fires — synced to the preview's fade-in.
|
|
let pendingOpenSilence = false;
|
|
// 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 = () => {
|
|
for (const l of listeners) l(state, { focusWaiting });
|
|
};
|
|
const dispatch = (action) => {
|
|
if (!state) return;
|
|
const before = state;
|
|
state = reduce(state, action);
|
|
if (state !== before) {
|
|
afterChange(before);
|
|
emit();
|
|
}
|
|
};
|
|
const transport = transportFactory(dispatch);
|
|
|
|
// ------------------------------------------------------ media pipeline --
|
|
|
|
function afterChange(before) {
|
|
if (!state.manifest) return;
|
|
const generationMoved = state.requestGeneration !== before.requestGeneration;
|
|
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
|
scheduleSettle();
|
|
}
|
|
// Deferred open crossfade: fade the main track out the instant the
|
|
// opening preview clip becomes audible (PLAYING) — so the ramps
|
|
// overlap — or the moment it's clear it never will (ITEM_FAILED),
|
|
// matching the old take-audio-on-open behaviour on failure.
|
|
if (pendingOpenSilence && (state.phase === PHASE.PLAYING || state.phase === PHASE.ITEM_FAILED)) {
|
|
pendingOpenSilence = false;
|
|
focus.coordinator.beginMainSilence?.();
|
|
}
|
|
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;
|
|
// 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);
|
|
}, delay);
|
|
}
|
|
|
|
async function runPipeline(sessionId, generation) {
|
|
const fresh = () =>
|
|
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;
|
|
}
|
|
dispatch({ type: "RESOLVE_STARTED", sessionId, generation, sessionKey: item.sessionKey });
|
|
|
|
// Prefetched lease still inside its safe window → skip the resolve.
|
|
let lease = null;
|
|
const pre = prefetched.get(item.sessionKey);
|
|
if (pre && monotonicNow() - pre.receiptAt < pre.lease.safeForMs * 0.8) {
|
|
lease = pre.lease;
|
|
}
|
|
prefetched.delete(item.sessionKey);
|
|
|
|
if (!lease) {
|
|
let result = await resolver.resolve(source, { generation });
|
|
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 clearOverstay();
|
|
}
|
|
if (!result.ok) {
|
|
retireOutgoing();
|
|
dispatch({
|
|
type: "ITEM_FAILED",
|
|
sessionId,
|
|
generation,
|
|
sessionKey: item.sessionKey,
|
|
errorClass: result.failure,
|
|
availability: result.failure === "not_provided" ? "not_provided" : undefined,
|
|
});
|
|
return;
|
|
}
|
|
lease = result.lease;
|
|
}
|
|
|
|
dispatch({
|
|
type: "LEASE_READY",
|
|
sessionId,
|
|
generation,
|
|
sessionKey: item.sessionKey,
|
|
clipDurationMs: lease.clipDurationMs,
|
|
});
|
|
if (!fresh()) return clearOverstay();
|
|
|
|
// The load owns the handover from here (crossfade or hard swap).
|
|
clearOverstay();
|
|
const audible = state.desiredPlaying && focusToken != null && !state.muted;
|
|
// 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();
|
|
|
|
prefetchNeighbors(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) 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(() => {});
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------- focus --
|
|
|
|
async function ensureFocus() {
|
|
if (focusToken) return true;
|
|
focusWaiting = true;
|
|
emit();
|
|
try {
|
|
const { granted, token } = await focus.coordinator.acquirePreview();
|
|
focusToken = granted ? token : null;
|
|
return granted;
|
|
} catch {
|
|
focusToken = null;
|
|
return false;
|
|
} finally {
|
|
focusWaiting = false;
|
|
emit();
|
|
}
|
|
}
|
|
|
|
// ----------------------------------------------------------- public ----
|
|
|
|
return {
|
|
subscribe(fn) {
|
|
listeners.add(fn);
|
|
if (state) fn(state, { focusWaiting });
|
|
return () => listeners.delete(fn);
|
|
},
|
|
getState: () => state,
|
|
isFocusWaiting: () => focusWaiting,
|
|
|
|
/** Open a session: focus first (take-audio-on-open), then manifest.
|
|
* If the acknowledgement fails, the session still opens — parked,
|
|
* with the transient wait surfaced — and Play retries acquisition. */
|
|
async open({ sessionId, manifest, startAtManifestIndex }) {
|
|
state = createInitialState(sessionId);
|
|
emit();
|
|
const granted = await ensureFocus();
|
|
// Arm the deferred main fade only on a granted open (a parked
|
|
// session leaves the main track playing). It fires from
|
|
// afterChange when the first clip is audible.
|
|
pendingOpenSilence = granted;
|
|
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
|
|
if (!granted && state.phase !== PHASE.EMPTY) {
|
|
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
|
|
}
|
|
},
|
|
|
|
step: (dir) => dispatch({ type: "STEP", sessionId: state?.sessionId, dir }),
|
|
activateKey: (sessionKey) => dispatch({ type: "ACTIVATE_KEY", sessionId: state?.sessionId, sessionKey }),
|
|
replay: () => dispatch({ type: "REPLAY", sessionId: state?.sessionId }),
|
|
seek: (positionMs) => {
|
|
if (!state) return;
|
|
void transport.seek?.(positionMs);
|
|
dispatch({
|
|
type: "MEDIA_POSITION",
|
|
sessionId: state.sessionId,
|
|
generation: state.requestGeneration,
|
|
positionMs,
|
|
});
|
|
},
|
|
setMuted(value) {
|
|
if (!state) return;
|
|
dispatch({ type: "SET_MUTED", sessionId: state.sessionId, value });
|
|
if (value && state.phase === PHASE.PLAYING) void transport.pause();
|
|
},
|
|
|
|
/** Mirror the APP's volume (and mute, as 0) onto the preview
|
|
* transport — previews must never play louder than the player bar
|
|
* says. No-op for transports without a volume control (fixture). */
|
|
setVolume(v) {
|
|
void transport.setVolume?.(v);
|
|
},
|
|
|
|
async togglePlay() {
|
|
if (!state) return;
|
|
const sid = state.sessionId;
|
|
if (state.phase === PHASE.PLAYING) {
|
|
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false });
|
|
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;
|
|
}
|
|
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: true });
|
|
const granted = await ensureFocus();
|
|
if (!granted) return; // parked; wait state already surfaced
|
|
if (state.phase === PHASE.READY || state.phase === PHASE.PAUSED) {
|
|
await transport.play();
|
|
}
|
|
// RESOLVING/BUFFERING: the pipeline observes desiredPlaying when
|
|
// the lease lands; nothing else to do here.
|
|
},
|
|
|
|
/** Play full track: hand ownership to ordinary playback. The old
|
|
* main snapshot is intentionally discarded (never restored). */
|
|
async playFullTrack(startMainPlayback) {
|
|
if (!state) return;
|
|
await transport.stop();
|
|
focus.coordinator.handoffToMain();
|
|
focusToken = null;
|
|
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: state.sessionId, value: false });
|
|
await startMainPlayback(state.manifest.items[state.activeManifestIndex]);
|
|
},
|
|
|
|
/** Close: terminal for the session; transport torn down; main
|
|
* 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) {
|
|
scheduler.clear(settleTimer);
|
|
settleTimer = null;
|
|
}
|
|
// Closing before the opening clip ever sounded: leave the deferred
|
|
// fade unfired — release() will find the main still playing and
|
|
// simply disarm it (no fade-resume dip).
|
|
pendingOpenSilence = false;
|
|
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
|
prefetched.clear();
|
|
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;
|
|
},
|
|
};
|
|
}
|