/** Where you are, how you got here, and how to get back. * * Two different things live in this file, and keeping them apart is the whole * design: * * HISTORY is a sequence — every place you visited, in order. It drives the * back and forward arrows, and it does not care about structure: going from * one album to a completely unrelated one is a step like any other. * * The TRAIL is a hierarchy — the path to where you are NOW. It is derived * from the current place, never from history, which is why switching from * one edition of a record to another leaves the trail the same shape while * still adding a history step. A breadcrumb built out of history would read * `Search › Weezer › Blue Album › Deluxe Edition`, implying the deluxe sits * inside the original, which it does not. * * Every place is an ENTRY: enough to re-open it, plus what a crumb needs to * draw itself. */ const MAX_HISTORY = 60; export const nav = $state({ /** Visited places, oldest first; the last one is where you are. */ stack: [], /** Places ahead of you, nearest first — filled by going back. */ future: [], /** The root the current catalog journey began from, so a record reached by * search does not claim it was reached from the library. */ origin: null, }); /** Identity for an entry — two entries with the same key are the same place. */ function entryKey(e) { switch (e.view) { case "catalogArtist": return `artist:${e.artistId}`; case "catalogDiscography": return `disco:${e.artistId}`; case "catalogAlbum": return `album:${e.albumId}`; case "catalog": return `search:${e.label}`; case "playlist": return `playlist:${e.playlistId}`; case "library": return e.albumId != null ? `libalbum:${e.albumId}` : "library"; default: return e.view; } } /** Roots start a journey rather than extending one. A results page counts: * it is where a browse begins, and it is the crumb you actually want to get * back to — "Search" as a bare destination is a box you have already left. */ const ROOTS = new Set([ "home", "search", "library", "downloads", "settings", "playlists", "catalog", ]); /** True while the arrows are driving, so replaying a place does not record it * as a new visit and wipe the future you are walking back into. */ let restoring = false; export const isRestoring = () => restoring; /** * Record a visit. Callers pass what they know; everything else is inferred. * @param {{view: string, label: string, art?: string|null, round?: boolean, * glyph?: string|null, artistId?: number|null, albumId?: number|null, * playlistId?: number|null, artist?: string|null}} entry */ export function recordVisit(entry) { if (restoring) return; const e = { ...entry, key: entryKey(entry) }; const top = nav.stack[nav.stack.length - 1]; // Re-entering the place you are already on is not a visit. It happens on // every refresh of a page that reloads its own payload, and without this // each one would push a duplicate the back arrow then has to walk through. if (top && top.key === e.key) { nav.stack[nav.stack.length - 1] = e; return; } if (ROOTS.has(e.view)) { // A root is a fresh journey: the trail starts here. nav.origin = e; } else if (!nav.origin) { nav.origin = { view: "search", label: "Search", key: "search" }; } nav.stack.push(e); if (nav.stack.length > MAX_HISTORY) nav.stack.shift(); nav.future = []; } /** Update the crumb for the place you are already on, and only that place. * Never pushes. Identity arrives after navigation — an artist page opens on an * id and learns its name and portrait a moment later — so the crumb has to be * able to improve in place without becoming a second history step. */ export function refreshVisit(entry) { const e = { ...entry, key: entryKey(entry) }; const top = nav.stack[nav.stack.length - 1]; if (top && top.key === e.key) nav.stack[nav.stack.length - 1] = e; if (nav.origin && nav.origin.key === e.key) nav.origin = e; } /* ---------------------------------------------------- scroll memory ---- * Where the reader was on each place they visited. Going back should return * you to the paragraph you left, not to the top of a page you have already * read; arriving somewhere NEW should start at its top. The difference is * exactly whether the move came from the arrows/trail or from a fresh click. */ const paneEl = () => typeof document === "undefined" ? null : document.querySelector(".content"); /** Remember the reader's position on the place being left. Called at the START * of every navigation, before anything swaps — by the time a view has changed, * the pane's scrollTop belongs to the page that replaced it. */ export function captureScroll() { const pane = paneEl(); const top = nav.stack[nav.stack.length - 1]; if (pane && top) top.scroll = pane.scrollTop; } /** Set by a history move, read once by whichever view lands. `null` means a * fresh arrival, which starts at the top. */ let pendingScroll = null; export function consumeScroll() { const v = pendingScroll; pendingScroll = null; return v; } /** Put the reader back. A page that loads in stages is SHORT when it first * appears — the artist page paints a skeleton, then an overview, then the full * discography — so a single assignment clamps to whatever height exists in * that frame and silently lands near the top. Keep re-applying until the * target is actually reachable, and give up the moment the reader takes over. */ export function restoreScroll(pane, target, budgetMs = 1500) { if (!pane) return; const started = performance.now(); let cancelled = false; const release = () => { cancelled = true; for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"]) { pane.removeEventListener(ev, release); window.removeEventListener(ev, release); } }; for (const ev of ["wheel", "touchstart", "keydown", "pointerdown"]) { pane.addEventListener(ev, release, { passive: true, once: true }); window.addEventListener(ev, release, { passive: true, once: true }); } const tick = () => { if (cancelled) return; pane.scrollTop = target; const arrived = Math.abs(pane.scrollTop - target) < 1; if (arrived || performance.now() - started > budgetMs) return release(); requestAnimationFrame(tick); }; tick(); } export const canGoBack = () => nav.stack.length > 1; export const canGoForward = () => nav.future.length > 0; /** Walk to an entry without recording it — the caller has already decided * which way the stacks move. */ async function replay(entry, open) { restoring = true; try { await open(entry); } finally { restoring = false; } } export async function goBack(open) { if (!canGoBack()) return; captureScroll(); const leaving = nav.stack.pop(); nav.future = [leaving, ...nav.future]; const target = nav.stack[nav.stack.length - 1]; pendingScroll = target.scroll ?? 0; await replay(target, open); } export async function goForward(open) { if (!canGoForward()) return; captureScroll(); const [next, ...rest] = nav.future; nav.future = rest; nav.stack.push(next); pendingScroll = next.scroll ?? 0; await replay(next, open); } /** Jump to a crumb. Everything after it becomes the future, so the forward * arrow walks back down the path you just climbed out of. */ export async function goToCrumb(entry, open) { const at = nav.stack.findIndex((e) => e.key === entry.key); if (at === -1 || at === nav.stack.length - 1) { // A trail step you never actually stood on (the origin of a journey // resumed from elsewhere): go there fresh rather than rewriting history. return open(entry); } captureScroll(); const dropped = nav.stack.slice(at + 1); nav.stack = nav.stack.slice(0, at + 1); nav.future = [...dropped.reverse(), ...nav.future]; pendingScroll = nav.stack[nav.stack.length - 1].scroll ?? 0; await replay(entry, open); } /** * The path to where you are, as crumbs. Derived from the current place and the * journey's origin — NOT from history. */ export function trail() { const here = nav.stack[nav.stack.length - 1]; if (!here) return []; const crumbs = []; if (nav.origin && nav.origin.key !== here.key) crumbs.push(nav.origin); // An album sits under its artist; an artist sits under the origin. The // artist crumb is taken from history when it is there, so it carries the // portrait and id already resolved, and is synthesised from the record's // own artist name when the album was reached directly. // A discography sits under its artist too, so both branches want the same // crumb — found in history when it is there (it carries the portrait and id // already resolved) and synthesised from the record's own artist name when // the album was reached directly. if ((here.view === "catalogAlbum" || here.view === "catalogDiscography") && here.artist) { const known = [...nav.stack] .reverse() .find((e) => e.view === "catalogArtist" && e.label === here.artist); crumbs.push( known ?? { view: "catalogArtist", label: here.artist, artistId: here.artistId ?? null, // A direct arrival's entry learns these a beat after the // page lands (openCatalogAlbum resolves the artist seed and // updates the crumb in place) — until then the synthesised // crumb honestly has no portrait. art: here.artistArt ?? null, round: true, key: `artist:${here.artistId ?? here.artist}`, }, ); } // A playlist sits under the Playlists shelf the same way. The shelf is a // root, so when the page was reached through it the origin crumb already // says Playlists; this branch covers the direct arrivals (a pinned // playlist in the sidebar) by borrowing the shelf from history or // synthesising it. if (here.view === "playlist" && nav.origin?.view !== "playlists") { const known = [...nav.stack].reverse().find((e) => e.view === "playlists"); crumbs.push( known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" }, ); } crumbs.push(here); return crumbs; }