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:
1 parent
ad2c912b71
commit
6a7a5c49c0
7 files changed
+534
-39
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user