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 {