// 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), };