The third action in the preview modal's control row has been a disabled stub since the modal landed -- there was nowhere for it to go until the album page existed. It goes there now. The target is the item's own provider `albumId`, never a title match, so a top-tracks preview lands on the exact record the modal's metadata line is already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway to Hell" the album rather than any of the compilations carrying it. The manifest has carried `albumId` and an `actions.openAlbum` flag from the start; all 20 rows of a Deezer top-track payload declare one. The navigation lands on the previewed TRACK, not just the record -- the album page's existing pending-track flash does the scrolling and the highlight, the same landing the now-playing song link already gets. Three details that are not obvious: - The overlay must not import state.svelte.js (the module comment on previewSession explains the cycle), so the navigator is injected by the page exactly as `playFull` already is. - Both ids are read out of previewUi and the snapshot BEFORE the close, not after: closePreview() nulls the callback and drops the snapshot the active item came from. And the close happens first, because the session holds the audio focus and navigating under an open dialog would land the new page behind the scrim. - contracts.js freezes every manifest id as a String, while `catalog_album` takes a u64 -- a JSON string fails to deserialize -- and the album page's flash compares track ids with ===. Both cross back to numbers in openAlbumFromPreviewItem, once, rather than at each call site. An album session is already standing on the record it would open, so the button greys out there with "You're already on this album" rather than re-navigating to the page underneath. The decision is made from the manifest's own contextKey, so it holds for any future surface that opens an album-kind session. The album page still supplies the navigator, so a row that ever declares a different album goes somewhere instead of being silently dead. Verified in the running app over CDP: artist top-tracks preview -> click -> lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number, matching track found on the page, .trow.flash present, modal gone, and the glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album session the button is disabled and a click changes nothing. 85 JS tests pass.
192 lines
7.3 KiB
JavaScript
192 lines
7.3 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,
|
|
/** page-supplied "take me to this item's album" navigator */
|
|
openAlbum: 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, openAlbum } = {}) {
|
|
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;
|
|
previewUi.openAlbum = openAlbum ?? 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;
|
|
previewUi.openAlbum = 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),
|
|
};
|