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

+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,