From 98d11a2228185303d0fcbac58cb764dc561b8b81 Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:13:54 -0500 Subject: [PATCH] previews: the album button opens the record the previewed track came from The third action in the preview modal's control row has been a disabled stub since the modal landed -- there was nowhere for it to go until the album page existed. It goes there now. The target is the item's own provider `albumId`, never a title match, so a top-tracks preview lands on the exact record the modal's metadata line is already naming: Elephant Gym's "Light" opens Angle, and AC/DC's "Highway to Hell" the album rather than any of the compilations carrying it. The manifest has carried `albumId` and an `actions.openAlbum` flag from the start; all 20 rows of a Deezer top-track payload declare one. The navigation lands on the previewed TRACK, not just the record -- the album page's existing pending-track flash does the scrolling and the highlight, the same landing the now-playing song link already gets. Three details that are not obvious: - The overlay must not import state.svelte.js (the module comment on previewSession explains the cycle), so the navigator is injected by the page exactly as `playFull` already is. - Both ids are read out of previewUi and the snapshot BEFORE the close, not after: closePreview() nulls the callback and drops the snapshot the active item came from. And the close happens first, because the session holds the audio focus and navigating under an open dialog would land the new page behind the scrim. - contracts.js freezes every manifest id as a String, while `catalog_album` takes a u64 -- a JSON string fails to deserialize -- and the album page's flash compares track ids with ===. Both cross back to numbers in openAlbumFromPreviewItem, once, rather than at each call site. An album session is already standing on the record it would open, so the button greys out there with "You're already on this album" rather than re-navigating to the page underneath. The decision is made from the manifest's own contextKey, so it holds for any future surface that opens an album-kind session. The album page still supplies the navigator, so a row that ever declares a different album goes somewhere instead of being silently dead. Verified in the running app over CDP: artist top-tracks preview -> click -> lands on Highway to Hell (id 9410086), pendingTrackId 92719900 as a Number, matching track found on the page, .trow.flash present, modal gone, and the glass bar trail reading Search -> AC/DC -> Highway to Hell. From an album session the button is disabled and a click changes nothing. 85 JS tests pass. --- src/components/PreviewOverlay.svelte | 45 +++++++++++++++++++++++- src/lib/preview/previewSession.svelte.js | 6 +++- src/lib/state.svelte.js | 23 ++++++++++++ src/views/CatalogAlbum.svelte | 6 ++++ src/views/CatalogArtist.svelte | 2 ++ 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/components/PreviewOverlay.svelte b/src/components/PreviewOverlay.svelte index 1038800..ed931ec 100644 --- a/src/components/PreviewOverlay.svelte +++ b/src/components/PreviewOverlay.svelte @@ -436,6 +436,43 @@ await previewCommands.playFullTrack(async (item) => starter(item)); await closePreview(); } + + // "Open album" goes to the record the PREVIEWED TRACK declares, which on a + // top-tracks session is often a single or a compilation rather than the + // artist's studio album — that is the record the metadata line above is + // already naming, and the id comes from the provider rather than a title + // match, so the two can never disagree. + // + // An album session is already standing on that record, so the action is + // offered only when it would actually take you somewhere. + const albumTarget = $derived( + activeItem?.actions.openAlbum && activeItem.albumId ? activeItem.albumId : null, + ); + const atOwnAlbum = $derived( + albumTarget != null && manifest?.context.contextKey === `album:${albumTarget}`, + ); + const canOpenAlbum = $derived( + Boolean(previewUi.openAlbum) && albumTarget != null && !atOwnAlbum, + ); + const openAlbumTitle = $derived( + atOwnAlbum + ? "You're already on this album" + : albumTarget == null + ? "No album to open for this track" + : "Open album", + ); + + async function onOpenAlbum() { + if (!canOpenAlbum) return; + // Both are read BEFORE the close, which nulls the callback and drops + // the snapshot the active item came from. + const go = previewUi.openAlbum; + const item = activeItem; + // Close first: the session holds the audio focus, and navigating under + // an open dialog would land the new page behind the scrim. + await closePreview(); + go(item); + } @@ -570,7 +607,13 @@ - diff --git a/src/lib/preview/previewSession.svelte.js b/src/lib/preview/previewSession.svelte.js index 725d694..8836f47 100644 --- a/src/lib/preview/previewSession.svelte.js +++ b/src/lib/preview/previewSession.svelte.js @@ -24,6 +24,8 @@ export const previewUi = $state({ returnFocusTo: null, /** page-supplied "play this item as a full track" starter */ playFull: null, + /** page-supplied "take me to this item's album" navigator */ + openAlbum: null, }); let service = null; @@ -71,11 +73,12 @@ function attach(svc) { /** 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 } = {}) { +export async function openPreview(manifest, { playFull, openAlbum } = {}) { 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; + previewUi.openAlbum = openAlbum ?? null; attach( createPreviewService({ resolver: createProductionResolver(), @@ -171,6 +174,7 @@ export async function closePreview() { previewUi.snapshot = null; previewUi.focusWaiting = false; previewUi.playFull = null; + previewUi.openAlbum = null; return out; } diff --git a/src/lib/state.svelte.js b/src/lib/state.svelte.js index 30032d9..8127dea 100644 --- a/src/lib/state.svelte.js +++ b/src/lib/state.svelte.js @@ -2131,6 +2131,29 @@ export async function openCatalogAlbum(albumId, opts = {}) { } } +/** + * Open the album a preview-modal item declares, landing on the track that was + * being previewed. + * + * The record comes from the item's own provider `albumId` — never a title + * match — so a top-tracks preview lands on the single or compilation the + * modal's metadata line was already naming, not on a same-titled record. + * + * The conversion is the reason this is one function rather than a lambda at + * each call site: `contracts.js` freezes every manifest id as a STRING, while + * `catalog_album` takes a `u64` (a JSON string fails to deserialize) and the + * album page's flash compares track ids with `===`. Both cross back to numbers + * here, once. + */ +export function openAlbumFromPreviewItem(item) { + const albumId = Number(item?.albumId); + if (!Number.isFinite(albumId)) return; + const trackId = Number(item?.sources?.[0]?.providerTrackId); + return openCatalogAlbum(albumId, { + trackId: Number.isFinite(trackId) ? trackId : null, + }); +} + const catNorm = (s) => (s || "").toLowerCase().replace(/[^a-z0-9]/g, ""); /** Resolve an artist by name (cached) and open their catalog page — used by diff --git a/src/views/CatalogAlbum.svelte b/src/views/CatalogAlbum.svelte index fee83e8..c37f15f 100644 --- a/src/views/CatalogAlbum.svelte +++ b/src/views/CatalogAlbum.svelte @@ -17,6 +17,7 @@ resolveArtistSeedByName, getCatalogArtistPage, openCatalogAlbum, + openAlbumFromPreviewItem, toggleShuffle, ui, } from "../lib/state.svelte.js"; @@ -314,6 +315,11 @@ const track = byId.get(item.sources[0]?.providerTrackId); if (track) playTrack(track); }, + // Every row here belongs to the record already on screen, so the + // overlay greys this out — supplied anyway so a row that ever + // declares a DIFFERENT album (a compilation pressing) still goes + // somewhere rather than being silently dead. + openAlbum: (item) => void openAlbumFromPreviewItem(item), }); } diff --git a/src/views/CatalogArtist.svelte b/src/views/CatalogArtist.svelte index 9ae5892..6dd6264 100644 --- a/src/views/CatalogArtist.svelte +++ b/src/views/CatalogArtist.svelte @@ -29,6 +29,7 @@ tinyCover, albumDisambiguator, openCatalogAlbum, + openAlbumFromPreviewItem, } from "../lib/state.svelte.js"; import * as api from "../lib/api.js"; import { accentFromImage } from "../lib/color.js"; @@ -790,6 +791,7 @@ const track = byProviderId.get(item.sources[0]?.providerTrackId); if (track) playPopular(track); }, + openAlbum: (item) => void openAlbumFromPreviewItem(item), }); }