Files
SoulStream/src/lib/preview/service.js
T
KeelrLeek 8cb3cd593b Phase 2A+2B: native preview backend and renderer glue
Rust (2A):
- metadata: deezer_track_uncached (fresh mint, cache-updating, paced) and
  fetch_preview_media — dedicated client whose custom redirect policy
  re-validates every hop against the media allowlist; bounded body read
  (cap enforced on received bytes, not Content-Length's promise); Date
  header captured for provider-clock lease math. httpdate dep added.
- src-tauri/preview.rs: second Player instance + soulstream:preview event
  channel; preview_resolve (fresh mint → host gate → MIME gate → ID3v2
  shell strip → MPEG sync check → clip file + opaque lease id → safe_for_ms
  mirroring lease.js); preview_load with start_paused via the
  desired-paused latch; preview_control/dispose; player_pause_acked
  exposing the Phase 0.4 ack on the MAIN player. 9 unit tests.

JS (2B):
- api.js: preview command surface (opaque lease ids only).
- nativeTransport.js: stamps {sessionId, generation, sessionKey} onto
  backend events; cleared context makes stragglers dispatch nothing.
- productionResolver.js: DTO → MediaLease, failure classes preserved.
- focusBridge.js: production coordinator binding with the documented
  renderer-side generation invariant (buffering counter + gateway).
- service.js: the orchestrator — settle window, resolve→load→play under
  focus, single capped retry, one forward lease prefetch with safe-window
  expiry, stale re-checks before every transport command, close/handoff.
  The service constructs its transport around its own dispatch — the
  factory signature exists because a pre-built transport silently loses
  the event wiring (caught by test).

70 JS tests passing (npm test); metadata 46 + app preview 9 in cargo.
2026-07-22 18:45:29 -05:00

283 lines
11 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, one direction-aware 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),
};
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;
// sessionKey -> { lease, receiptAt } — the one direction-aware prefetch.
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();
}
}
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();
settleTimer = scheduler.set(() => {
settleTimer = null;
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
void runPipeline(sessionId, generation);
}, settleMs);
}
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];
const source = item.sources[0];
if (!source) {
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;
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 (!result.ok) {
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;
const audible = state.desiredPlaying && focusToken != null && !state.muted;
await transport.load(lease, { sessionId, generation, sessionKey: item.sessionKey }, { startPaused: !audible });
if (!fresh()) {
void transport.stop();
return;
}
if (audible) await transport.play();
prefetchNeighbor(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) {
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);
}
}
})
.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();
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();
},
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;
}
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. */
async close() {
if (!state) return { restored: false };
if (settleTimer != null) {
scheduler.clear(settleTimer);
settleTimer = null;
}
dispatch({ type: "CLOSE", sessionId: state.sessionId });
prefetched.clear();
await transport.stop();
await transport.dispose();
const token = focusToken;
focusToken = null;
const restored = token ? focus.coordinator.release(token) : { restored: false };
return restored;
},
};
}