Files
SoulStream/src/lib/preview/previewSession.svelte.js
T
KeelrLeek 691fdcdc9a fix: previews follow the app volume; volume bar fill always accent
The preview backend never received a volume — clips played at the crate
default (0.7) regardless of the slider, even muted. The session now sets
the preview volume from the app store (mute → 0) before the first load and
mirrors it live while the modal is open; the debug overlay's PREVIEW row
exposes the backend's resting volume as proof.

The player-bar volume (and fallback seek) fill is now always the accent
color — matching the waveform scrubber's played region — stepping up to
the hover shade on hover instead of white-until-hover.

Also syncs package-lock to the soulstream name + alpha version.
2026-07-23 16:25:42 -05:00

188 lines
7.1 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;
let readPlayerRef = null; // injected with the bridge; app volume source
// ------------------------------------------------------------- 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 }) {
readPlayerRef = readPlayer;
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,
})
);
// Preview volume follows the APP volume (mute = 0) — set it BEFORE the
// first load so the opening clip can't blast at the backend default.
// The overlay keeps it mirrored live while the modal is open.
const p = readPlayerRef?.();
if (p) service.setVolume(p.muted ? 0 : (p.volume ?? 0.7));
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),
setVolume: (v) => service?.setVolume(v),
};