Files
SoulStream/src/lib/navigation.svelte.js
T

272 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** 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",
"community",
"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;
}