overlay + library: the preloader working on glass, album names that land on the right self-titled record, and covers warmed before you scroll to them

This commit is contained in:
KeelrLeek committed 2026-08-25 08:05:07 -05:00
1 parent ad2c912b71
commit 6a7a5c49c0
7 files changed
+534 -39

No files matched your search

+186 -4
View File
@@ -1,15 +1,22 @@
<script>
// Dev debug overlay — Ctrl+Shift+D. Read-only glass pane over the app:
// system + process-tree CPU/GPU/RAM, network rates, the active track's
// download progress/speed and loudness-normalization state, renderer
// FPS, and the preview session when one is open. Polls the backend only
// while visible; costs nothing when hidden.
// download progress/speed, the on-deck queue window (what the pre-buffer
// is doing about each upcoming track), loudness-normalization state,
// renderer FPS, and the preview session when one is open. Polls the
// backend only while visible; costs nothing when hidden.
import * as api from "../lib/api.js";
import { player } from "../lib/state.svelte.js";
import { player, onDeckSnapshot } from "../lib/state.svelte.js";
import { previewUi } from "../lib/preview/previewSession.svelte.js";
let visible = $state(false);
let stats = $state(null);
// The on-deck window (QUEUE section): indices + module-private markers
// from the player machinery, refreshed on the same 1s tick as stats.
let deck = $state(null);
// Cache-file byte lengths for on-deck rows with no live transfer —
// tells "cached ✓" from "prefix n MB" from "nothing yet".
let cacheLens = $state({});
let fps = $state(0);
let drops = $state(0); // main-thread frames >25ms since overlay opened
let worstGap = $state(0);
@@ -41,6 +48,29 @@
dlSpeed = cur >= lastReceived && dt > 0 ? (cur - lastReceived) / dt : 0;
lastReceived = cur;
lastAt = now;
// On-deck window. For stream rows with no live transfer reporting
// bytes, ask the cache file itself — that covers tracks that were
// fully cached before this session ever preloaded anything.
deck = onDeckSnapshot();
const lens = {};
await Promise.all(
deck.indices.map(async (i) => {
const it = player.queue[i];
if (!it?.stream || !it.path) return;
const live = stats?.preloadDl?.some(
(p) =>
p.path === it.path
&& p.username === it.stream.username
&& p.state !== "done",
);
if (live) return;
try {
const len = await api.streamCacheLen(it.stream.username, it.path);
if (len != null) lens[it.path] = len;
} catch {}
}),
);
cacheLens = lens;
};
void tick();
const id = setInterval(tick, 1000);
@@ -125,6 +155,87 @@
? { db: loud.headStartDb, from: loud.prefixGainDb }
: null,
);
// ---- QUEUE: the on-deck pre-buffer window ----
const base = (p) => (p ? p.split("\\").pop() : "?");
/** One readout line per queue row: what the on-deck machinery is doing
* about it right now. Live transfer state comes from the backend's own
* mirror (preloadDl); rows without a transfer fall back to the cache
* file's length; pending rows report the lookahead's posture. */
function classifyRow(item, slot, isCurrent) {
const name = `${item.artist ? `${item.artist} — ` : ""}${item.title ?? base(item.path)}`;
let status;
let pct = null;
let cls = "dim";
if (isCurrent) {
status = player.status;
cls = "";
} else if (item.unplayable) {
status = "unplayable";
cls = "warn";
} else if (!item.path && !item.stream) {
// A pending playlist row: no source yet. One lookahead search per
// row — spent means it came up dry and waits for its real turn.
const tried = deck.wasLookaheadTried(item);
status = tried ? "searched · dry (retries on reach)" : "resolving source…";
cls = tried ? "warn" : "";
} else if (!item.stream) {
status = "local file";
} else {
const dl = stats?.preloadDl?.find(
(p) => p.path === item.path && p.username === item.stream.username,
);
const size = dl?.total || item.stream.size || 0;
const len = cacheLens[item.path];
if (dl?.state === "transferring") {
status = `${mb(dl.received)}/${mb(size)} MB · ${rate(dl.speedBps)}`;
pct = size > 0 ? (dl.received / size) * 100 : null;
cls = "";
} else if (dl?.state === "queued") {
status = dl.place != null ? `queued at peer #${dl.place}` : "queued at peer";
} else if (dl && dl.state !== "done") {
// connecting / requested / failed: reason / cancelled
status = dl.state;
cls = dl.state === "connecting" || dl.state === "requested" ? "dim" : "warn";
} else if (dl?.state === "done" || (len != null && size > 0 && len >= size)) {
status = "cached ✓";
cls = "ok";
} else if (len > 0) {
status = `prefix ${mb(len)} MB`;
} else if (deck.isRequested(item.path)) {
status = "requested…";
} else {
status = "idle";
}
if (deck.appendedNextPath === item.path) status += " · in sink";
}
return { slot, name, status, pct, cls, isCurrent };
}
const queueRows = $derived.by(() => {
if (!deck) return [];
const rows = [];
const cur = player.queue[player.queueIndex];
if (cur) rows.push(classifyRow(cur, "▶", true));
deck.indices.forEach((i, k) => {
const item = player.queue[i];
if (item) rows.push(classifyRow(item, String(k + 1), false));
});
return rows;
});
/** Backend transfers for paths no longer in the window — the sliding
* window moved on (a jump, a reshuffle) but the bytes keep arriving. */
const offWindow = $derived.by(() => {
if (!deck || !stats?.preloadDl?.length) return [];
const winPaths = new Set(
[player.queue[player.queueIndex], ...deck.indices.map((i) => player.queue[i])]
.map((it) => it?.path)
.filter(Boolean),
);
return stats.preloadDl.filter((p) => !winPaths.has(p.path));
});
</script>
<svelte:window onkeydown={onKeydown} />
@@ -163,6 +274,36 @@
<div class="dim">DL — no active stream</div>
{/if}
</div>
<div class="sec">
<div>
<b>QUEUE</b>
{#if player.queue.length}
{player.queueIndex + 1}/{player.queue.length}
· on-deck {deck?.indices?.length ?? 0}
{#if player.shuffle}· shuffle{/if}
{#if player.repeat && player.repeat !== "off"}· repeat {player.repeat}{/if}
{:else}
empty
{/if}
</div>
{#each queueRows as r}
<div class="qrow">
<span class="qslot" class:cur={r.isCurrent}>{r.slot}</span>
<span class="qname" title={r.name}>{r.name}</span>
</div>
<div class="qstat {r.cls}">{r.status}</div>
{#if r.pct != null}
<div class="bar q"><i style="width:{Math.min(100, r.pct)}%"></i></div>
{/if}
{/each}
{#each offWindow as p}
<!-- State + bytes lead: the filename is the part that can
afford to truncate. -->
<div class="dim qoff" title={p.path}>
↳ {p.state}{p.state === "transferring" ? ` ${mb(p.received)}${p.total ? `/${mb(p.total)}` : ""} MB` : ""} · {base(p.path)}
</div>
{/each}
</div>
<div class="sec">
<div>
<b>NORM</b>
@@ -302,6 +443,47 @@
.num.hot {
color: #ffc46b;
}
/* QUEUE rows: name line, then an indented status line (and a mini bar
while bytes are moving) — two short lines read better at 11px than
one truncated long one. */
.qrow {
display: flex;
gap: 6px;
align-items: baseline;
margin-top: 3px;
}
.qslot {
min-width: 12px;
text-align: right;
color: rgba(215, 224, 234, 0.45);
}
.qslot.cur {
color: #7fd0ff;
}
.qname {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qstat {
margin-left: 18px;
font-variant-numeric: tabular-nums;
}
.qstat.warn {
color: #ffc46b;
}
.qstat.ok {
color: #7fd0ff;
}
.bar.q {
margin-left: 18px;
}
.qoff {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* A scale, not a fill: the two markers are POSITIONS on −15…+9 dB, so the
distance between them is the correction still to travel. */
.bar.slew {
+4
View File
@@ -140,6 +140,10 @@ export const openExternal = (url) => invoke("open_external", { url });
// pixels on the long side. Omit it only where true pixels matter (the
// fullscreen viewer, the import picker's comparison and loupe).
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
/** Warm the disk thumbnail cache for the whole library at the given edges —
* [{warm, decoded}] per edge. Slow by design (one decode at a time); callers
* fire-and-forget it. */
export const libraryArtPrewarm = (edges) => invoke("library_art_prewarm", { edges });
/** The cover embedded in a streamed track's own cache file, extracted to a
* local image path — null while the transfer is too young to read, or when
* the peer's file simply carries no picture. */
+96 -7
View File
@@ -541,7 +541,11 @@ function gatedArtFetch(fn) {
.then(resolve, reject)
.finally(() => {
artFetchActive -= 1;
const next = artFetchQueue.shift();
// Newest first: on a fast scroll the queue's tail is the
// tiles on screen RIGHT NOW and its head is the screens
// scrolled past. FIFO served the wake behind the viewer;
// the passed-by tiles still drain once the burst settles.
const next = artFetchQueue.pop();
if (next) next();
});
};
@@ -1176,12 +1180,53 @@ export async function refreshLibrary() {
libraryStore.albums = await api.libraryAlbums();
libraryStore.loaded = true;
window.__libErr = null;
scheduleArtPrewarm();
} catch (e) {
// not connected to backend (browser preview) — leave empty
window.__libErr = String(e);
}
}
// ---- thumbnail pre-warm ----
// The disk cache makes a REVISITED cover a 1.5ms read, but the first-ever
// visit still decoded on scroll — so a section you had never scrolled to
// showed placeholders while its masters decoded two at a time. Warm the whole
// library once in the background instead (the Library's tile edge first, then
// the queue-card size) and every scroll lands on the warm path. Re-runs are
// ~one stat per cover, so this simply re-fires after every library change to
// pick up new arrivals; the guard coalesces bursts.
let artPrewarmRunning = false;
let artPrewarmAgain = false;
const ART_PREWARM_DELAY_MS = 4000;
let artPrewarmTimer = null;
function scheduleArtPrewarm() {
// Delay past the boot/refresh paint so the first screen's own art
// requests hit the decode permits before the background walk does.
clearTimeout(artPrewarmTimer);
artPrewarmTimer = setTimeout(() => {
artPrewarmTimer = null;
void runArtPrewarm();
}, ART_PREWARM_DELAY_MS);
}
async function runArtPrewarm() {
if (artPrewarmRunning) {
artPrewarmAgain = true;
return;
}
artPrewarmRunning = true;
try {
await api.libraryArtPrewarm([ART_EDGE_DEFAULT, 96]);
} catch {
// browser preview / old backend — tiles still load on demand
} finally {
artPrewarmRunning = false;
}
if (artPrewarmAgain) {
artPrewarmAgain = false;
scheduleArtPrewarm();
}
}
// ---- catalog browser (Deezer-backed streaming-style search) ----
export const catalog = $state({
@@ -3274,20 +3319,45 @@ export async function openAlbumByName(artist, title, opts = {}) {
if (!artist || !title) return null;
const na = catNorm(artist);
const nt = catNorm(title);
const key = `${na}|${nt}`;
// A year hint changes which record the same name resolves to (the
// self-titled-trio case), so it is part of the answer's identity — a
// shared key would let one LP's answer poison the others'.
const key = opts.year != null ? `${na}|${nt}|${opts.year}` : `${na}|${nt}`;
if (albumNameResolves.has(key)) {
const id = albumNameResolves.get(key);
if (id != null) openCatalogAlbum(id, opts);
return id;
}
/** Same-titled rows from a DATED list (the artist's own discography),
* preferring the hinted year. Self-titled multiples (American Football ×3,
* Weezer ×7) all normalize to one title, so without a date the first row
* simply wins — and the first row is the wrong record two times in three. */
const pickByYear = (albums) => {
const same = (albums ?? []).filter((al) => catNorm(al.title) === nt);
if (!same.length) return null;
if (opts.year != null) {
const dated = same.find((al) =>
String(al.release_date ?? "").startsWith(String(opts.year)),
);
if (dated) return dated;
}
return same[0];
};
try {
const res = await api.catalogSearch(`${artist} ${title}`);
if (request !== albumResolveRequest) return null;
// A title-only or first-result fallback can cross into a namesake's
// catalog. Without a verified id, both artist and title must match.
let match = res.albums?.find(
const searchMatches = (res.albums ?? []).filter(
(album) => catNorm(album.artist) === na && catNorm(album.title) === nt,
);
// Deezer's /search/album strips release dates, so a year hint cannot
// be checked here — and a single hit is no proof of the RIGHT record
// when namesake editions exist. A unique, unhinted hit keeps the fast
// path; ambiguity (a hint, or several same-titled hits) falls through
// to the artist's own dated discography below.
let match =
searchMatches.length === 1 && opts.year == null ? searchMatches[0] : null;
// Deezer's SEARCH index is not the whole discography. The artist page
// merges a MusicBrainz supplement on top of it, so records Deezer does
// not carry at all still have a real id, a real release date and full
@@ -3303,7 +3373,7 @@ export async function openAlbumByName(artist, title, opts = {}) {
if (seed?.id != null) {
const page = await getCatalogArtistPage(seed.id).catch(() => null);
if (request !== albumResolveRequest) return null;
match = page?.albums?.find((album) => catNorm(album.title) === nt);
match = pickByYear(page?.albums);
// The cached page is only SUPPLEMENTED once the artist page
// has been visited — cold, it is the raw Deezer list and the
// MB-only records are still missing. Run the same merge the
@@ -3324,12 +3394,14 @@ export async function openAlbumByName(artist, title, opts = {}) {
},
identity,
);
match = merged?.albums?.find(
(album) => catNorm(album.title) === nt,
);
match = pickByYear(merged?.albums);
}
}
}
// The discography had nothing to say (unresolvable artist, or the
// record missing from it) — a bare search hit is still better than
// a dead click, year-verified or not.
if (!match) match = searchMatches[0] ?? null;
}
// A settled miss is an answer ABOUT THE RECORD: neither the catalog nor
// the artist's own discography has it. Negative-cache that. A throw
@@ -6895,6 +6967,22 @@ export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) {
return out;
}
/** Debug-overlay window into the on-deck machinery. Read-only: the indices
* the pre-buffer is arming, plus the module-private bits a QUEUE readout
* needs — which path is already appended to the sink, whether a path has
* been asked of the backend this session, and whether the lookahead already
* spent its one search try on a still-pending row. The membership checks are
* closures because both containers are reassigned/forgotten on queue
* rebuilds — a copied array would go stale mid-tick. */
export function onDeckSnapshot() {
return {
indices: peekNextIndices(STREAM_PRELOAD_DEPTH),
appendedNextPath,
isRequested: (path) => preloadRequestedPaths.has(path),
wasLookaheadTried: (item) => (item ? lookaheadTried.has(item) : false),
};
}
/** The queue index that plays after the current track, or -1 when playback
* would stop there. THE one answer for what comes next — the up-next card,
* the gapless preload/append, the pending-row lookahead and the advance
@@ -7314,6 +7402,7 @@ if (import.meta.env.DEV) {
// beyond convenience: the server ignores the handshake after a burst
// of reconnects, so a verification run that streams costs a login.
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
jumpToQueueIndex,
catalogGo, refreshLibrary, refreshRecents,
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
playlistStore, refreshPlaylists, goToView, peekNextIndices,
+26 -8
View File
@@ -35,6 +35,7 @@
getCatalogArtistPage,
openCatalogArtist,
openCatalogAlbum,
openAlbumByName,
RECENTS_FLOOR_SECS,
recentsStore,
importResume,
@@ -778,9 +779,19 @@
const wanted = album.id;
try {
const page = await getCatalogArtistPage(anchored.artistId);
const match = (page?.albums ?? []).find(
// Same-titled records collapse to one normalized key (American
// Football × 3, Weezer × 7). The artist page's rows are dated, so
// the local folder's year picks the right one; only then does the
// first row stand in.
const same = (page?.albums ?? []).filter(
(r) => normKey(r.title) === normKey(album.title),
);
const match =
(album.year != null
&& same.find((r) =>
String(r.release_date ?? "").startsWith(String(album.year)),
))
|| same[0];
if (openId === wanted && match) openCatalogId = match.id;
} catch { /* no link; the title stays plain */ }
}
@@ -1610,17 +1621,24 @@
{/if}
</button>
<!-- The name opens the record's own page, as it does
on the artist page — but only once the local
folder has been joined to a catalogue row. Until
then (or for an artist with no anchored id) it
stays plain text rather than a link that would
have to guess where it goes. -->
on the artist page. An anchored (followed)
artist's join gives the id up front; otherwise
the click resolves through openAlbumByName —
the now-playing links' guarded resolver, which
verifies artist AND title (never guesses) and
stays put on a settled miss. Plain text only
when the row has no artist+title to resolve. -->
<h3 class="rail-title">
{#if openCatalogId != null}
{#if openCatalogId != null || (openAlbum?.artist && openAlbum?.title)}
<button
class="rail-title-link"
title="Open album"
onclick={() => openCatalogAlbum(openCatalogId)}
onclick={() =>
openCatalogId != null
? openCatalogAlbum(openCatalogId)
: openAlbumByName(openAlbum.artist, openAlbum.title, {
year: openAlbum.year ?? null,
})}
>{openAlbum?.title ?? ""}</button>
{:else}
<span class="rail-title-text">{openAlbum?.title ?? ""}</span>