Files
SoulStream/src/lib/navigation.svelte.js
T
KeelrLeek 15746893a9 restoreSession rebuilds the waveform and the cache badge from the backend, and a direct album arrival gets its artist portrait
Two bugs Kyle reported, both in what a session restore does NOT bring back.

THE LANE AND THE STREAM BADGE. Kyle: after the machine sleeps, "the playbar
waveform goes away and the track says streming even though its cashed". The
trigger is not sleep — it is the frontend restarting with a track loaded, which
WebView2 does on its own under memory pressure (and which a page reload does
too). Neither survives it: the waveform is measured out of the audio bytes and
was never re-requested, so the lane rested as dots while ALSO flagged complete;
and streamReceived/streamTotal reset to 0, which is what the Now Playing header
picks between "transferring", "Cached · size" and "Streaming from <peer>" with —
both at zero fell through to the last, for a file that finished downloading
sessions ago. His read was exactly right: it WAS cached and the label was lying.

Both are now rebuilt from the BACKEND's truth rather than the snapshot. The
restore fires the same final-quality waveform request the stream-completion path
uses (it answers from whatever the cache holds — full, partial or nothing, each
rendering honestly) and asks stream_cache_len for the real cached byte count,
setting the counters only when the cache is COMPLETE so "Cached" is never
claimed for a prefix. Local tracks get their waveform re-requested too; they had
the same dotted-lane bug. Note the guard compares path+peer, not object
identity: player.queue is a $state proxy, so the item read back is never === the
snapshot object the closure holds.

THE BREADCRUMB'S ARTIST PORTRAIT. Reaching an album directly (search -> album,
skipping the artist page) left a bare glyph where the artist's picture belongs:
trail() synthesises that crumb from the record's own artist NAME, and
metadata::CatalogAlbum carries only the name — no id, no picture. openCatalogAlbum
now resolves the artist seed after the page lands and re-records the crumb in
place (same key), so trail() reads artistArt/artistId out of it. The resolver
shares the album header's own cache and in-flight dedupe, so this is the lookup
the page mounting already pays for. It also heals the crumb's CLICK, which had
artistId null on every direct arrival.

Verified live: reloading the app in its broken state restored a 2000-peak
waveform and flipped the header to "Cached · 14.6 MB"; Daft Punk's portrait
shows on a direct-from-search album arrival. 98/98 tests.
2026-08-31 05:53:18 -05:00

271 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",
"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;
}