Phase 2C WIP: PreviewOverlay modal + root integration (reel centering bug)

Working:
- PreviewOverlay.svelte mounted at app root; opens over a fixture manifest
  (window.__previewFixture(n) in dev), inert background, player bar hidden,
  focus trap, keyboard nav, F3 rail, T3 swaps, R1b capsule, accent extraction.
- previewSession.svelte.js: store + service wiring (production + fixture).
- Root integration in App.svelte; revision-gateway bumps + notePlaybackGeneration
  threaded through state.svelte.js player entry points; api.win guarded so plain
  vite browser mounts.
- Launcher on the artist page builds a real artist manifest and opens the modal
  with a play-full starter.
- 70 JS tests still green.

Two bugs found and fixed during bring-up:
- effect_update_depth_exceeded: bleed-crossfade and bump effects read AND wrote
  the same $state; wrapped writes in untrack(). This loop was breaking Svelte's
  scheduler globally (even ui.* stopped reacting).
- accentFromImage now sets crossOrigin for remote covers.

KNOWN BROKEN (handoff): reel vertical centering. The active card stays centred
at index 0 but drifts by one stride per step. Root cause isolated — see
TODO-Feature-Docs/scrollable-track-previews/HANDOFF-PHASE2C.md.
This commit is contained in:
KeelrLeek committed 2026-07-22 19:26:41 -05:00
1 parent 8cb3cd593b
commit d051f0db28
7 files changed
+1492 -16

No files matched your search

+27 -10
View File
@@ -146,18 +146,35 @@ export const previewDispose = () => invoke("preview_dispose");
export const playerPauseAcked = () => invoke("player_pause_acked");
// Window chrome (the app draws its own titlebar; decorations are off).
// Guarded like onEvent below: getCurrentWindow() THROWS outside Tauri, and
// it runs at mount via initWindowActivity — un-guarded it made every plain
// `vite` browser load a blank page, which also blocked browser-side preview
// fixtures. In-Tauri behavior is unchanged.
import { getCurrentWindow } from "@tauri-apps/api/window";
export const win = {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
isMinimized: () => getCurrentWindow().isMinimized(),
isFocused: () => getCurrentWindow().isFocused(),
isMaximized: () => getCurrentWindow().isMaximized(),
// returns Promise<unlisten>
onResized: (handler) => getCurrentWindow().onResized(handler),
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
const noWin = {
minimize: () => {},
toggleMaximize: () => {},
close: () => {},
isMinimized: async () => false,
isFocused: async () => true,
isMaximized: async () => false,
onResized: async () => () => {},
onFocusChanged: async () => () => {},
};
export const win = !inTauri
? noWin
: {
minimize: () => getCurrentWindow().minimize(),
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
close: () => getCurrentWindow().close(),
isMinimized: () => getCurrentWindow().isMinimized(),
isFocused: () => getCurrentWindow().isFocused(),
isMaximized: () => getCurrentWindow().isMaximized(),
// returns Promise<unlisten>
onResized: (handler) => getCurrentWindow().onResized(handler),
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
};
// Subscribe to a backend event; returns an unlisten function.
// No-ops outside Tauri (e.g. plain-browser preview).
+5
View File
@@ -51,6 +51,11 @@ function hslToRgb(h, s, l) {
*/
export async function accentFromImage(dataUrl) {
const img = new Image();
// Remote covers (the preview modal samples dzcdn URLs directly) need the
// CORS opt-in or getImageData throws on a tainted canvas and every such
// cover silently falls back to silver. Ignored for data: URLs, so the
// player-bar path is unchanged.
img.crossOrigin = "anonymous";
img.src = dataUrl;
try {
await img.decode();
+173
View File
@@ -0,0 +1,173 @@
// 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 };
const out = await service.close();
unsubscribe?.();
unsubscribe = null;
service = null;
previewUi.open = false;
previewUi.snapshot = null;
previewUi.focusWaiting = false;
previewUi.playFull = null;
const back = previewUi.returnFocusTo;
previewUi.returnFocusTo = null;
if (back && typeof back.focus === "function" && back.isConnected) {
back.focus();
}
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),
};
+34
View File
@@ -1,6 +1,11 @@
// Shared reactive app state (Svelte 5 runes module).
import * as api from "./api.js";
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
import {
initPreviewBridge,
bumpMainRevision,
notePlaybackGeneration,
} from "./preview/previewSession.svelte.js";
export const app = $state({
connection: "offline", // offline | connecting | online
@@ -3554,6 +3559,17 @@ export function initEvents() {
if (started) return;
started = true;
initWindowActivity();
// Preview audio-focus bridge (scrollable-track-previews). Injected here
// rather than imported there: this module owns the player store, and the
// preview module must stay import-cycle-free. resumeMain goes straight to
// api — NOT through any wrapper that bumps the revision gateway, because
// a focus-internal resume must not invalidate its own token.
initPreviewBridge({
readPlayer: () => player,
resumeMain: () => {
api.playerControl("resume");
},
});
// The `connected` event fires once, at login. A WebView reload (dev
// iteration, crash recovery) lands AFTER it, so the fresh frontend
@@ -3620,6 +3636,19 @@ export function initEvents() {
});
api.onEvent("soulseek:player", (ev) => {
// Revision-gateway bumps at the ONE choke point every native source
// change flows through. Each new decode ("buffering") is a new
// playback generation for the focus bridge; track-ended is the
// auto-advance revision. A pause caused by the focus coordinator
// emits "paused", which deliberately bumps nothing.
if (ev.kind === "buffering") {
notePlaybackGeneration();
bumpMainRevision("nativeSourceChange");
} else if (ev.kind === "track-ended") {
bumpMainRevision("autoAdvance");
} else if (ev.kind === "stopped") {
bumpMainRevision("userStop");
}
switch (ev.kind) {
case "buffering":
player.status = "buffering";
@@ -3835,6 +3864,7 @@ export async function streamFile(username, file, subLabel) {
/** Play a finished download from disk (single track). */
export async function playLocalFile(path, title, subLabel, artPath = null) {
bumpMainRevision("userPlay");
autoAlbum = null;
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
player.queueIndex = 0;
@@ -3867,6 +3897,10 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
function playQueueIndex(i) {
const item = player.queue[i];
if (!item) return;
// Every queue start is a user-command + queue + playback revision for the
// preview focus gateway (auto-advance re-enters here too — that bump is
// correct: a restore must never resurrect a track the queue moved past).
bumpMainRevision("queueJumpOrNextPrev");
player.queueIndex = i;
player.track = {
title: item.title,