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), }); }