playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises
This commit is contained in:
1 parent
30eb6fe025
commit
50414dae5c
16 files changed
+4157
-154
No files matched your search
+459
-44
@@ -1,5 +1,6 @@
|
||||
// Shared reactive app state (Svelte 5 runes module).
|
||||
import * as api from "./api.js";
|
||||
import { nameNorm, pickCatalogMatch } from "./catalogMatch.js";
|
||||
import { accentFromImage, hexToRgb, rgbToHex, lerpRgb } from "./color.js";
|
||||
import {
|
||||
nav,
|
||||
@@ -522,12 +523,39 @@ export const ART_EDGE_DEFAULT = 512;
|
||||
* tile's own `object-fit` still decides the crop. Sources already within
|
||||
* `edge` come back untouched rather than re-encoded. Pass `null` to opt out
|
||||
* and get the master. */
|
||||
// ---- art fetch gate ---------------------------------------------------
|
||||
// A page mounting a wall of tiles fires its art requests as fast as the
|
||||
// observer sees them; each answer is a base64 payload crossing IPC onto
|
||||
// the main thread. Six in flight keeps the pipe busy without the burst —
|
||||
// the backend bounds the DECODES separately (2 at a time); this bounds
|
||||
// the traffic. Masters (the player-bar hero) bypass the gate: rare, and
|
||||
// they should never wait behind a hundred tiles.
|
||||
const ART_FETCH_LIMIT = 6;
|
||||
let artFetchActive = 0;
|
||||
const artFetchQueue = [];
|
||||
function gatedArtFetch(fn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const run = () => {
|
||||
artFetchActive += 1;
|
||||
fn()
|
||||
.then(resolve, reject)
|
||||
.finally(() => {
|
||||
artFetchActive -= 1;
|
||||
const next = artFetchQueue.shift();
|
||||
if (next) next();
|
||||
});
|
||||
};
|
||||
if (artFetchActive < ART_FETCH_LIMIT) run();
|
||||
else artFetchQueue.push(run);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArt(path, edge = ART_EDGE_DEFAULT) {
|
||||
if (!path) return null;
|
||||
if (edge == null) return getArtMaster(path);
|
||||
const key = `${edge}|${path}`;
|
||||
if (artThumbCache.has(key)) return touchCache(artThumbCache, key);
|
||||
const pending = api.albumArt(path, edge).then(
|
||||
const pending = gatedArtFetch(() => api.albumArt(path, edge)).then(
|
||||
(url) => {
|
||||
if (artThumbCache.get(key) === pending) {
|
||||
setResolvedCache(artThumbCache, key, url, ART_THUMB_CACHE_MAX, ART_THUMB_CACHE_MAX_CHARS);
|
||||
@@ -3386,6 +3414,129 @@ export async function saveStream(scope) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- bulk save of a playlist's stream rows ----------------------------------
|
||||
// The playlist page's two collection actions, one job at a time app-wide:
|
||||
// "cached" — file every row whose bytes are ALREADY fully in the stream
|
||||
// cache into the library. Instant, free, touches no peer.
|
||||
// "download" — find a source for every row (download prefs: quality floor,
|
||||
// quality priority) and fetch it through save_to_library —
|
||||
// cached rows organize free, the rest download the known file.
|
||||
// Sequential on purpose: one Soulseek search at a time is the pending-
|
||||
// resolver's own anti-flood posture, and a bulk job has no reason to be
|
||||
// ruder than playback.
|
||||
export const playlistBulk = $state({
|
||||
active: false,
|
||||
mode: null, // "cached" | "download"
|
||||
playlistId: null,
|
||||
done: 0,
|
||||
total: 0,
|
||||
organized: 0, // filed into the library instantly (was fully cached)
|
||||
queued: 0, // handed to the download pipeline
|
||||
missed: 0, // no complete cache ("cached") / no source found ("download")
|
||||
summary: null, // last finished run's one-line result, until dismissed
|
||||
});
|
||||
let playlistBulkCancel = false;
|
||||
export function cancelPlaylistBulk() {
|
||||
playlistBulkCancel = true;
|
||||
}
|
||||
export function dismissPlaylistBulk() {
|
||||
playlistBulk.summary = null;
|
||||
}
|
||||
|
||||
export async function runPlaylistBulk(playlistId, rows, mode) {
|
||||
if (playlistBulk.active) return false;
|
||||
// Stream rows with full identity, deduped — the party-playlist case:
|
||||
// the same track twice must not download twice.
|
||||
const seen = new Set();
|
||||
const want = rows.filter((r) => {
|
||||
if (r.kind !== "stream" || !r.artist || !r.title) return false;
|
||||
const key = `${normText(r.artist)}|${normText(r.title)}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
playlistBulkCancel = false;
|
||||
Object.assign(playlistBulk, {
|
||||
active: true,
|
||||
mode,
|
||||
playlistId,
|
||||
done: 0,
|
||||
total: want.length,
|
||||
organized: 0,
|
||||
queued: 0,
|
||||
missed: 0,
|
||||
summary: null,
|
||||
});
|
||||
try {
|
||||
if (mode === "download") await autoConnect();
|
||||
for (const row of want) {
|
||||
if (playlistBulkCancel) break;
|
||||
try {
|
||||
const known = await cachedStreamSourceFor(
|
||||
row.track_id ?? null,
|
||||
row.artist,
|
||||
row.title,
|
||||
);
|
||||
let src = known
|
||||
? { username: known.username, path: known.path, size: known.size }
|
||||
: null;
|
||||
if (mode === "cached") {
|
||||
// Only a byte-complete cache counts — a prefix is not
|
||||
// the track, and this mode promised "no downloading".
|
||||
const len = src ? await api.streamCacheLen(src.username, src.path) : null;
|
||||
if (!src || !src.size || len !== src.size) {
|
||||
playlistBulk.missed += 1;
|
||||
continue;
|
||||
}
|
||||
} else if (!src) {
|
||||
const found = await resolveStreamSourceFor(
|
||||
{
|
||||
trackId: row.track_id ?? null,
|
||||
artist: row.artist,
|
||||
title: row.title,
|
||||
album: row.album ?? null,
|
||||
duration: row.duration_secs ?? null,
|
||||
},
|
||||
9000,
|
||||
false,
|
||||
);
|
||||
if (!found) {
|
||||
playlistBulk.missed += 1;
|
||||
continue;
|
||||
}
|
||||
src = {
|
||||
username: found.stream.username,
|
||||
path: found.path,
|
||||
size: found.stream.size,
|
||||
};
|
||||
}
|
||||
const [organized, queued] = await api.saveToLibrary([src]);
|
||||
playlistBulk.organized += organized;
|
||||
playlistBulk.queued += queued;
|
||||
if (organized === 0 && queued === 0) playlistBulk.missed += 1;
|
||||
} catch {
|
||||
playlistBulk.missed += 1;
|
||||
} finally {
|
||||
playlistBulk.done += 1;
|
||||
}
|
||||
}
|
||||
const parts = [];
|
||||
if (playlistBulk.organized)
|
||||
parts.push(`${playlistBulk.organized} saved to library`);
|
||||
if (playlistBulk.queued) parts.push(`${playlistBulk.queued} downloading`);
|
||||
if (playlistBulk.missed)
|
||||
parts.push(
|
||||
`${playlistBulk.missed} ${mode === "cached" ? "not fully streamed yet" : "without a source"}`,
|
||||
);
|
||||
if (playlistBulkCancel) parts.push("stopped early");
|
||||
playlistBulk.summary = parts.length ? parts.join(" · ") : "Nothing to do";
|
||||
if (playlistBulk.organized > 0) void refreshLibrary();
|
||||
} finally {
|
||||
playlistBulk.active = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||||
function currentArtistAlbum() {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
@@ -3882,6 +4033,95 @@ async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
}
|
||||
void refreshSourcePrefs();
|
||||
|
||||
// ---- artwork for a stream that arrived without any -----------------------
|
||||
|
||||
/** The cover embedded in a track's stream, but only if this app has already
|
||||
* streamed it and still holds the whole file.
|
||||
*
|
||||
* Nothing is fetched and no peer is contacted: this is the playlist page
|
||||
* asking what it can know for free about rows the catalog has never heard
|
||||
* of. Null means "not from here" — never "this row has no artwork". */
|
||||
export async function cachedStreamArt(trackId, artist, title) {
|
||||
const src = await cachedStreamSourceFor(trackId ?? null, artist, title);
|
||||
if (!src) return null;
|
||||
try {
|
||||
return await api.streamEmbeddedArt(src.username, src.path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Catalog covers already looked up this session, keyed artist|title.
|
||||
* A stored `null` is a real answer — the catalog was asked and had nothing
|
||||
* that matched strictly enough to be sure — and it stops every replay of
|
||||
* that track asking again. */
|
||||
const streamArtLookups = new Map();
|
||||
|
||||
/** A streamed track showing no artwork at all: find it some, in order of
|
||||
* certainty.
|
||||
*
|
||||
* The bytes the peer is sending ARE the recording, so a cover embedded in
|
||||
* them belongs to it — no matching, no doubt, no network. Only a file that
|
||||
* carries none asks the catalog, and then through the same strict matcher
|
||||
* the import review verifies rows with. A loose name lookup here is exactly
|
||||
* what once put one record's cover on seven unrelated songs, so no match
|
||||
* means no art: a blank tile is the honest answer, a wrong cover is not.
|
||||
*
|
||||
* Mutates the queue item in place, which is what every art surface already
|
||||
* reads — `art` for a local image, `artUrl` for a catalog cover — so the
|
||||
* bar, the panel and the lightbox all fill from one assignment. */
|
||||
async function ensureStreamArt(item) {
|
||||
if (!item?.stream || !item.path || item.art || item.artUrl) return;
|
||||
// The world moves while a lookup is in flight: the queue can be replaced,
|
||||
// and a later pass (or the stream-done retry) can land art first.
|
||||
const stillBlank = () =>
|
||||
player.queue.includes(item) && !item.art && !item.artUrl;
|
||||
const showing = () => player.queue[player.queueIndex] === item;
|
||||
|
||||
try {
|
||||
const embedded = await api.streamEmbeddedArt(item.stream.username, item.path);
|
||||
if (embedded && stillBlank()) {
|
||||
item.art = embedded;
|
||||
if (showing()) setAccentFromArt(embedded);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// No tag, no file yet, or an unreadable one — the catalog is next.
|
||||
}
|
||||
if (!stillBlank()) return;
|
||||
|
||||
const artist = item.artist ?? "";
|
||||
const title = item.title ?? "";
|
||||
if (!artist || !title) return; // nothing to be strict ABOUT
|
||||
const key = `${nameNorm(artist)}|${nameNorm(title)}`;
|
||||
if (streamArtLookups.has(key)) {
|
||||
const known = streamArtLookups.get(key);
|
||||
if (known && stillBlank()) {
|
||||
item.artUrl = known;
|
||||
if (showing()) setAccentFromArtUrl(known);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await api.catalogSearch(`${artist} ${title}`);
|
||||
const hit = pickCatalogMatch(res?.tracks, {
|
||||
title,
|
||||
artist,
|
||||
duration_secs: item.duration ?? null,
|
||||
});
|
||||
const url = hqCover(hit?.cover ?? null) ?? null;
|
||||
streamArtLookups.set(key, url);
|
||||
if (url && stillBlank()) {
|
||||
item.artUrl = url;
|
||||
if (showing()) setAccentFromArtUrl(url);
|
||||
}
|
||||
} catch {
|
||||
// Offline or a provider hiccup: leave the key unset so a later play
|
||||
// can try again rather than remembering a failure as an absence.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Whether a peer file satisfies the user's quality window for this mode.
|
||||
* Lossless formats satisfy every kbps floor; a lossy file must carry a KNOWN
|
||||
* bitrate at or above it — a peer that hides its bitrate does not get the
|
||||
@@ -5228,6 +5468,18 @@ export const player = $state({
|
||||
queue: [],
|
||||
queueIndex: -1,
|
||||
shuffle: false,
|
||||
// The PREDETERMINED shuffle order: a permutation of queue indices for
|
||||
// the current cycle. Play order is this array left to right; the
|
||||
// position is wherever `queueIndex` sits in it. Predetermined so the
|
||||
// up-next card, the gapless preload/append, and the actual advance all
|
||||
// agree on the SAME future — a random pick at advance time made the
|
||||
// card show the linear next track and the preloader cache it, while
|
||||
// playback then went somewhere else entirely.
|
||||
shuffleOrder: [],
|
||||
// Under repeat "all", the NEXT cycle's order — generated the moment
|
||||
// anything needs to see across the cycle boundary, so the last track's
|
||||
// up-next and its preload name the reshuffle's real first track.
|
||||
shuffleNextOrder: null,
|
||||
repeat: "off", // off | all | one
|
||||
// waveform peaks (0..1) for the current local track, [] while loading
|
||||
waveform: [],
|
||||
@@ -5650,7 +5902,12 @@ export function initEvents() {
|
||||
if (s.done) {
|
||||
// Fully cached → final waveform, and the peer's slot is
|
||||
// definitely free, so make sure the next track is pre-buffering.
|
||||
if (cur?.stream) loadStreamWaveform(cur, true);
|
||||
if (cur?.stream) {
|
||||
loadStreamWaveform(cur, true);
|
||||
// The first attempt may have read a file too young to parse;
|
||||
// the complete one carries whatever tag the peer sent.
|
||||
void ensureStreamArt(cur);
|
||||
}
|
||||
prepareFollowing();
|
||||
} else if (
|
||||
ui.windowActive
|
||||
@@ -5920,6 +6177,7 @@ function playQueueIndex(i) {
|
||||
// correct: a restore must never resurrect a track the queue moved past).
|
||||
bumpMainRevision("queueJumpOrNextPrev");
|
||||
player.queueIndex = i;
|
||||
ensureShuffleOrder();
|
||||
player.track = {
|
||||
title: item.title,
|
||||
sub: item.sub,
|
||||
@@ -5953,6 +6211,10 @@ function playQueueIndex(i) {
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
appendedNextPath = null;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
// A row that arrived with no cover of its own (a setlist row the
|
||||
// catalog could not verify, a by-name stream) gets one looked up
|
||||
// while it plays — the file's own embedded art first.
|
||||
void ensureStreamArt(item);
|
||||
loadStreamWaveform(item); // instant when already fully cached
|
||||
api.playStream(item.stream.username, item.path, item.stream.size, item.duration ?? null)
|
||||
.then(() => {
|
||||
@@ -5998,10 +6260,10 @@ export function jumpToQueueIndex(i) {
|
||||
}
|
||||
|
||||
function gaplessNextIndex() {
|
||||
if (player.shuffle) return -1;
|
||||
if (player.repeat === "one") return player.queueIndex;
|
||||
const next = player.queueIndex + 1;
|
||||
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
// Shuffle included: the order is predetermined, so the engine can arm
|
||||
// the SAME track the panel promises.
|
||||
const i = peekNextIndex();
|
||||
// Set-recording slices never join the gapless machinery: a preload or
|
||||
// append would come up at the FILE's start, not the slice's, and the
|
||||
// slice's early cut is driven by the position handler, not the
|
||||
@@ -6024,14 +6286,27 @@ function preloadFollowingTrack() {
|
||||
// Quietly pre-buffer the next queued stream into the cache. The backend
|
||||
// no-ops when it's already cached or being fetched; single-slot peers just
|
||||
// queue the request until the current transfer finishes.
|
||||
let lastPreloadedPath = null;
|
||||
// Paths already asked for this session — the window re-arms on every
|
||||
// advance, and a path once requested is cached or in flight; asking again
|
||||
// would only duplicate peer traffic.
|
||||
let preloadRequestedPaths = new Set();
|
||||
|
||||
function preloadNextStream() {
|
||||
const next = gaplessNextIndex();
|
||||
const item = next >= 0 ? player.queue[next] : null;
|
||||
if (!item?.stream || item.path === lastPreloadedPath) return;
|
||||
lastPreloadedPath = item.path;
|
||||
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {});
|
||||
/** Pull the next few stream tracks' bytes into the cache — the on-deck
|
||||
* window. Nearest first, so the imminent boundary always has the head
|
||||
* start; rows with no source yet are skipped here and picked up by the
|
||||
* pending-row lookahead, whose resolve calls back into this. */
|
||||
function preloadUpcomingStreams() {
|
||||
for (const i of peekNextIndices(STREAM_PRELOAD_DEPTH)) {
|
||||
const item = player.queue[i];
|
||||
// Segments play a slice of a recording that is already on disk.
|
||||
if (!item?.stream || !item.path || item.segment) continue;
|
||||
if (preloadRequestedPaths.has(item.path)) continue;
|
||||
preloadRequestedPaths.add(item.path);
|
||||
api.preloadStream(item.stream.username, item.path, item.stream.size).catch(() => {
|
||||
// Failed to even start — let a later window retry it.
|
||||
preloadRequestedPaths.delete(item.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Once the next queued stream is fully cached, append it to the active
|
||||
@@ -6077,7 +6352,7 @@ async function tryAppendNext() {
|
||||
* exactly one boundary and stalled on every one after it.) */
|
||||
function prepareFollowing() {
|
||||
preloadFollowingTrack();
|
||||
preloadNextStream();
|
||||
preloadUpcomingStreams();
|
||||
tryAppendNext();
|
||||
scheduleQueueLookahead();
|
||||
}
|
||||
@@ -6264,10 +6539,10 @@ async function maybeExtendAlbumQueue() {
|
||||
// Teach the memory as the fill goes, so the album's LATER tracks
|
||||
// are known even if playback never reaches them this session.
|
||||
rememberStreamSource(item);
|
||||
// Both are guarded against repeat work (lastPreloadedPath,
|
||||
// Both are guarded against repeat work (preloadRequestedPaths,
|
||||
// appendedNextPath), so calling them per append costs nothing once
|
||||
// the immediate next track is already prepared.
|
||||
preloadNextStream();
|
||||
// the upcoming window is already prepared.
|
||||
preloadUpcomingStreams();
|
||||
tryAppendNext();
|
||||
}
|
||||
autoAlbum = null; // every remaining track queued or skipped
|
||||
@@ -6287,8 +6562,10 @@ async function maybeExtendAlbumQueue() {
|
||||
|
||||
const pendingResolves = new WeakMap();
|
||||
|
||||
/** Resolve one pending descriptor to a playable source, or null. */
|
||||
async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
/** Resolve one pending descriptor to a playable source, or null.
|
||||
* `streamOnly=false` ranks by the DOWNLOAD source prefs instead (quality
|
||||
* floor, quality priority) — the bulk download uses it; playback never. */
|
||||
async function resolveStreamSourceFor(t, budgetMs = 7000, streamOnly = true) {
|
||||
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||||
if (known) {
|
||||
return {
|
||||
@@ -6300,7 +6577,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
}
|
||||
if (app.connection !== "online") return null;
|
||||
const goodCount = (rs) =>
|
||||
songCandidates(rs, t.title, true, t.album ?? null, t.duration ?? null, t.artist).length;
|
||||
songCandidates(rs, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist).length;
|
||||
const primary = sanitizeSearchQuery(`${t.artist} ${simplifyTitle(t.title)}`);
|
||||
const fallback = sanitizeSearchQuery(simplifyTitle(t.title));
|
||||
let artistIncluded = true;
|
||||
@@ -6312,7 +6589,7 @@ async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||||
}
|
||||
const best = songCandidates(
|
||||
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||||
responses, t.title, streamOnly, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||||
)[0];
|
||||
if (!best) return null;
|
||||
return {
|
||||
@@ -6377,17 +6654,36 @@ function scheduleQueueLookahead() {
|
||||
}, ALBUM_FILL_DELAY_MS);
|
||||
}
|
||||
|
||||
// Rows the LOOKAHEAD has already searched once. Without this, a row whose
|
||||
// search came up dry would re-enter the window every tick and re-search on
|
||||
// a 4-second loop — the precise flood the pacing exists to prevent. One
|
||||
// lookahead try per row; the play-on-reach resolver still retries it when
|
||||
// its turn actually comes. Queue rebuilds mint new row objects, so the set
|
||||
// forgets naturally.
|
||||
const lookaheadTried = new WeakSet();
|
||||
|
||||
async function resolveNextPendingItem() {
|
||||
if (player.shuffle) return; // the next index is unknowable
|
||||
const next = player.queueIndex + 1;
|
||||
// The whole on-deck window wants sources, but Soulseek searches stay
|
||||
// ONE at a time on the album fill's own pacing — resolve the nearest
|
||||
// untried pending row, then schedule the next tick if more remain.
|
||||
const pendingIn = (indices) =>
|
||||
indices.find((i) => {
|
||||
const it = player.queue[i];
|
||||
return it && !it.path && !it.stream && !it.unplayable && !lookaheadTried.has(it);
|
||||
});
|
||||
const next = pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH));
|
||||
if (next == null) return;
|
||||
const item = player.queue[next];
|
||||
if (!item || item.path || item.stream || item.unplayable) return;
|
||||
lookaheadTried.add(item);
|
||||
const src = await resolveQueueItem(item);
|
||||
if (!src || !player.queue.includes(item)) return;
|
||||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||||
// append, exactly as if it had been queued with its source.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
if (!player.queue.includes(item)) return;
|
||||
if (src) {
|
||||
// Now a normal upcoming stream: pre-buffer it and offer the gapless
|
||||
// append, exactly as if it had been queued with its source.
|
||||
preloadUpcomingStreams();
|
||||
tryAppendNext();
|
||||
}
|
||||
if (pendingIn(peekNextIndices(STREAM_PRELOAD_DEPTH)) != null) scheduleQueueLookahead();
|
||||
}
|
||||
|
||||
// ---- waveform ----
|
||||
@@ -6506,16 +6802,115 @@ async function loadStreamWaveform(item, final = false) {
|
||||
|
||||
// ---- queue navigation, shuffle, repeat ----
|
||||
|
||||
// Indices already played this shuffle cycle (module-private).
|
||||
let shuffleSeen = new Set();
|
||||
/** A Fisher-Yates permutation of 0..n, optionally anchored so `firstIndex`
|
||||
* leads — the track already playing when an order is (re)built stays where
|
||||
* it is and the shuffle happens to everything after it. */
|
||||
function shuffledIndices(n, firstIndex = null) {
|
||||
const idx = Array.from({ length: n }, (_, i) => i);
|
||||
for (let i = n - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[idx[i], idx[j]] = [idx[j], idx[i]];
|
||||
}
|
||||
if (firstIndex != null) {
|
||||
const at = idx.indexOf(firstIndex);
|
||||
if (at > 0) {
|
||||
idx.splice(at, 1);
|
||||
idx.unshift(firstIndex);
|
||||
}
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
function pickShuffleIndex(reset = false) {
|
||||
/** Keep the predetermined order valid. Called from the MUTATION points —
|
||||
* every track start, the shuffle/repeat toggles, session restore — never
|
||||
* from render-time reads: `peekNextIndex` runs inside $derived, where
|
||||
* writing state is (rightly) an error. */
|
||||
function ensureShuffleOrder() {
|
||||
if (!player.shuffle) return;
|
||||
const n = player.queue.length;
|
||||
if (reset) shuffleSeen = new Set();
|
||||
const remaining = [];
|
||||
for (let i = 0; i < n; i++) if (!shuffleSeen.has(i)) remaining.push(i);
|
||||
if (remaining.length === 0) return -1;
|
||||
return remaining[Math.floor(Math.random() * remaining.length)];
|
||||
if (n === 0) {
|
||||
player.shuffleOrder = [];
|
||||
player.shuffleNextOrder = null;
|
||||
return;
|
||||
}
|
||||
if (player.shuffleOrder.length !== n || !player.shuffleOrder.includes(player.queueIndex)) {
|
||||
player.shuffleOrder = shuffledIndices(n, player.queueIndex);
|
||||
player.shuffleNextOrder = null;
|
||||
}
|
||||
// Within a preload-window of the cycle's end with repeat "all": the
|
||||
// future crosses into a reshuffle, so make that reshuffle NOW and keep
|
||||
// it — the up-next display, the 3-deep pre-buffer and the advance all
|
||||
// need the SAME answer.
|
||||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||
if (pos >= n - STREAM_PRELOAD_DEPTH && player.repeat === "all") {
|
||||
if (!player.shuffleNextOrder || player.shuffleNextOrder.length !== n) {
|
||||
const next = shuffledIndices(n);
|
||||
// A cycle boundary should not replay the same track back to back.
|
||||
if (n > 1 && next[0] === player.queueIndex) {
|
||||
[next[0], next[1]] = [next[1], next[0]];
|
||||
}
|
||||
player.shuffleNextOrder = next;
|
||||
}
|
||||
} else {
|
||||
player.shuffleNextOrder = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** How many upcoming tracks keep their bytes ON DECK in the stream cache.
|
||||
* One was the minimum for gapless boundaries; three gives a slow peer two
|
||||
* whole songs of runway, and the window slides — every advance tops it
|
||||
* back up. The SEARCHES that find sources stay one-at-a-time regardless
|
||||
* (the album fill's own anti-flood posture); only the byte-pulling widens. */
|
||||
const STREAM_PRELOAD_DEPTH = 3;
|
||||
|
||||
/** The next `count` queue indices in PLAY order — through the shuffle
|
||||
* order when it's on, across the repeat-all boundary when the pre-made
|
||||
* next cycle exists. Pure: reads state only. Duplicates collapse (a
|
||||
* two-track queue on repeat has one upcoming track, not two entries). */
|
||||
export function peekNextIndices(count = STREAM_PRELOAD_DEPTH) {
|
||||
const n = player.queue.length;
|
||||
if (n === 0) return [];
|
||||
let seq;
|
||||
let pos;
|
||||
if (player.shuffle) {
|
||||
seq = player.shuffleOrder;
|
||||
if (player.repeat === "all" && player.shuffleNextOrder?.length === n) {
|
||||
seq = seq.concat(player.shuffleNextOrder);
|
||||
}
|
||||
pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||
if (pos < 0) return [];
|
||||
} else {
|
||||
seq = Array.from({ length: n }, (_, i) => i);
|
||||
if (player.repeat === "all") seq = seq.concat(seq);
|
||||
pos = player.queueIndex;
|
||||
}
|
||||
const out = [];
|
||||
const seen = new Set([player.queueIndex]);
|
||||
for (let at = pos + 1; at < seq.length && out.length < count; at += 1) {
|
||||
const i = seq[at];
|
||||
if (seen.has(i)) continue;
|
||||
seen.add(i);
|
||||
out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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
|
||||
* itself all read this, so they cannot disagree. Pure: reads state only. */
|
||||
export function peekNextIndex() {
|
||||
const n = player.queue.length;
|
||||
if (n === 0) return -1;
|
||||
if (player.shuffle) {
|
||||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||
if (pos < 0) return -1; // order not built yet — a mutation point will
|
||||
if (pos + 1 < player.shuffleOrder.length) return player.shuffleOrder[pos + 1];
|
||||
if (player.repeat !== "all") return -1;
|
||||
return player.shuffleNextOrder?.length === n ? player.shuffleNextOrder[0] : -1;
|
||||
}
|
||||
const next = player.queueIndex + 1;
|
||||
return next < n ? next : player.repeat === "all" ? 0 : -1;
|
||||
}
|
||||
|
||||
export function nextTrack(auto = false) {
|
||||
@@ -6532,11 +6927,14 @@ export function nextTrack(auto = false) {
|
||||
|
||||
let next;
|
||||
if (player.shuffle) {
|
||||
shuffleSeen.add(player.queueIndex);
|
||||
next = pickShuffleIndex();
|
||||
if (next < 0) {
|
||||
if (player.repeat === "all") next = pickShuffleIndex(true);
|
||||
else return stopPlayback();
|
||||
ensureShuffleOrder();
|
||||
next = peekNextIndex();
|
||||
if (next < 0) return stopPlayback();
|
||||
// Crossing the cycle boundary consumes the pre-made reshuffle.
|
||||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||
if (pos === player.shuffleOrder.length - 1 && player.shuffleNextOrder) {
|
||||
player.shuffleOrder = player.shuffleNextOrder;
|
||||
player.shuffleNextOrder = null;
|
||||
}
|
||||
} else {
|
||||
next = player.queueIndex + 1;
|
||||
@@ -6568,6 +6966,14 @@ export function prevTrack() {
|
||||
seekPlayer(0);
|
||||
return;
|
||||
}
|
||||
// Under shuffle, "previous" is the order's previous — the track that
|
||||
// actually just played — not the neighbouring playlist row.
|
||||
if (player.shuffle) {
|
||||
const pos = player.shuffleOrder.indexOf(player.queueIndex);
|
||||
if (pos > 0) playQueueIndex(player.shuffleOrder[pos - 1]);
|
||||
else seekPlayer(0);
|
||||
return;
|
||||
}
|
||||
const prev = player.queueIndex - 1;
|
||||
if (prev >= 0) playQueueIndex(prev);
|
||||
else seekPlayer(0);
|
||||
@@ -6575,12 +6981,16 @@ export function prevTrack() {
|
||||
|
||||
export function toggleShuffle() {
|
||||
player.shuffle = !player.shuffle;
|
||||
shuffleSeen = new Set([player.queueIndex]);
|
||||
player.shuffleOrder = [];
|
||||
player.shuffleNextOrder = null;
|
||||
ensureShuffleOrder();
|
||||
}
|
||||
|
||||
export function cycleRepeat() {
|
||||
player.repeat =
|
||||
player.repeat === "off" ? "all" : player.repeat === "all" ? "one" : "off";
|
||||
// Repeat decides whether a cycle boundary has a future — recheck it.
|
||||
ensureShuffleOrder();
|
||||
}
|
||||
|
||||
function stopPlayback() {
|
||||
@@ -6602,7 +7012,7 @@ function stopPlayback() {
|
||||
latestStreamTotal = 0;
|
||||
autoAlbum = null;
|
||||
appendedNextPath = null;
|
||||
lastPreloadedPath = null;
|
||||
preloadRequestedPaths = new Set();
|
||||
setAccentFromArt(null);
|
||||
}
|
||||
|
||||
@@ -6734,6 +7144,7 @@ function writeSession() {
|
||||
duration: player.duration,
|
||||
catalogRef: player.catalogRef,
|
||||
shuffle: player.shuffle,
|
||||
shuffleOrder: player.shuffleOrder,
|
||||
repeat: player.repeat,
|
||||
volume: player.volume,
|
||||
muted: player.muted,
|
||||
@@ -6829,6 +7240,9 @@ export async function restoreSession() {
|
||||
player.muted = !!saveState.muted;
|
||||
player.shuffle = !!saveState.shuffle;
|
||||
player.repeat = saveState.repeat ?? "off";
|
||||
// The predetermined order survives a restart with its queue; anything
|
||||
// stale is rebuilt at the next mutation point.
|
||||
player.shuffleOrder = Array.isArray(saveState.shuffleOrder) ? saveState.shuffleOrder : [];
|
||||
|
||||
const item = saveState.queue?.[saveState.queueIndex] ?? null;
|
||||
if (item && saveState.track) {
|
||||
@@ -6844,6 +7258,7 @@ export async function restoreSession() {
|
||||
// Nothing is loaded in the audio thread, so the play button has to
|
||||
// re-source rather than resume — see `resumePlayback`.
|
||||
player.needsResource = !live;
|
||||
ensureShuffleOrder();
|
||||
// The bar's artwork and the app accent follow the track exactly as
|
||||
// they would had it just been started.
|
||||
if (item.stream) setAccentFromArtUrl(item.artUrl ?? null);
|
||||
@@ -6901,6 +7316,6 @@ if (import.meta.env.DEV) {
|
||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||
catalogGo, refreshLibrary, refreshRecents,
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
playlistStore, refreshPlaylists, goToView,
|
||||
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user