forked from Keelrleek/SoulStream
Preview modal audio, five rounds: - True track-change crossfade: scheduleSettle no longer silences the outgoing clip at t=0; preview_load's equal-power fade owns both ramps, gated on the incoming voice's first Playing event (audible_marks), with failure/overstay fallbacks. CROSSFADE_MS = 340 — locked to the reel's card-slide so audio and visuals land together. - Two-neighbour lease prefetch + a 15-min on-disk clip byte-cache (recent_clips): steps in BOTH directions crossfade in ~15 ms; revisits never re-download. - Adaptive settle (isolated steps fire immediately, bursts coalesce) and anticipatory auto-advance (step at clip end minus 400 ms) so auto-play crossfades over the tail instead of gapping. - Session resume memory: revisited tracks continue where they left off (decoder-level start offset via play_local_from/resume_at); finished tracks restart from the top. - Open/close crossfades with the MAIN player: fade-pause with the desired-paused latch landing at ramp end, fade-resume from the paused position; MAIN_FADE_GEN guards; focus handshake relaxation documented in focusBridge. Volume normalization (Settings > Playback, default on): - Dependency-free BS.1770/EBU R128 meter in the player crate (K-weighting, 400 ms gated blocks, incremental) + analyzer that reads the same growing file playback streams from — first plays are measured at download speed. - Gain layered under user volume; audible start GATED on a preliminary measurement (10 s of audio or 800 ms budget) so the level is right from the first sample and never changes mid-track; prelim boosts capped 3 dB, peak-aware clipping guard, persistent loudness-cache.json. - Gapless boundaries get their own sample-accurate gain via SetNextNormGainDb, fed by preload analysis. Debug overlay (Ctrl+Shift+D): process-tree CPU / PDH GPU / working-set + private-commit RAM / network rates / FPS / current-track download progress read from the backend's own StreamProgress / live normalization state / preview session phase. Polls only while visible. 131 JS + player + app tests green; every path verified live over CDP.
180 lines
6.7 KiB
JavaScript
180 lines
6.7 KiB
JavaScript
// Svelte-side preview session: one store, one service instance, and the
|
|
// production wiring (resolver → Tauri, transport → native player, focus →
|
|
// main player). state.svelte.js hands us its player accessors at init
|
|
// (dependency injection, deliberately: this module must not import the
|
|
// 4000-line state module, and the state module needs to import US for
|
|
// gateway bumps — injection breaks what would otherwise be a cycle).
|
|
|
|
import { createPreviewService } from "./service.js";
|
|
import { createProductionResolver } from "./productionResolver.js";
|
|
import { createNativeTransport } from "./nativeTransport.js";
|
|
import { createProductionFocus } from "./focusBridge.js";
|
|
import { createFixtureResolver } from "./resolver.js";
|
|
import { createFixtureTransport } from "./transport.js";
|
|
import { buildFixtureManifest } from "./manifest.js";
|
|
import { AVAILABILITY } from "./contracts.js";
|
|
|
|
/** Reactive mirror of the session for components. `snapshot` is the frozen
|
|
* reducer state; everything else is presentation-level. */
|
|
export const previewUi = $state({
|
|
open: false,
|
|
snapshot: null,
|
|
focusWaiting: false,
|
|
/** element to restore focus to when the modal closes */
|
|
returnFocusTo: null,
|
|
/** page-supplied "play this item as a full track" starter */
|
|
playFull: null,
|
|
});
|
|
|
|
let service = null;
|
|
let unsubscribe = null;
|
|
let focusParts = null; // { coordinator, gateway }
|
|
let demoTicker = null;
|
|
|
|
// ------------------------------------------------------------- gateway ----
|
|
|
|
/** Bump the main-player revision gateway from state.svelte.js. A no-op
|
|
* until the bridge is initialised, and a no-op for unknown kinds — the
|
|
* caller sites stay one-liners. */
|
|
export function bumpMainRevision(kind, opts) {
|
|
const g = focusParts?.gateway;
|
|
if (g && typeof g[kind] === "function") g[kind](opts);
|
|
}
|
|
|
|
let playbackGeneration = 0;
|
|
/** Called by state.svelte.js on every main "buffering" — each new decode is
|
|
* a new renderer-side playback generation (the focusBridge invariant). */
|
|
export function notePlaybackGeneration() {
|
|
playbackGeneration += 1;
|
|
}
|
|
|
|
/** One-time wiring from state.svelte.js. */
|
|
export function initPreviewBridge({ readPlayer, resumeMain }) {
|
|
focusParts = createProductionFocus({
|
|
readPlayer,
|
|
readPlaybackGeneration: () => playbackGeneration,
|
|
resumeMain,
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------- session ---
|
|
|
|
function attach(svc) {
|
|
service = svc;
|
|
unsubscribe = svc.subscribe((snapshot, extras) => {
|
|
previewUi.snapshot = snapshot;
|
|
previewUi.focusWaiting = extras.focusWaiting;
|
|
});
|
|
}
|
|
|
|
/** Open a preview session over the production stack. `manifest` comes from
|
|
* a page adapter (artist page in Phase 3). */
|
|
export async function openPreview(manifest, { playFull } = {}) {
|
|
if (!focusParts) throw new Error("preview bridge not initialised");
|
|
await closePreview(); // one session at a time, always torn down first
|
|
previewUi.returnFocusTo = document.activeElement;
|
|
previewUi.playFull = playFull ?? null;
|
|
attach(
|
|
createPreviewService({
|
|
resolver: createProductionResolver(),
|
|
transportFactory: (dispatch) => createNativeTransport(dispatch),
|
|
focus: focusParts,
|
|
})
|
|
);
|
|
previewUi.open = true;
|
|
await service.open({ sessionId: crypto.randomUUID(), manifest });
|
|
}
|
|
|
|
/**
|
|
* Dev/browser fixture session: deterministic resolver + a fixture transport
|
|
* driven by a wall-clock ticker, no Tauri and no audio. This is what the
|
|
* Phase 2 exit gate's 500-item responsiveness check runs against, and what
|
|
* a plain `vite` browser shows.
|
|
*/
|
|
export async function openPreviewFixture(count = 12) {
|
|
await closePreview();
|
|
previewUi.returnFocusTo = document.activeElement;
|
|
const rows = Array.from({ length: count }, (_, i) => ({
|
|
providerTrackId: String(i + 1),
|
|
title: `Fixture track ${i + 1}`,
|
|
artists: ["Fixture Artist"],
|
|
album: `Fixture Album ${1 + (i % 7)}`,
|
|
fullDurationMs: 180_000 + (i % 9) * 21_000,
|
|
initialAvailability: i % 11 === 5 ? AVAILABILITY.NOT_PROVIDED : AVAILABILITY.AVAILABLE,
|
|
}));
|
|
const manifest = buildFixtureManifest({
|
|
kind: "collection",
|
|
contextKey: `fixture:${count}`,
|
|
title: `Fixture · ${count} tracks`,
|
|
rows,
|
|
createdAtMs: Date.now(),
|
|
});
|
|
const plan = {};
|
|
for (let i = 1; i <= count; i++) plan[i] = { opaqueLeaseId: `fx-${i}` };
|
|
|
|
let transport = null;
|
|
const focus = {
|
|
coordinator: {
|
|
async acquirePreview() {
|
|
return { granted: true, token: { id: "fixture", pausedByPreview: false } };
|
|
},
|
|
release: () => ({ restored: false, reason: "fixture" }),
|
|
handoffToMain: () => ({ restored: false, reason: "fixture" }),
|
|
},
|
|
};
|
|
attach(
|
|
createPreviewService({
|
|
resolver: createFixtureResolver(plan),
|
|
transportFactory: (dispatch) => {
|
|
transport = createFixtureTransport(dispatch);
|
|
return transport;
|
|
},
|
|
focus,
|
|
})
|
|
);
|
|
// Drive the fixture transport at ~10 fps so progress/auto-advance behave
|
|
// like real playback in the browser.
|
|
demoTicker = setInterval(() => transport?.tick(100), 100);
|
|
previewUi.open = true;
|
|
await service.open({ sessionId: crypto.randomUUID(), manifest });
|
|
}
|
|
|
|
export async function closePreview() {
|
|
if (demoTicker) {
|
|
clearInterval(demoTicker);
|
|
demoTicker = null;
|
|
}
|
|
if (!service) return { restored: false };
|
|
// Detach first (re-entrancy: a second Escape lands on the early return),
|
|
// and hide the modal BEFORE awaiting the close — service.close() now
|
|
// holds the line for the close-crossfade tail (~CROSSFADE_MS), and the
|
|
// visual out-transition must not wait on audio. The snapshot stays until
|
|
// the close resolves so the dialog has content through its exit.
|
|
const svc = service;
|
|
service = null;
|
|
previewUi.open = false;
|
|
const back = previewUi.returnFocusTo;
|
|
previewUi.returnFocusTo = null;
|
|
if (back && typeof back.focus === "function" && back.isConnected) {
|
|
back.focus();
|
|
}
|
|
const out = await svc.close();
|
|
unsubscribe?.();
|
|
unsubscribe = null;
|
|
previewUi.snapshot = null;
|
|
previewUi.focusWaiting = false;
|
|
previewUi.playFull = null;
|
|
return out;
|
|
}
|
|
|
|
// Thin command pass-throughs for the overlay component.
|
|
export const previewCommands = {
|
|
step: (dir) => service?.step(dir),
|
|
activateKey: (k) => service?.activateKey(k),
|
|
togglePlay: () => service?.togglePlay(),
|
|
replay: () => service?.replay(),
|
|
seek: (ms) => service?.seek(ms),
|
|
setMuted: (v) => service?.setMuted(v),
|
|
playFullTrack: (starter) => service?.playFullTrack(starter),
|
|
};
|