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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user