// 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; }, }; }