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