forked from Keelrleek/SoulStream
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:
1 parent
8cb3cd593b
commit
d051f0db28
7 files changed
+1492
-16
No files matched your search
+36
-3
@@ -5,6 +5,8 @@
|
|||||||
import NowPlaying from "./components/NowPlaying.svelte";
|
import NowPlaying from "./components/NowPlaying.svelte";
|
||||||
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
import ArtworkViewer from "./components/ArtworkViewer.svelte";
|
||||||
import LoginModal from "./components/LoginModal.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 Home from "./views/Home.svelte";
|
||||||
import Search from "./views/Search.svelte";
|
import Search from "./views/Search.svelte";
|
||||||
import Library from "./views/Library.svelte";
|
import Library from "./views/Library.svelte";
|
||||||
@@ -40,16 +42,38 @@
|
|||||||
autoConnect();
|
autoConnect();
|
||||||
refreshLibrary();
|
refreshLibrary();
|
||||||
refreshPlaylists();
|
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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="app" class:np-open={ui.nowPlaying} class:sidebar-collapsed={ui.sidebarCollapsed}>
|
<div
|
||||||
|
class="app"
|
||||||
|
class:np-open={ui.nowPlaying}
|
||||||
|
class:sidebar-collapsed={ui.sidebarCollapsed}
|
||||||
|
class:preview-open={previewUi.open}
|
||||||
|
>
|
||||||
<TitleBar />
|
<TitleBar />
|
||||||
|
<!-- Previews take over the whole window (decision 2026-07-22): while the
|
||||||
|
modal is open the page and the player bar are inert/not rendered —
|
||||||
|
there is deliberately NO UI path to command main audio behind the
|
||||||
|
focus coordinator's back. The titlebar stays live for window chrome. -->
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="content" use:floatScroll>
|
<main class="content" use:floatScroll inert={previewUi.open || undefined}>
|
||||||
<CurrentView />
|
<CurrentView />
|
||||||
</main>
|
</main>
|
||||||
<NowPlaying />
|
<NowPlaying />
|
||||||
<PlayerBar />
|
{#if !previewUi.open}
|
||||||
|
<PlayerBar />
|
||||||
|
{/if}
|
||||||
|
<PreviewOverlay />
|
||||||
<ArtworkViewer />
|
<ArtworkViewer />
|
||||||
<LoginModal />
|
<LoginModal />
|
||||||
</div>
|
</div>
|
||||||
@@ -57,6 +81,15 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Frameless window: the titlebar row is part of the app background —
|
/* Frameless window: the titlebar row is part of the app background —
|
||||||
no separate chrome color, the panels float beneath it. */
|
no separate chrome color, the panels float beneath it. */
|
||||||
|
/* While the preview modal is open: keyboard is contained by the dialog's
|
||||||
|
focus trap and aria-modal, the content pane is `inert`, and the flanking
|
||||||
|
panels stop taking pointer input here. (The player bar is simply not
|
||||||
|
rendered — see the markup note above.) */
|
||||||
|
.app.preview-open :global(.sidebar),
|
||||||
|
.app.preview-open :global(.nowplaying) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width);
|
grid-template-columns: var(--sidebar-width) 1fr var(--nowplaying-width);
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
+27
-10
@@ -146,18 +146,35 @@ export const previewDispose = () => invoke("preview_dispose");
|
|||||||
export const playerPauseAcked = () => invoke("player_pause_acked");
|
export const playerPauseAcked = () => invoke("player_pause_acked");
|
||||||
|
|
||||||
// Window chrome (the app draws its own titlebar; decorations are off).
|
// 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";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
export const win = {
|
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||||
minimize: () => getCurrentWindow().minimize(),
|
const noWin = {
|
||||||
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
|
minimize: () => {},
|
||||||
close: () => getCurrentWindow().close(),
|
toggleMaximize: () => {},
|
||||||
isMinimized: () => getCurrentWindow().isMinimized(),
|
close: () => {},
|
||||||
isFocused: () => getCurrentWindow().isFocused(),
|
isMinimized: async () => false,
|
||||||
isMaximized: () => getCurrentWindow().isMaximized(),
|
isFocused: async () => true,
|
||||||
// returns Promise<unlisten>
|
isMaximized: async () => false,
|
||||||
onResized: (handler) => getCurrentWindow().onResized(handler),
|
onResized: async () => () => {},
|
||||||
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
|
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.
|
// Subscribe to a backend event; returns an unlisten function.
|
||||||
// No-ops outside Tauri (e.g. plain-browser preview).
|
// No-ops outside Tauri (e.g. plain-browser preview).
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ function hslToRgb(h, s, l) {
|
|||||||
*/
|
*/
|
||||||
export async function accentFromImage(dataUrl) {
|
export async function accentFromImage(dataUrl) {
|
||||||
const img = new Image();
|
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;
|
img.src = dataUrl;
|
||||||
try {
|
try {
|
||||||
await img.decode();
|
await img.decode();
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
// Shared reactive app state (Svelte 5 runes module).
|
// Shared reactive app state (Svelte 5 runes module).
|
||||||
import * as api from "./api.js";
|
import * as api from "./api.js";
|
||||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||||
|
import {
|
||||||
|
initPreviewBridge,
|
||||||
|
bumpMainRevision,
|
||||||
|
notePlaybackGeneration,
|
||||||
|
} from "./preview/previewSession.svelte.js";
|
||||||
|
|
||||||
export const app = $state({
|
export const app = $state({
|
||||||
connection: "offline", // offline | connecting | online
|
connection: "offline", // offline | connecting | online
|
||||||
@@ -3554,6 +3559,17 @@ export function initEvents() {
|
|||||||
if (started) return;
|
if (started) return;
|
||||||
started = true;
|
started = true;
|
||||||
initWindowActivity();
|
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
|
// The `connected` event fires once, at login. A WebView reload (dev
|
||||||
// iteration, crash recovery) lands AFTER it, so the fresh frontend
|
// iteration, crash recovery) lands AFTER it, so the fresh frontend
|
||||||
@@ -3620,6 +3636,19 @@ export function initEvents() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
api.onEvent("soulseek:player", (ev) => {
|
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) {
|
switch (ev.kind) {
|
||||||
case "buffering":
|
case "buffering":
|
||||||
player.status = "buffering";
|
player.status = "buffering";
|
||||||
@@ -3835,6 +3864,7 @@ export async function streamFile(username, file, subLabel) {
|
|||||||
|
|
||||||
/** Play a finished download from disk (single track). */
|
/** Play a finished download from disk (single track). */
|
||||||
export async function playLocalFile(path, title, subLabel, artPath = null) {
|
export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||||||
|
bumpMainRevision("userPlay");
|
||||||
autoAlbum = null;
|
autoAlbum = null;
|
||||||
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
|
player.queue = [{ path, title, sub: subLabel ?? "", duration: null, art: artPath, quality: localQuality({ path }) }];
|
||||||
player.queueIndex = 0;
|
player.queueIndex = 0;
|
||||||
@@ -3867,6 +3897,10 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
|||||||
function playQueueIndex(i) {
|
function playQueueIndex(i) {
|
||||||
const item = player.queue[i];
|
const item = player.queue[i];
|
||||||
if (!item) return;
|
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.queueIndex = i;
|
||||||
player.track = {
|
player.track = {
|
||||||
title: item.title,
|
title: item.title,
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
} from "../lib/state.svelte.js";
|
} from "../lib/state.svelte.js";
|
||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { accentFromImage } from "../lib/color.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 { traceToSvg, svgDataUrl } from "../lib/trace.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
import HeroGpuCanvas from "../components/HeroGpuCanvas.svelte";
|
||||||
@@ -537,8 +539,6 @@
|
|||||||
// state cannot occur here: the whole action row is gated on
|
// state cannot occur here: the whole action row is gated on
|
||||||
// popularTracks.length, so by the time this control exists the tracks (and
|
// popularTracks.length, so by the time this control exists the tracks (and
|
||||||
// therefore their preview fields) have landed.
|
// 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 previewableCount = $derived(allPopular.filter((track) => track.preview).length);
|
||||||
const previewState = $derived(
|
const previewState = $derived(
|
||||||
previewableCount === 0
|
previewableCount === 0
|
||||||
@@ -554,7 +554,31 @@
|
|||||||
? `Preview tracks — ${previewableCount} of ${allPopular.length} available`
|
? `Preview tracks — ${previewableCount} of ${allPopular.length} available`
|
||||||
: "Preview tracks",
|
: "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
|
// Instant-shell seed: the search portrait the user clicked, shown as a
|
||||||
// heavily-blurred colour field behind the hero until the real photo
|
// heavily-blurred colour field behind the hero until the real photo
|
||||||
|
|||||||
Reference in new issue
Block a user