src/lib/preview/ — framework-free ESM modules, per design §12–14: - contracts.js: versioned manifest/item/source-ref factories; signed URLs structurally excluded from manifests; Apple identity first-class. - lease.js: discriminated MediaLease union; computeSafeForMs derives lifetimes provider-clock-first with fallback/contradiction handling (margins are ADRs backed by Phase 0 soak evidence). - redact.js: URL redaction (host+hash+exp only) and the HTTPS + exact host allowlist gate applied to initial URLs and every redirect. - manifest.js: artist (rank order, exact-id dedupe), album (disc/track sort, context inheritance) and fixture builders — pure, no fetching. - session.js: reducer with session/generation guards on every async action; keyed active item with derived playable indices/cursor; auto-advance under fresh generations; close is terminal. - resolver.js: PreviewResolver contract + scripted fixture resolver with classified failures (stale_lease/not_found/network/…). - transport.js: transport seam + deterministic fake (manual ticks). - focus.js: revision gateway (all main-player entry points; focus- internal tag) and the coordinator — acquisition only via a generation-bound silence ack, restore only with revisions unchanged. tests/preview/*.test.mjs run via `npm test` (node --test): 61 passing, covering the full Phase 1 list in IMPLEMENTATION-PLAN §6 including the exit gate: stale lease/play events are inert, no restoration after any simulated main command, and no component renders anywhere.
278 lines
12 KiB
JavaScript
278 lines
12 KiB
JavaScript
// Pure preview-session reducer — design §12 runtime state, §9 navigation
|
|
// invariants. No timers, no I/O, no Date.now(): every action carries what it
|
|
// needs, so the whole machine replays deterministically under node:test.
|
|
//
|
|
// THE one rule everything else hangs off: any action produced by async work
|
|
// (resolver, transport) carries {sessionId, generation}, and the reducer
|
|
// drops it unless BOTH match the current state. A late lease from generation
|
|
// 3 can never start audio under the cover generation 4 is showing.
|
|
|
|
import { AVAILABILITY } from "./contracts.js";
|
|
|
|
export const PHASE = Object.freeze({
|
|
RESOLVING_MANIFEST: "resolving_manifest",
|
|
READY: "ready",
|
|
RESOLVING_MEDIA: "resolving_media",
|
|
BUFFERING: "buffering",
|
|
PLAYING: "playing",
|
|
PAUSED: "paused",
|
|
ENDED: "ended",
|
|
ITEM_FAILED: "item_failed",
|
|
EMPTY: "empty",
|
|
CLOSING: "closing",
|
|
});
|
|
|
|
export function createInitialState(sessionId) {
|
|
if (!sessionId) throw new Error("sessionId required");
|
|
return Object.freeze({
|
|
sessionId,
|
|
phase: PHASE.RESOLVING_MANIFEST,
|
|
manifest: null,
|
|
itemRuntimeByKey: Object.freeze({}),
|
|
activeItemKey: null,
|
|
activeManifestIndex: null,
|
|
requestGeneration: 0,
|
|
desiredPlaying: false,
|
|
muted: false,
|
|
positionMs: 0,
|
|
clipDurationMs: undefined,
|
|
focusToken: null,
|
|
errorClass: undefined,
|
|
});
|
|
}
|
|
|
|
// ------------------------------------------------------------ derivations --
|
|
|
|
/** Effective availability: runtime overrides the manifest's initial value. */
|
|
export function effectiveAvailability(state, item) {
|
|
return state.itemRuntimeByKey[item.sessionKey]?.availability ?? item.initialAvailability;
|
|
}
|
|
|
|
/** Manifest indices whose items are currently playable, in manifest order.
|
|
* Derived every time — never stored, so it can't drift (design §9). */
|
|
export function playableManifestIndices(state) {
|
|
if (!state.manifest) return [];
|
|
const out = [];
|
|
state.manifest.items.forEach((item, i) => {
|
|
if (effectiveAvailability(state, item) === AVAILABILITY.AVAILABLE) out.push(i);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
/** Position of the active item inside the playable list, or null when the
|
|
* active item is not currently eligible (e.g. it failed and is being shown
|
|
* in its error state). The active item is identified by sessionKey + index —
|
|
* never by this cursor (design §9: availability changes recompute the cursor
|
|
* AROUND the stable item; they never move the item). */
|
|
export function playableCursor(state) {
|
|
if (state.activeManifestIndex == null) return null;
|
|
const idx = playableManifestIndices(state).indexOf(state.activeManifestIndex);
|
|
return idx === -1 ? null : idx;
|
|
}
|
|
|
|
function itemAt(state, manifestIndex) {
|
|
return state.manifest?.items[manifestIndex] ?? null;
|
|
}
|
|
|
|
// -------------------------------------------------------------- reducer ----
|
|
|
|
function guard(state, action, { needGeneration }) {
|
|
if (action.sessionId !== state.sessionId) return false;
|
|
if (state.phase === PHASE.CLOSING) return false; // terminal: nothing lands
|
|
if (needGeneration && action.generation !== state.requestGeneration) return false;
|
|
return true;
|
|
}
|
|
|
|
function withRuntime(state, sessionKey, patch) {
|
|
const prev = state.itemRuntimeByKey[sessionKey] ?? {
|
|
availability: undefined,
|
|
mediaPhase: "idle",
|
|
errorClass: undefined,
|
|
};
|
|
return Object.freeze({
|
|
...state.itemRuntimeByKey,
|
|
[sessionKey]: Object.freeze({ ...prev, ...patch }),
|
|
});
|
|
}
|
|
|
|
/** 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;
|
|
return Object.freeze({
|
|
...state,
|
|
phase: PHASE.RESOLVING_MEDIA,
|
|
activeItemKey: item.sessionKey,
|
|
activeManifestIndex: manifestIndex,
|
|
requestGeneration: state.requestGeneration + 1,
|
|
desiredPlaying: keepDesired ? state.desiredPlaying : state.desiredPlaying,
|
|
positionMs: 0,
|
|
clipDurationMs: undefined,
|
|
errorClass: undefined,
|
|
});
|
|
}
|
|
|
|
export function reduce(state, action) {
|
|
switch (action.type) {
|
|
// ---- session lifecycle ------------------------------------------
|
|
case "MANIFEST_READY": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
const next = Object.freeze({ ...state, manifest: action.manifest });
|
|
const playable = playableManifestIndices(next);
|
|
if (playable.length === 0) {
|
|
return Object.freeze({ ...next, phase: PHASE.EMPTY });
|
|
}
|
|
// Opening takes audio (decision 2026-07-22): the first playable
|
|
// item becomes active with listen intent already set; the focus
|
|
// coordinator upstream decides when sound may actually start.
|
|
return activate(
|
|
Object.freeze({ ...next, desiredPlaying: true }),
|
|
action.startAtManifestIndex != null && playable.includes(action.startAtManifestIndex)
|
|
? action.startAtManifestIndex
|
|
: playable[0],
|
|
{ keepDesired: true }
|
|
);
|
|
}
|
|
case "CLOSE": {
|
|
if (action.sessionId !== state.sessionId) return state;
|
|
return Object.freeze({ ...state, phase: PHASE.CLOSING, desiredPlaying: false, focusToken: null });
|
|
}
|
|
|
|
// ---- navigation (user intent, current-generation by definition) --
|
|
case "STEP": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
const playable = playableManifestIndices(state);
|
|
if (playable.length === 0) return state;
|
|
const cur = playableCursor(state);
|
|
let target;
|
|
if (cur != null) {
|
|
target = cur + action.dir;
|
|
} else {
|
|
// Active item isn't eligible (failed): move to the next
|
|
// eligible MANIFEST ordinal in the step direction (design §9).
|
|
const from = state.activeManifestIndex ?? -1;
|
|
target =
|
|
action.dir > 0
|
|
? playable.findIndex((i) => i > from)
|
|
: (() => {
|
|
let last = -1;
|
|
playable.forEach((i, k) => {
|
|
if (i < from) last = k;
|
|
});
|
|
return last;
|
|
})();
|
|
}
|
|
if (target < 0 || target >= playable.length) return state; // no wrap
|
|
return activate(state, playable[target], { keepDesired: true });
|
|
}
|
|
case "ACTIVATE_KEY": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
const idx = state.manifest?.items.findIndex((it) => it.sessionKey === action.sessionKey) ?? -1;
|
|
if (idx === -1) return state;
|
|
const item = state.manifest.items[idx];
|
|
if (effectiveAvailability(state, item) !== AVAILABILITY.AVAILABLE) return state;
|
|
if (idx === state.activeManifestIndex) return state;
|
|
return activate(state, idx, { keepDesired: true });
|
|
}
|
|
case "REPLAY": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
if (state.activeManifestIndex == null) return state;
|
|
return Object.freeze({
|
|
...activate(state, state.activeManifestIndex, { keepDesired: true }),
|
|
desiredPlaying: true,
|
|
});
|
|
}
|
|
case "SET_DESIRED_PLAYING": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
return Object.freeze({ ...state, desiredPlaying: !!action.value });
|
|
}
|
|
case "SET_MUTED": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
return Object.freeze({ ...state, muted: !!action.value });
|
|
}
|
|
case "FOCUS_SET": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
return Object.freeze({ ...state, focusToken: action.token ?? null });
|
|
}
|
|
|
|
// ---- async completions (generation-gated) ------------------------
|
|
case "RESOLVE_STARTED": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
if (action.sessionKey !== state.activeItemKey) return state;
|
|
return Object.freeze({
|
|
...state,
|
|
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "resolving" }),
|
|
});
|
|
}
|
|
case "LEASE_READY": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
if (action.sessionKey !== state.activeItemKey) return state;
|
|
return Object.freeze({
|
|
...state,
|
|
phase: state.desiredPlaying ? PHASE.BUFFERING : PHASE.READY,
|
|
clipDurationMs: action.clipDurationMs ?? state.clipDurationMs,
|
|
itemRuntimeByKey: withRuntime(state, action.sessionKey, { mediaPhase: "ready" }),
|
|
});
|
|
}
|
|
case "MEDIA_PLAYING": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
return Object.freeze({ ...state, phase: PHASE.PLAYING });
|
|
}
|
|
case "MEDIA_PAUSED": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
return Object.freeze({ ...state, phase: PHASE.PAUSED });
|
|
}
|
|
case "MEDIA_POSITION": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
return Object.freeze({ ...state, positionMs: action.positionMs });
|
|
}
|
|
case "MEDIA_ENDED": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
// Auto-advance (decision 2026-07-22): roll into the next playable
|
|
// item under the SAME generation rules — activate() bumps, so any
|
|
// straggler event from the finished clip is stale by construction.
|
|
const playable = playableManifestIndices(state);
|
|
const cur = playableCursor(state);
|
|
if (cur != null && cur + 1 < playable.length) {
|
|
return activate(state, playable[cur + 1], { keepDesired: true });
|
|
}
|
|
return Object.freeze({ ...state, phase: PHASE.ENDED, desiredPlaying: false });
|
|
}
|
|
case "ITEM_FAILED": {
|
|
if (!guard(state, action, { needGeneration: true })) return state;
|
|
const runtime = withRuntime(state, action.sessionKey, {
|
|
availability: action.availability ?? AVAILABILITY.TEMP_FAILED,
|
|
mediaPhase: "failed",
|
|
errorClass: action.errorClass,
|
|
});
|
|
const isActive = action.sessionKey === state.activeItemKey;
|
|
return Object.freeze({
|
|
...state,
|
|
itemRuntimeByKey: runtime,
|
|
// A failed ACTIVE item stays visible in its error state
|
|
// (design §9) — no auto-skip, the user retries or steps.
|
|
phase: isActive ? PHASE.ITEM_FAILED : state.phase,
|
|
errorClass: isActive ? action.errorClass : state.errorClass,
|
|
});
|
|
}
|
|
|
|
// ---- runtime availability (metadata, NOT generation-gated) -------
|
|
case "AVAILABILITY_CHANGED": {
|
|
if (!guard(state, action, { needGeneration: false })) return state;
|
|
// Only itemRuntimeByKey changes: a newly available EARLIER item
|
|
// must never move the current card (design §9) — and it can't,
|
|
// because the active item is keyed, not cursor-positioned.
|
|
return Object.freeze({
|
|
...state,
|
|
itemRuntimeByKey: withRuntime(state, action.sessionKey, {
|
|
availability: action.availability,
|
|
}),
|
|
});
|
|
}
|
|
|
|
default:
|
|
return state;
|
|
}
|
|
}
|