playback: a cached track plays without asking Soulseek where it lives

Hopping between songs of an already-cached album still ran the full
"finding best source…" resolve for audio sitting complete on disk — a
second of popup between every click. The player now remembers the source
each stream item was queued from (localStorage, LRU-capped at 400) and,
before any search, asks the backend whether that exact file is
byte-complete in the stream cache. If it is, the track plays those bytes
immediately: no resolver, no popup, and no online requirement — cached
audio is playable offline, so the check sits before requireOnline.

The memory is a hint, never a promise. It is written eagerly when an item
is queued to PLAY, because a source that never delivers leaves no complete
cache file and the byte-complete probe rejects it later at no cost;
eviction and half-finished transfers fall back to the normal resolver the
same way. First play of a track records it, every play after that is
instant.
This commit is contained in:
KeelrLeek committed 2026-08-03 16:08:51 -05:00
1 parent b33d403634
commit f55562f0ab
4 files changed
+138

No files matched your search

+2
View File
@@ -182,6 +182,8 @@ export const preloadCachedStream = (username, path, size, duration) =>
invoke("preload_cached_stream", { username, path, size, duration });
export const streamWaveform = (username, path, size, buckets) =>
invoke("stream_waveform", { username, path, size, buckets });
export const streamCacheReady = (username, path, size) =>
invoke("stream_cache_ready", { username, path, size });
// Preview tracks (scrollable-track-previews): a fully separate audio path.
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
+115
View File
@@ -3392,6 +3392,82 @@ export async function refreshSourcePrefs() {
}
}
// ---- remembered stream sources ------------------------------------------
// The last source each catalog track streamed from, persisted so a track can
// play again WITHOUT a Soulseek search: its bytes are already in the stream
// cache, and the resolve would only rediscover the peer that put them there.
// The memory is a HINT, not a promise — before any search is skipped the
// backend confirms the exact file is byte-complete on disk, so eviction, a
// half-finished transfer, or a source that never delivered all fall back to
// the normal resolver on their own.
const STREAM_SOURCE_STORE = "soulstream.streamSources.v1";
const STREAM_SOURCE_CAP = 400;
let streamSources = null; // Map key → {username, path, size, duration, quality}
function streamSourceKeys(trackId, artist, title) {
const keys = [];
if (trackId != null) keys.push(`t:${trackId}`);
if (artist && title) keys.push(`n:${artist}|${title}`.toLowerCase());
return keys;
}
function loadStreamSources() {
if (streamSources) return streamSources;
streamSources = new Map();
try {
for (const [k, v] of JSON.parse(localStorage.getItem(STREAM_SOURCE_STORE) ?? "[]")) {
streamSources.set(k, v);
}
} catch {
// corrupt store — start over; losing it only costs one search per track
}
return streamSources;
}
function saveStreamSources() {
try {
const entries = [...streamSources.entries()];
localStorage.setItem(
STREAM_SOURCE_STORE,
JSON.stringify(entries.slice(-STREAM_SOURCE_CAP)),
);
} catch {
// quota — same story, the memory is only a shortcut
}
}
/** Called every time a stream item is queued to PLAY (not when it proves
* itself) — eager on purpose: a source that never delivers leaves no complete
* cache file, so the probe rejects it later and nothing was lost. Insertion
* order doubles as the LRU for the cap in saveStreamSources. */
function rememberStreamSource(item) {
if (!item?.stream?.username || !item.path || !(item.stream.size > 0)) return;
const map = loadStreamSources();
const record = {
username: item.stream.username,
path: item.path,
size: item.stream.size,
duration: item.duration ?? null,
quality: item.quality ?? null,
};
for (const key of streamSourceKeys(item.trackId ?? null, item.artist, item.title)) {
map.delete(key);
map.set(key, record);
}
saveStreamSources();
}
/** The remembered source for this track, but ONLY if its cache file is
* byte-complete right now. Anything else is null: resolve normally. */
async function cachedStreamSourceFor(trackId, artist, title) {
const map = loadStreamSources();
for (const key of streamSourceKeys(trackId, artist, title)) {
const src = map.get(key);
if (!src) continue;
try {
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
} catch {
// backend unavailable — resolve normally
}
}
return null;
}
void refreshSourcePrefs();
/** Whether a peer file satisfies the user's quality window for this mode.
@@ -4146,6 +4222,44 @@ export async function streamSong(artist, title, opts = {}) {
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
return;
}
// Streamed before and still byte-complete in the cache? Play those bytes
// now — no search, no resolver popup, no network requirement. This is the
// second-long "finding best source…" beat that used to sit between songs
// of an already-cached album. Checked before `requireOnline` on purpose:
// a cached track is playable offline.
const cached = await cachedStreamSourceFor(opts.trackId ?? null, artist, title);
if (cached) {
autoAlbum = null;
player.catalogRef = { albumId: opts.albumId ?? null, trackId: opts.trackId ?? null };
player.queue = [
{
path: cached.path,
title,
sub: opts.album ? `${artist} — ${opts.album}` : artist,
artist,
album: opts.album ?? null,
duration: cached.duration ?? opts.expectSecs ?? null,
art: null,
artUrl: hqCover(opts.cover) ?? null,
trackId: opts.trackId ?? null,
stream: { username: cached.username, size: cached.size },
quality: cached.quality ?? null,
},
];
playQueueIndex(0);
// Keep the album going exactly like the resolved path does.
if (opts.albumId) {
autoAlbum = {
albumId: opts.albumId,
albumTitle: opts.album ?? null,
cover: hqCover(opts.cover) ?? null,
tracks: null,
pending: false,
};
maybeExtendAlbumQueue();
}
return;
}
if (!requireOnline()) return;
autoAlbum = null;
player.track = { title, sub: "finding best source…", source: "stream" };
@@ -5221,6 +5335,7 @@ function playQueueIndex(i) {
player.duration = item.duration;
if (item.stream) {
// Remote track streamed from a peer while it downloads to cache.
rememberStreamSource(item);
player.streamReceived = 0;
player.streamTotal = item.stream.size;
// A fresh track must not inherit the previous one's refresh clock —