Files
SoulStream/src/lib/navigation.svelte.js
T
KeelrLeek 7a517dca59 navigation: one glass bar for every page, with a history and a path in it
The glass bar was copied into two views and each carried its own contextual
back link — "Results" on the artist page, the artist's name on the album page.
It is a shared component now (src/components/GlassBar.svelte), and it carries
the app's navigation: back/forward arrows, and the trail to where you are.

**History and the trail are different things, and the file keeps them apart.**
History is a SEQUENCE — every place visited, in order — and it drives the
arrows. The trail is a HIERARCHY, derived from the current place, never from
history. That is why switching between two editions of a record adds a history
step but does not add a crumb: a breadcrumb built out of history would read
`Search › Weezer › Blue Album › Deluxe Edition`, which says the deluxe sits
inside the original. It does not.

Crumbs improve in place. An artist page opens on an id and learns its name and
portrait a moment later, so the crumb starts as a placeholder and is refreshed
through ONE call site (`rememberArtistSeed`), guarded so remembering a seed for
a row in some list can never rewrite where you are. Search results are the
journey's root rather than a bare "Search" — the crumb reads “weezer”, which
is the thing you actually want to get back to.

Arriving crumbs slide in from the right; only genuinely new ones animate, so an
ancestor that was already there does not re-run its entrance when the tail
changes. Chosen from four motions mocked against a real navigation stack.

Every navigation now goes through a recorder. A bare `ui.view = "…"` is a
destination the back arrow can never return to, so the sidebar routes through
`goToView` and the catalog openers record on arrival.

**Scroll position is remembered.** Going back returns you to the paragraph you
left; arriving somewhere new starts at its top. The difference is exactly
whether the move came from the arrows/trail or from a fresh click. Two details
that mattered: the offset is captured at the START of a navigation, because the
scroll pane outlives the view swap and by the time a page has been replaced its
scrollTop already belongs to its successor; and restoring is not one assignment
— the artist page paints a skeleton, then an overview, then the full
discography, so a single `scrollTop = 900` clamps to whatever height exists in
that frame and lands near the top. It re-applies until the target is reachable
and gives up the instant the reader touches wheel, key or trackpad. The album
page never reset its scroll at all, so a record opened from half-way down an
artist page began half-way down; it does now.

**The artwork lightbox serves any cover.** It was hardwired to the playing
queue item; `ui.fullscreenArt` (a boolean) is now `ui.artwork` (a descriptor),
so Now Playing, the artist page's expander rail and the album hero all open the
same viewer with their own `{src, local, title, subtitle}`. Each passes the
UPGRADED cover rather than the sized variant it paints — the point of the
viewer is the master resolution.

Hovering artwork offers it: the whole element lifts under a light wash with the
expand mark centred. `transform`, not a width change — on the album page the
copy column is sized to end level with the artwork, so a reflowing hover would
twitch the entire hero. The three numbers (--art-grow 1.035, --art-motion
160ms, --art-wash) live in global.css rather than being typed into three files.
Now Playing's corner expand disc is gone: one gesture for "open this artwork"
everywhere instead of a disc in one place and a lift in the others.

On the artist page the expander's album NAME now opens that record's page.

Also removed: the album page's scroll-triggered title takeover. The breadcrumb
names the record permanently, which made it redundant — the build was already
flagging its CSS as dead.

**And the fix this session actually turned on.** Kyle reported the album
tracklist's counts as "heavily glitched": Pinkerton showed El Scorcho at 636
listeners beside Tired Of Sex at 606,324. Switching the column from plays to
listeners changed nothing, because both were wrong for the same reason.

`artist.gettoptracks` returns a popular song many times over — once as itself,
then again for every "(Album Version)", live cut and remaster — listeners
descending. The fold meant to keep the first entry, and its comment said so,
but it was written `if by_key.insert(k, v).is_some() { continue }`.
`HashMap::insert` returns the previous value AND STILL OVERWRITES, so the guard
correctly skipped the ranked push while letting every later duplicate win the
map the album tracklist reads. The last re-listing — the most obscure pressing
— is what reached the screen.

Measured against the live API for Weezer: Buddy Holly is rank 1 at 2,351,642
listeners and rank 679 as "(Album Version)" at 1,919; the page printed 1,919.
My Name Is Jonas 1,100,251 → 870. El Scorcho 654,071 → 636. Pink Triangle
810,659 → 6,070. Only songs Last.fm lists exactly once — "Tired of Sex",
"Across the Sea" — were ever right, which is why the column looked plausible in
places and absurd in others. Twenty-four entries for "The Good Life", twenty
for "El Scorcho".

The fold is now `fold_lastfm_duplicates`, pinned by two tests using those real
figures. It also repairs Popular's plays and the album totals, which read the
same map. The tracklist column shows listeners in both places it appears — the
album page and the artist page's expander — because leaving one on plays would
have let one record disagree with itself.

152 Rust tests and 85 JS tests pass.
2026-07-28 16:40:31 -05:00

248 lines
9.3 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 "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",
"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.
if (here.view === "catalogAlbum" && 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,
art: null,
round: true,
key: `artist:${here.artistId ?? here.artist}`,
},
);
}
crumbs.push(here);
return crumbs;
}