forked from Keelrleek/SoulStream
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:
1 parent
b33d403634
commit
f55562f0ab
4 files changed
+138
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
Reference in new issue
Block a user