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.
This commit is contained in:
KeelrLeek committed 2026-07-22 18:45:29 -05:00
1 parent 8ede6a8adf
commit 8cb3cd593b
11 files changed
+1536

No files matched your search

+108
View File
@@ -0,0 +1,108 @@
// 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 } = {}) {
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 };
await api.previewLoad(lease.opaqueLeaseId, startPaused);
},
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);
},
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;
}
}
},
};
}