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
@@ -132,6 +132,19 @@ export const preloadCachedStream = (username, path, size, duration) =>
|
||||
export const streamWaveform = (username, path, size, buckets) =>
|
||||
invoke("stream_waveform", { username, path, size, buckets });
|
||||
|
||||
// Preview tracks (scrollable-track-previews): a fully separate audio path.
|
||||
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
||||
// never crosses this boundary, only an opaque lease id does.
|
||||
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
||||
export const previewLoad = (leaseId, startPaused) =>
|
||||
invoke("preview_load", { leaseId, startPaused });
|
||||
export const previewControl = (action, value = null) =>
|
||||
invoke("preview_control", { action, value });
|
||||
export const previewDispose = () => invoke("preview_dispose");
|
||||
// Main-player silence acknowledgement (audio-focus contract). Throws on
|
||||
// timeout — callers keep preview parked; they never assume silence.
|
||||
export const playerPauseAcked = () => invoke("player_pause_acked");
|
||||
|
||||
// Window chrome (the app draws its own titlebar; decorations are off).
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
export const win = {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// Production wiring for the Phase 1 focus coordinator: the real main player
|
||||
// on one side, the preview session on the other.
|
||||
//
|
||||
// One honest deviation from the pure model, documented here because it is a
|
||||
// real design decision: the renderer cannot know the NATIVE playback
|
||||
// generation ahead of time (it only travels in the PauseAck itself). The
|
||||
// production binding therefore replaces "ack.generation === snapshot's
|
||||
// native generation" with an equivalent renderer-side invariant:
|
||||
//
|
||||
// granted ⇔ ack received (never assumed on timeout)
|
||||
// ∧ no renderer PLAYBACK generation change during negotiation
|
||||
// (a new Buffering = a new track start = a new generation)
|
||||
// ∧ no revision-gateway change during negotiation
|
||||
//
|
||||
// Every native source change reaches the renderer as an event that bumps the
|
||||
// gateway (wired in state.svelte.js), so a native-side surprise invalidates
|
||||
// the grant exactly as a user command does. The native ack generation is
|
||||
// kept on the token for diagnostics.
|
||||
|
||||
import * as api from "../api.js";
|
||||
import { createFocusCoordinator, createRevisionGateway } from "./focus.js";
|
||||
|
||||
/**
|
||||
* @param {object} deps
|
||||
* @param {() => {status: string, track: object|null, position: number}} deps.readPlayer
|
||||
* Reads the live main-player store (state.svelte.js `player`).
|
||||
* @param {() => number} deps.readPlaybackGeneration
|
||||
* Renderer-side monotonic counter, bumped on every main "buffering".
|
||||
* @param {(snapshot: object) => void} deps.resumeMain
|
||||
* Ordinary resume path, invoked focus-internally.
|
||||
*/
|
||||
export function createProductionFocus({ readPlayer, readPlaybackGeneration, resumeMain }) {
|
||||
const gateway = createRevisionGateway();
|
||||
|
||||
const coordinator = createFocusCoordinator({
|
||||
gateway,
|
||||
mainStatus: () => {
|
||||
const p = readPlayer();
|
||||
return {
|
||||
status: p.status,
|
||||
trackKey: p.track ? `${p.track.source}:${p.track.sub ?? ""}:${p.track.title ?? ""}` : null,
|
||||
positionMs: Math.round((p.position ?? 0) * 1000),
|
||||
// Audible covers buffering too: a decode in flight will BECOME
|
||||
// audible, which is exactly the auto-start window the
|
||||
// desired-paused latch exists to close.
|
||||
audible: p.status === "playing" || p.status === "buffering",
|
||||
playbackGeneration: readPlaybackGeneration(),
|
||||
};
|
||||
},
|
||||
requestSilence: async (generationAtSnapshot) => {
|
||||
const ack = await api.playerPauseAcked(); // throws on timeout
|
||||
const generationNow = readPlaybackGeneration();
|
||||
return {
|
||||
// Applied only if no new main track started mid-negotiation;
|
||||
// the returned generation makes the coordinator's equality
|
||||
// check express exactly that.
|
||||
applied: true,
|
||||
generation:
|
||||
generationNow === generationAtSnapshot ? generationAtSnapshot : generationNow,
|
||||
nativeGeneration: ack.generation,
|
||||
latched: ack.latched,
|
||||
};
|
||||
},
|
||||
resumeMain: (snapshot, opts) => {
|
||||
gateway.userPlay(opts); // focus-internal: bumps nothing
|
||||
resumeMain(snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
return { coordinator, gateway };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Production PreviewResolver — the renderer half of the native resolver.
|
||||
//
|
||||
// Same contract as createFixtureResolver: stable source ref in, discriminated
|
||||
// result out. The heavy lifting (fresh mint, host policy, bounded fetch, MIME
|
||||
// gate, ID3 strip, safeForMs) happens in Rust; this wrapper only reshapes the
|
||||
// DTO into a MediaLease and keeps the failure classes intact. `bypassCache`
|
||||
// is accepted for contract parity — the backend mints fresh on every call by
|
||||
// design, so the flag only matters to the caller's single-retry accounting.
|
||||
|
||||
import * as api from "../api.js";
|
||||
import { makeNativeLease } from "./lease.js";
|
||||
|
||||
export function createProductionResolver() {
|
||||
return {
|
||||
async resolve(sourceRef, { generation } = {}) {
|
||||
if (sourceRef.provider !== "deezer") {
|
||||
// Apple identity plumbing is the Phase 2 backend extension
|
||||
// tracked in the plan; until it lands, non-Deezer sources
|
||||
// classify as permanently unresolvable rather than guessing.
|
||||
return { ok: false, failure: "not_found", generation, retryable: false };
|
||||
}
|
||||
let dto;
|
||||
try {
|
||||
dto = await api.previewResolve(Number(sourceRef.providerTrackId));
|
||||
} catch (e) {
|
||||
return { ok: false, failure: "network", generation, retryable: true };
|
||||
}
|
||||
if (!dto?.ok || !dto.lease) {
|
||||
return {
|
||||
ok: false,
|
||||
failure: dto?.failure ?? "temporarily_failed",
|
||||
generation,
|
||||
retryable: !!dto?.retryable,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
generation,
|
||||
lease: makeNativeLease({
|
||||
source: sourceRef,
|
||||
opaqueLeaseId: dto.lease.opaqueLeaseId,
|
||||
safeForMs: dto.lease.safeForMs,
|
||||
mimeType: dto.lease.mimeType,
|
||||
clipDurationMs: dto.lease.clipDurationMs,
|
||||
byteLength: dto.lease.byteLength,
|
||||
supportsRanges: true,
|
||||
}),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
// Preview session service — the effectful orchestrator over the pure parts.
|
||||
//
|
||||
// The reducer decides WHAT happens; this service decides WHEN: the settle
|
||||
// window after navigation, the resolve→load→play pipeline, the single
|
||||
// cache-bypass retry, one direction-aware lease prefetch, and the focus
|
||||
// handshake at the session edges. Everything time-like is injected
|
||||
// (scheduler, monotonic clock) so the whole loop runs under node:test with
|
||||
// manual ticks — no real timers anywhere in the tests.
|
||||
//
|
||||
// Staleness discipline: every async continuation re-checks BOTH the session
|
||||
// id and the request generation before dispatching or touching the
|
||||
// transport. The reducer would drop a stale dispatch anyway; checking here
|
||||
// too keeps the transport from even being commanded on behalf of a dead
|
||||
// generation (the "transport boundary" half of the Phase 2 exit gate).
|
||||
|
||||
import { reduce, createInitialState, playableManifestIndices, playableCursor, PHASE } from "./session.js";
|
||||
|
||||
const defaultScheduler = {
|
||||
set: (fn, ms) => setTimeout(fn, ms),
|
||||
clear: (id) => clearTimeout(id),
|
||||
};
|
||||
|
||||
export function createPreviewService({
|
||||
resolver,
|
||||
// The service CONSTRUCTS the transport, binding it to its own dispatch —
|
||||
// the transport's events (MEDIA_PLAYING, MEDIA_ENDED, …) flow through the
|
||||
// same generation-guarded reducer as everything else. Passing an already-
|
||||
// built transport is how the original draft silently lost that wiring.
|
||||
transportFactory,
|
||||
focus, // { coordinator } from focusBridge, or a test double
|
||||
settleMs = 150,
|
||||
scheduler = defaultScheduler,
|
||||
monotonicNow = () => (globalThis.performance ?? Date).now(),
|
||||
}) {
|
||||
let state = null;
|
||||
let listeners = new Set();
|
||||
let settleTimer = null;
|
||||
let focusToken = null;
|
||||
let focusWaiting = false;
|
||||
// sessionKey -> { lease, receiptAt } — the one direction-aware prefetch.
|
||||
let prefetched = new Map();
|
||||
|
||||
const emit = () => {
|
||||
for (const l of listeners) l(state, { focusWaiting });
|
||||
};
|
||||
const dispatch = (action) => {
|
||||
if (!state) return;
|
||||
const before = state;
|
||||
state = reduce(state, action);
|
||||
if (state !== before) {
|
||||
afterChange(before);
|
||||
emit();
|
||||
}
|
||||
};
|
||||
const transport = transportFactory(dispatch);
|
||||
|
||||
// ------------------------------------------------------ media pipeline --
|
||||
|
||||
function afterChange(before) {
|
||||
if (!state.manifest) return;
|
||||
const generationMoved = state.requestGeneration !== before.requestGeneration;
|
||||
if (generationMoved && state.phase === PHASE.RESOLVING_MEDIA) {
|
||||
scheduleSettle();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleSettle() {
|
||||
if (settleTimer != null) scheduler.clear(settleTimer);
|
||||
const sessionId = state.sessionId;
|
||||
const generation = state.requestGeneration;
|
||||
// Stop the outgoing clip immediately — silence between cards is
|
||||
// correct; audio under the wrong artwork never is.
|
||||
void transport.stop();
|
||||
settleTimer = scheduler.set(() => {
|
||||
settleTimer = null;
|
||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||
void runPipeline(sessionId, generation);
|
||||
}, settleMs);
|
||||
}
|
||||
|
||||
async function runPipeline(sessionId, generation) {
|
||||
const fresh = () =>
|
||||
state && state.sessionId === sessionId && state.requestGeneration === generation;
|
||||
if (!fresh() || state.activeItemKey == null) return;
|
||||
const item = state.manifest.items[state.activeManifestIndex];
|
||||
const source = item.sources[0];
|
||||
if (!source) {
|
||||
dispatch({ type: "ITEM_FAILED", sessionId, generation, sessionKey: item.sessionKey, errorClass: "no_source" });
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "RESOLVE_STARTED", sessionId, generation, sessionKey: item.sessionKey });
|
||||
|
||||
// Prefetched lease still inside its safe window → skip the resolve.
|
||||
let lease = null;
|
||||
const pre = prefetched.get(item.sessionKey);
|
||||
if (pre && monotonicNow() - pre.receiptAt < pre.lease.safeForMs * 0.8) {
|
||||
lease = pre.lease;
|
||||
}
|
||||
prefetched.delete(item.sessionKey);
|
||||
|
||||
if (!lease) {
|
||||
let result = await resolver.resolve(source, { generation });
|
||||
if (!fresh()) return;
|
||||
if (!result.ok && result.retryable) {
|
||||
// The capped retry: exactly one cache-bypass re-mint per
|
||||
// user intent, per the resolver contract. Never a loop.
|
||||
result = await resolver.resolve(source, { generation, bypassCache: true });
|
||||
if (!fresh()) return;
|
||||
}
|
||||
if (!result.ok) {
|
||||
dispatch({
|
||||
type: "ITEM_FAILED",
|
||||
sessionId,
|
||||
generation,
|
||||
sessionKey: item.sessionKey,
|
||||
errorClass: result.failure,
|
||||
availability: result.failure === "not_provided" ? "not_provided" : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
lease = result.lease;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: "LEASE_READY",
|
||||
sessionId,
|
||||
generation,
|
||||
sessionKey: item.sessionKey,
|
||||
clipDurationMs: lease.clipDurationMs,
|
||||
});
|
||||
if (!fresh()) return;
|
||||
|
||||
const audible = state.desiredPlaying && focusToken != null && !state.muted;
|
||||
await transport.load(lease, { sessionId, generation, sessionKey: item.sessionKey }, { startPaused: !audible });
|
||||
if (!fresh()) {
|
||||
void transport.stop();
|
||||
return;
|
||||
}
|
||||
if (audible) await transport.play();
|
||||
|
||||
prefetchNeighbor(sessionId, generation);
|
||||
}
|
||||
|
||||
/** Resolve the NEXT playable item's lease in the background so stepping
|
||||
* forward usually skips the network entirely. One item, forward only —
|
||||
* the bounded mechanism the plan allows without byte prefetch. */
|
||||
function prefetchNeighbor(sessionId, generation) {
|
||||
if (!state || state.sessionId !== sessionId || state.requestGeneration !== generation) return;
|
||||
const playable = playableManifestIndices(state);
|
||||
const cur = playableCursor(state);
|
||||
if (cur == null || cur + 1 >= playable.length) return;
|
||||
const next = state.manifest.items[playable[cur + 1]];
|
||||
if (prefetched.has(next.sessionKey) || !next.sources[0]) return;
|
||||
void resolver
|
||||
.resolve(next.sources[0], { generation })
|
||||
.then((r) => {
|
||||
if (r.ok && state && state.sessionId === sessionId) {
|
||||
prefetched.set(next.sessionKey, { lease: r.lease, receiptAt: monotonicNow() });
|
||||
// bounded: keep at most one prefetched lease around
|
||||
if (prefetched.size > 1) {
|
||||
const oldest = prefetched.keys().next().value;
|
||||
if (oldest !== next.sessionKey) prefetched.delete(oldest);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- focus --
|
||||
|
||||
async function ensureFocus() {
|
||||
if (focusToken) return true;
|
||||
focusWaiting = true;
|
||||
emit();
|
||||
try {
|
||||
const { granted, token } = await focus.coordinator.acquirePreview();
|
||||
focusToken = granted ? token : null;
|
||||
return granted;
|
||||
} catch {
|
||||
focusToken = null;
|
||||
return false;
|
||||
} finally {
|
||||
focusWaiting = false;
|
||||
emit();
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- public ----
|
||||
|
||||
return {
|
||||
subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
if (state) fn(state, { focusWaiting });
|
||||
return () => listeners.delete(fn);
|
||||
},
|
||||
getState: () => state,
|
||||
isFocusWaiting: () => focusWaiting,
|
||||
|
||||
/** Open a session: focus first (take-audio-on-open), then manifest.
|
||||
* If the acknowledgement fails, the session still opens — parked,
|
||||
* with the transient wait surfaced — and Play retries acquisition. */
|
||||
async open({ sessionId, manifest, startAtManifestIndex }) {
|
||||
state = createInitialState(sessionId);
|
||||
emit();
|
||||
const granted = await ensureFocus();
|
||||
dispatch({ type: "MANIFEST_READY", sessionId, manifest, startAtManifestIndex });
|
||||
if (!granted && state.phase !== PHASE.EMPTY) {
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId, value: false });
|
||||
}
|
||||
},
|
||||
|
||||
step: (dir) => dispatch({ type: "STEP", sessionId: state?.sessionId, dir }),
|
||||
activateKey: (sessionKey) => dispatch({ type: "ACTIVATE_KEY", sessionId: state?.sessionId, sessionKey }),
|
||||
replay: () => dispatch({ type: "REPLAY", sessionId: state?.sessionId }),
|
||||
seek: (positionMs) => {
|
||||
if (!state) return;
|
||||
void transport.seek?.(positionMs);
|
||||
dispatch({
|
||||
type: "MEDIA_POSITION",
|
||||
sessionId: state.sessionId,
|
||||
generation: state.requestGeneration,
|
||||
positionMs,
|
||||
});
|
||||
},
|
||||
setMuted(value) {
|
||||
if (!state) return;
|
||||
dispatch({ type: "SET_MUTED", sessionId: state.sessionId, value });
|
||||
if (value && state.phase === PHASE.PLAYING) void transport.pause();
|
||||
},
|
||||
|
||||
async togglePlay() {
|
||||
if (!state) return;
|
||||
const sid = state.sessionId;
|
||||
if (state.phase === PHASE.PLAYING) {
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: false });
|
||||
await transport.pause();
|
||||
return;
|
||||
}
|
||||
if (state.phase === PHASE.ENDED) {
|
||||
dispatch({ type: "REPLAY", sessionId: sid });
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: sid, value: true });
|
||||
const granted = await ensureFocus();
|
||||
if (!granted) return; // parked; wait state already surfaced
|
||||
if (state.phase === PHASE.READY || state.phase === PHASE.PAUSED) {
|
||||
await transport.play();
|
||||
}
|
||||
// RESOLVING/BUFFERING: the pipeline observes desiredPlaying when
|
||||
// the lease lands; nothing else to do here.
|
||||
},
|
||||
|
||||
/** Play full track: hand ownership to ordinary playback. The old
|
||||
* main snapshot is intentionally discarded (never restored). */
|
||||
async playFullTrack(startMainPlayback) {
|
||||
if (!state) return;
|
||||
await transport.stop();
|
||||
focus.coordinator.handoffToMain();
|
||||
focusToken = null;
|
||||
dispatch({ type: "SET_DESIRED_PLAYING", sessionId: state.sessionId, value: false });
|
||||
await startMainPlayback(state.manifest.items[state.activeManifestIndex]);
|
||||
},
|
||||
|
||||
/** Close: terminal for the session; transport torn down; main
|
||||
* restored only if we paused it and nothing changed since. */
|
||||
async close() {
|
||||
if (!state) return { restored: false };
|
||||
if (settleTimer != null) {
|
||||
scheduler.clear(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
dispatch({ type: "CLOSE", sessionId: state.sessionId });
|
||||
prefetched.clear();
|
||||
await transport.stop();
|
||||
await transport.dispose();
|
||||
const token = focusToken;
|
||||
focusToken = null;
|
||||
const restored = token ? focus.coordinator.release(token) : { restored: false };
|
||||
return restored;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user