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