forked from Keelrleek/SoulStream
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:
1 parent
8ede6a8adf
commit
8cb3cd593b
11 files changed
+1536
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user