forked from Keelrleek/SoulStream
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.
271 lines
11 KiB
JavaScript
271 lines
11 KiB
JavaScript
/** 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;
|
||
}
|