// Native preview transport — the production implementation of the transport // contract in transport.js, over the Tauri preview backend. // // The backend's event stream (`soulstream:preview`) knows nothing about // sessions or generations; THIS wrapper is what makes those events safe. It // stamps every incoming event with the {sessionId, generation, sessionKey} // of the load that is CURRENTLY active on this side of the boundary, and the // reducer's guard does the rest: after a step, events from the superseded // clip carry the old generation and land inert. stop()/dispose() clear the // context, so a straggler event after teardown dispatches nothing at all. import * as api from "../api.js"; import { isValidLease } from "./lease.js"; export function createNativeTransport(dispatch) { let ctx = null; // { sessionId, generation, sessionKey } let unlisten = null; let disposed = false; const stamp = (type, extra = {}) => { if (!ctx) return; dispatch({ type, ...ctx, ...extra }); }; async function ensureSubscribed() { if (unlisten || disposed) return; unlisten = await api.onEvent("soulstream:preview", (ev) => { switch (ev.kind) { case "playing": stamp("MEDIA_PLAYING"); break; case "paused": stamp("MEDIA_PAUSED"); break; case "position": stamp("MEDIA_POSITION", { positionMs: Math.round(ev.secs * 1000) }); break; case "ended": stamp("MEDIA_ENDED"); break; case "error": stamp("ITEM_FAILED", { sessionKey: ctx?.sessionKey, errorClass: "transport", }); break; // "buffering"/"stopped" carry no session-level meaning: the // reducer derives buffering from LEASE_READY + desiredPlaying, // and stops are always consequences of our own commands. default: break; } }); } return { /** Accept a native lease and hand its clip to the preview player. * `startPaused` maps to the backend's desired-paused latch, so a * parked open can never leak audio (Phase 0.4). */ async load(lease, { sessionId, generation, sessionKey }, { startPaused = false, crossfadeMs = 0, startAtMs = 0 } = {}) { if (disposed) throw new Error("transport disposed"); await ensureSubscribed(); if (!isValidLease(lease) || lease.transport !== "native") { ctx = { sessionId, generation, sessionKey }; stamp("ITEM_FAILED", { sessionKey, errorClass: "invalid_media" }); ctx = null; return; } ctx = { sessionId, generation, sessionKey }; // startAtMs rides into the DECODER (session resume): the clip // comes up already positioned, no post-load seek race. await api.previewLoad( lease.opaqueLeaseId, startPaused, crossfadeMs, startAtMs > 0 ? startAtMs / 1000 : null ); }, async play() { if (!ctx) return; await api.previewControl("resume"); }, async pause() { if (!ctx) return; await api.previewControl("pause"); }, async seek(positionMs) { if (!ctx) return; await api.previewControl("seek", positionMs / 1000); }, async setVolume(v) { await api.previewControl("volume", v); }, /** Crossfade out-half: ramp the current clip to silence instead of * cutting it. ctx is deliberately KEPT — the tail's events carry the * superseded generation and die at the reducer, and the next load * will restamp; clearing ctx here would also mute a failure event * racing the fade. */ async fadeOut(ms) { if (!ctx) return; await api.previewControl("fade_out", ms); }, async stop() { ctx = null; // events from the dying clip stamp nothing await api.previewControl("stop"); }, /** Idempotent teardown: silences the backend, deletes clip files, * detaches the event listener. Close, navigation and hot reload all * funnel here. */ async dispose() { if (disposed) return; disposed = true; ctx = null; try { await api.previewDispose(); } finally { if (unlisten) { unlisten(); unlisten = null; } } }, }; }