diff --git a/src/App.svelte b/src/App.svelte
index 60324d5..e6c36cb 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -5,6 +5,8 @@
import NowPlaying from "./components/NowPlaying.svelte";
import ArtworkViewer from "./components/ArtworkViewer.svelte";
import LoginModal from "./components/LoginModal.svelte";
+ import PreviewOverlay from "./components/PreviewOverlay.svelte";
+ import { previewUi, openPreviewFixture } from "./lib/preview/previewSession.svelte.js";
import Home from "./views/Home.svelte";
import Search from "./views/Search.svelte";
import Library from "./views/Library.svelte";
@@ -40,16 +42,38 @@
autoConnect();
refreshLibrary();
refreshPlaylists();
+
+ // Dev-only hook for the Phase 2 exit-gate check (500-item fixture in a
+ // plain browser) and for driving the modal without a Tauri backend.
+ if (import.meta.env.DEV && typeof window !== "undefined") {
+ window.__previewFixture = (n = 12) => openPreviewFixture(n);
+ window.__previewUi = previewUi; // debug: the app's own store instance
+ window.__ui = ui; // debug: known-good reactive store, same instance
+ window.__togglePv = () => (previewUi.open = !previewUi.open);
+ window.__toggleNp = () => (ui.nowPlaying = !ui.nowPlaying);
+ }
-
+
+
-
+
-
+ {#if !previewUi.open}
+
+ {/if}
+
@@ -57,6 +81,15 @@
diff --git a/src/lib/api.js b/src/lib/api.js
index d09ba64..02a0c90 100644
--- a/src/lib/api.js
+++ b/src/lib/api.js
@@ -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
- 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
+ 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).
diff --git a/src/lib/color.js b/src/lib/color.js
index a3cbc9f..42f6dc7 100644
--- a/src/lib/color.js
+++ b/src/lib/color.js
@@ -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();
diff --git a/src/lib/preview/previewSession.svelte.js b/src/lib/preview/previewSession.svelte.js
new file mode 100644
index 0000000..eb4c3b6
--- /dev/null
+++ b/src/lib/preview/previewSession.svelte.js
@@ -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),
+};
diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js
index a33eb47..b59d574 100644
--- a/src/lib/state.svelte.js
+++ b/src/lib/state.svelte.js
@@ -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,
diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte
index 9697e6f..5a81919 100644
--- a/src/views/CatalogArtist.svelte
+++ b/src/views/CatalogArtist.svelte
@@ -33,6 +33,8 @@
} from "../lib/state.svelte.js";
import * as api from "../lib/api.js";
import { accentFromImage } from "../lib/color.js";
+ import { buildArtistManifest } from "../lib/preview/manifest.js";
+ import { openPreview } from "../lib/preview/previewSession.svelte.js";
import { traceToSvg, svgDataUrl } from "../lib/trace.js";
import Icon from "../components/Icon.svelte";
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
@@ -537,8 +539,6 @@
// state cannot occur here: the whole action row is gated on
// popularTracks.length, so by the time this control exists the tracks (and
// therefore their preview fields) have landed.
- // TODO(previews): openPreviews() opens the scrollable preview modal —
- // TODO-Feature-Docs/scrollable-track-previews. Placement-only for now.
const previewableCount = $derived(allPopular.filter((track) => track.preview).length);
const previewState = $derived(
previewableCount === 0
@@ -554,7 +554,31 @@
? `Preview tracks — ${previewableCount} of ${allPopular.length} available`
: "Preview tracks",
);
- function openPreviews() {}
+ /** Open the preview modal over the COMPLETE ranked top-track payload —
+ * never just the rendered rows (design §13, artist adapter). The manifest
+ * is a snapshot: later staged-page rebuilds cannot rewrite an open
+ * session. Play-full hands the active item to the ordinary resolver
+ * pipeline via the same path the Popular rows use. */
+ function openPreviews() {
+ if (!page?.popular_tracks?.length) return;
+ const snapshotTracks = page.popular_tracks.slice(0, 10);
+ const byProviderId = new Map(snapshotTracks.map((t) => [String(t.id), t]));
+ const manifest = buildArtistManifest({
+ artist: {
+ id: page.artist.id,
+ name: page.artist.name,
+ picture: page.artist.picture ?? null,
+ },
+ popularTracks: snapshotTracks,
+ createdAtMs: Date.now(),
+ });
+ void openPreview(manifest, {
+ playFull: (item) => {
+ const track = byProviderId.get(item.sources[0]?.providerTrackId);
+ if (track) playPopular(track);
+ },
+ });
+ }
// Instant-shell seed: the search portrait the user clicked, shown as a
// heavily-blurred colour field behind the hero until the real photo