forked from Keelrleek/SoulStream
dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice
This commit is contained in:
1 parent
4efe85b6bf
commit
1dd8573808
41 files changed
+9288
-647
No files matched your search
+275
-27
@@ -1624,7 +1624,9 @@ const VIEW_LABELS = {
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
settings: "Settings",
|
||||
playlists: "Playlists",
|
||||
playlist: "Playlist",
|
||||
lyrics: "Lyrics",
|
||||
};
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
@@ -1632,7 +1634,9 @@ const VIEW_GLYPHS = {
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
settings: "settings",
|
||||
playlists: "playlist",
|
||||
playlist: "music",
|
||||
lyrics: "music",
|
||||
};
|
||||
|
||||
/** Re-open a recorded place. The one function the history arrows and the trail
|
||||
@@ -3384,10 +3388,37 @@ export async function saveStream(scope) {
|
||||
|
||||
/** Parse "Artist — Album" (or just "Artist") from the current track's sub. */
|
||||
function currentArtistAlbum() {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
// A playlist item's `sub` is the PLAYLIST's name, never "artist — album",
|
||||
// so its own fields are the whole identity — splitting would offer the
|
||||
// playlist name as an artist (and negative-cache the click into silence).
|
||||
if (item?.plItemId != null) return [item.artist ?? "", item.album ?? ""];
|
||||
const sub = player.track?.sub ?? "";
|
||||
return sub.includes(" — ")
|
||||
const split = sub.includes(" — ")
|
||||
? sub.split(" — ").map((s) => s.trim())
|
||||
: [sub.trim(), ""];
|
||||
// Per field, the item's own artist/album win — the player bar's rule.
|
||||
return [item?.artist ?? split[0] ?? "", item?.album ?? split[1] ?? ""];
|
||||
}
|
||||
|
||||
/** Anchor-exact album jump for a row that knows its catalog track id but not
|
||||
* its record (playlist stream rows from before commits stored albums): one
|
||||
* name search, then the ID picks the track — never a name guess. */
|
||||
async function openAlbumForCurrentTrackId(opts = {}) {
|
||||
const item = player.queue[player.queueIndex] ?? null;
|
||||
const trackId = item?.trackId;
|
||||
if (trackId == null || !item?.artist || !item?.title) return false;
|
||||
try {
|
||||
const res = await api.catalogSearch(`${item.artist} ${item.title}`);
|
||||
const hit = res?.tracks?.find((t) => Number(t.id) === Number(trackId));
|
||||
if (hit?.album_id != null) {
|
||||
openCatalogAlbum(hit.album_id, { trackId, ...opts });
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the name path
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Now-playing meta links: jump to the artist / album / song's catalog page. */
|
||||
@@ -3395,15 +3426,16 @@ export function openCurrentArtist() {
|
||||
const [artist] = currentArtistAlbum();
|
||||
if (artist) openArtistByName(artist);
|
||||
}
|
||||
export function openCurrentAlbum() {
|
||||
export async function openCurrentAlbum() {
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId);
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId()) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album);
|
||||
}
|
||||
export function openCurrentSong() {
|
||||
export async function openCurrentSong() {
|
||||
const trackTitle = player.track?.title;
|
||||
if (player.catalogRef.albumId != null) {
|
||||
openCatalogAlbum(player.catalogRef.albumId, {
|
||||
@@ -3412,6 +3444,7 @@ export function openCurrentSong() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (await openAlbumForCurrentTrackId({ trackTitle })) return;
|
||||
const [artist, album] = currentArtistAlbum();
|
||||
if (album) openAlbumByName(artist, album, { trackTitle });
|
||||
}
|
||||
@@ -3827,6 +3860,18 @@ async function cachedStreamSourceFor(trackId, artist, title) {
|
||||
for (const key of streamSourceKeys(trackId, artist, title)) {
|
||||
const src = map.get(key);
|
||||
if (!src) continue;
|
||||
// A remembered file whose NAME does not even contain the track's
|
||||
// base title is a mis-paired memory (the 2026-08-20 incident:
|
||||
// three unrelated rows each "remembered" the same Weezer FLAC and
|
||||
// played it from the warm cache forever). Delete it and resolve
|
||||
// normally — a wrongly-dropped legit source only costs one search.
|
||||
const base = normText(asciiFold((title ?? "").split(/[([]/)[0]));
|
||||
const fileName = normText(asciiFold((src.path ?? "").split("\\").pop() ?? ""));
|
||||
if (base.length >= 4 && fileName && !fileName.includes(base)) {
|
||||
map.delete(key);
|
||||
saveStreamSources();
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (await api.streamCacheReady(src.username, src.path, src.size)) return src;
|
||||
} catch {
|
||||
@@ -5542,7 +5587,7 @@ export function initEvents() {
|
||||
// the reader back where they were. Taken exactly once, so an
|
||||
// ordinary resume later in the same track is unaffected.
|
||||
const seekTo = takeResumeSeek();
|
||||
if (seekTo != null) api.playerControl("seek", seekTo);
|
||||
if (seekTo != null) seekPlayer(seekTo);
|
||||
break;
|
||||
}
|
||||
case "paused":
|
||||
@@ -5559,7 +5604,19 @@ export function initEvents() {
|
||||
if (ev.gapless) advanceGaplessly();
|
||||
else nextTrack(true);
|
||||
break;
|
||||
case "position":
|
||||
case "position": {
|
||||
// A set-recording slice shows slice-relative time, keeps its
|
||||
// own duration (the engine reports the whole set's), and
|
||||
// cuts to the next row at its end boundary.
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
if (seg) {
|
||||
const rel = Math.max(0, ev.secs - seg.start);
|
||||
latestPlayerPosition = rel;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = rel;
|
||||
listenTick(rel);
|
||||
if (seg.end != null && ev.secs >= seg.end) nextTrack(true);
|
||||
break;
|
||||
}
|
||||
latestPlayerPosition = ev.secs;
|
||||
if (ev.duration != null) latestPlayerDuration = ev.duration;
|
||||
if (!player.scrubbing && ui.windowActive) player.position = ev.secs;
|
||||
@@ -5568,6 +5625,7 @@ export function initEvents() {
|
||||
}
|
||||
listenTick(ev.secs);
|
||||
break;
|
||||
}
|
||||
case "error":
|
||||
player.status = "idle";
|
||||
player.error = ev.message;
|
||||
@@ -5593,8 +5651,7 @@ export function initEvents() {
|
||||
// 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);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
} else if (
|
||||
ui.windowActive
|
||||
&& cur?.stream
|
||||
@@ -5823,22 +5880,33 @@ export async function playLocalFile(path, title, subLabel, artPath = null) {
|
||||
}
|
||||
|
||||
/** Play an album/playlist (list of library tracks) starting at `start`.
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path. */
|
||||
* `artPath` is the album cover; playlist tracks carry their own art_path.
|
||||
*
|
||||
* A row with `needsResolve` is a playlist STREAM row: full identity, no
|
||||
* source yet. It queues as a pending item that resolves through the normal
|
||||
* ladder when reached (or by the look-ahead a track early), so Play on a
|
||||
* mixed playlist keeps every row in order instead of skipping the streams. */
|
||||
export function playAlbum(tracks, subLabel, start = 0, artPath = null) {
|
||||
autoAlbum = null;
|
||||
player.queue = tracks.map((t) => ({
|
||||
path: t.path,
|
||||
path: t.path ?? null,
|
||||
title: t.title,
|
||||
sub: subLabel,
|
||||
artist: t.artist ?? null,
|
||||
album: t.album ?? null,
|
||||
duration: t.duration_secs ?? null,
|
||||
art: t.art_path ?? artPath,
|
||||
quality: localQuality(t),
|
||||
art: t.art_path ?? (t.needsResolve ? null : artPath),
|
||||
artUrl: t.artUrl ?? null,
|
||||
trackId: t.trackId ?? null,
|
||||
quality: t.path ? localQuality(t) : null,
|
||||
// Which playlist OCCURRENCE this queue item came from, so the playlist
|
||||
// page highlights exactly the row that is playing — a recording that
|
||||
// appears twice must light up once. Null for album playback.
|
||||
plItemId: t.plItemId ?? null,
|
||||
// A set-recording SLICE: {start, end} seconds into the file at
|
||||
// `path` (end null = to the file's end). The playback layer seeks
|
||||
// in at start and cuts at end; display time is slice-relative.
|
||||
segment: t.segment ?? null,
|
||||
}));
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
playQueueIndex(start);
|
||||
@@ -5863,6 +5931,15 @@ function playQueueIndex(i) {
|
||||
};
|
||||
player.status = "buffering";
|
||||
player.duration = item.duration;
|
||||
// A playlist stream row queued without a source yet: resolve it now, then
|
||||
// re-enter with the source attached. The item mutates in place, so a
|
||||
// repeat visit (prev/next, repeat-all) is instant.
|
||||
if (!item.path && !item.stream) {
|
||||
player.track.source = "stream";
|
||||
player.streamTotal = 0;
|
||||
void resolvePendingQueueItem(i, item);
|
||||
return;
|
||||
}
|
||||
if (item.stream) {
|
||||
// Remote track streamed from a peer while it downloads to cache.
|
||||
rememberStreamSource(item);
|
||||
@@ -5881,8 +5958,7 @@ function playQueueIndex(i) {
|
||||
.then(() => {
|
||||
// Ordered behind play_stream so the gapless append can't
|
||||
// race the generation bump on the audio thread.
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
})
|
||||
.catch((e) => {
|
||||
player.error = String(e);
|
||||
@@ -5891,12 +5967,27 @@ function playQueueIndex(i) {
|
||||
return;
|
||||
}
|
||||
player.streamTotal = 0;
|
||||
if (item.segment) {
|
||||
// A set-recording slice: the lane shows the SLICE's own waveform (a
|
||||
// ranged decode — a 90-minute skyline says nothing about a 4-minute
|
||||
// stretch), and the decoder comes up at the slice start. The early
|
||||
// cut at segment.end is enforced by the position handler.
|
||||
loadWaveform(item.path, item.segment);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration, item.segment.start).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
// Slices skip the gapless arms (gaplessNextIndex refuses them) but
|
||||
// a pending row after one still resolves a track early.
|
||||
prepareFollowing();
|
||||
return;
|
||||
}
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
api.playLocal(item.path, item.duration).catch((e) => {
|
||||
player.error = String(e);
|
||||
});
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
/** Play a specific queue position (Now Playing panel track clicks). */
|
||||
@@ -5910,14 +6001,23 @@ function gaplessNextIndex() {
|
||||
if (player.shuffle) return -1;
|
||||
if (player.repeat === "one") return player.queueIndex;
|
||||
const next = player.queueIndex + 1;
|
||||
return next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
const i = next < player.queue.length ? next : player.repeat === "all" ? 0 : -1;
|
||||
// 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
|
||||
// engine's track-ended.
|
||||
if (player.queue[player.queueIndex]?.segment || (i >= 0 && player.queue[i]?.segment)) {
|
||||
return -1;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
function preloadFollowingTrack() {
|
||||
const next = gaplessNextIndex();
|
||||
if (next < 0) return;
|
||||
const item = player.queue[next];
|
||||
if (item.stream) return; // gapless preload is local-only
|
||||
// Gapless preload is local-only; a pending stream row has no path at all.
|
||||
if (item.stream || !item.path) return;
|
||||
api.preloadLocal(item.path, item.duration).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -5967,6 +6067,21 @@ async function tryAppendNext() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Arm every next-track preparation that applies: a LOCAL next preloads
|
||||
* into the sink, a resolved STREAM next pre-buffers and offers the
|
||||
* gapless append, and a PENDING playlist row resolves a track early so
|
||||
* it can join the same chain. Each is a cheap no-op when it does not
|
||||
* apply — call this at every track start and every gapless boundary,
|
||||
* whatever the current source is. (The playlist lesson: only the
|
||||
* album-shaped paths were armed, so a mixed playlist went seamless for
|
||||
* exactly one boundary and stalled on every one after it.) */
|
||||
function prepareFollowing() {
|
||||
preloadFollowingTrack();
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
scheduleQueueLookahead();
|
||||
}
|
||||
|
||||
function advanceGaplessly() {
|
||||
const next = gaplessNextIndex();
|
||||
// The backend only labels a boundary gapless when it has this source
|
||||
@@ -6000,15 +6115,14 @@ function advanceGaplessly() {
|
||||
if ("trackId" in item) player.catalogRef.trackId = item.trackId;
|
||||
setAccentFromArtUrl(item.artUrl ?? null);
|
||||
loadStreamWaveform(item);
|
||||
preloadNextStream();
|
||||
tryAppendNext();
|
||||
prepareFollowing();
|
||||
scheduleAlbumFill();
|
||||
return;
|
||||
}
|
||||
player.track = { title: item.title, sub: item.sub, source: "local", path: item.path };
|
||||
loadWaveform(item.path);
|
||||
setAccentFromArt(item.art ?? null);
|
||||
preloadFollowingTrack();
|
||||
prepareFollowing();
|
||||
}
|
||||
|
||||
// ---- album continuation ----
|
||||
@@ -6164,6 +6278,118 @@ async function maybeExtendAlbumQueue() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- pending playlist stream rows -------------------------------------------
|
||||
// A mixed playlist queues its stream rows as PENDING items — identity, no
|
||||
// source. They resolve through the same ladder the album fill uses: the
|
||||
// remembered-source memory first (free), then the sanitized search. One
|
||||
// resolution is in flight per item, shared between play-on-reach and the
|
||||
// look-ahead, so hammering next can't stack searches for one row.
|
||||
|
||||
const pendingResolves = new WeakMap();
|
||||
|
||||
/** Resolve one pending descriptor to a playable source, or null. */
|
||||
async function resolveStreamSourceFor(t, budgetMs = 7000) {
|
||||
const known = await cachedStreamSourceFor(t.trackId ?? null, t.artist, t.title);
|
||||
if (known) {
|
||||
return {
|
||||
path: known.path,
|
||||
duration: known.duration ?? t.duration ?? null,
|
||||
stream: { username: known.username, size: known.size },
|
||||
quality: known.quality ?? null,
|
||||
};
|
||||
}
|
||||
if (app.connection !== "online") return null;
|
||||
const goodCount = (rs) =>
|
||||
songCandidates(rs, t.title, true, 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;
|
||||
let responses = primary ? await collectSources(primary, budgetMs, goodCount) : [];
|
||||
if (!responses.length && fallback && fallback !== primary) {
|
||||
responses = await collectSources(fallback, budgetMs, goodCount);
|
||||
artistIncluded = normText(fallback).includes(normText(t.artist ?? ""));
|
||||
} else if (primary) {
|
||||
artistIncluded = normText(primary).includes(normText(t.artist ?? ""));
|
||||
}
|
||||
const best = songCandidates(
|
||||
responses, t.title, true, t.album ?? null, t.duration ?? null, t.artist, !artistIncluded,
|
||||
)[0];
|
||||
if (!best) return null;
|
||||
return {
|
||||
path: best.file.path,
|
||||
duration: best.file.duration ?? t.duration ?? null,
|
||||
stream: { username: best.username, size: best.file.size },
|
||||
quality: qualityOf(best.file),
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-flight source resolution for a queue item; mutates it in place. */
|
||||
function resolveQueueItem(item) {
|
||||
const inFlight = pendingResolves.get(item);
|
||||
if (inFlight) return inFlight;
|
||||
const p = resolveStreamSourceFor(item)
|
||||
.then((src) => {
|
||||
if (src) Object.assign(item, src);
|
||||
return src;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => pendingResolves.delete(item));
|
||||
pendingResolves.set(item, p);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Two sourceless rows in a row is a signal (a whole stretch that cannot
|
||||
// resolve), and auto-advancing through a long dead playlist would fire one
|
||||
// Soulseek search per row — the exact flood the album fill's delay exists to
|
||||
// prevent. The streak resets on any success.
|
||||
let pendingFailStreak = 0;
|
||||
|
||||
async function resolvePendingQueueItem(i, item) {
|
||||
const src = await resolveQueueItem(item);
|
||||
// The world may have moved on mid-search: queue replaced, or the user is
|
||||
// on another row. An enriched item keeps its source either way.
|
||||
if (player.queue[i] !== item || player.queueIndex !== i) return;
|
||||
if (src) {
|
||||
pendingFailStreak = 0;
|
||||
playQueueIndex(i);
|
||||
return;
|
||||
}
|
||||
item.unplayable = true;
|
||||
player.error = `No streamable sources found for “${item.title}”`;
|
||||
pendingFailStreak += 1;
|
||||
if (pendingFailStreak >= 2 || player.queue.length <= 1) {
|
||||
pendingFailStreak = 0;
|
||||
stopPlayback();
|
||||
return;
|
||||
}
|
||||
nextTrack(true);
|
||||
}
|
||||
|
||||
// Resolve the NEXT pending row a track early, on the album fill's own
|
||||
// anti-flood delay, so the boundary into a stream row starts instantly
|
||||
// instead of stalling on a live search.
|
||||
let queueLookaheadTimer = null;
|
||||
function scheduleQueueLookahead() {
|
||||
clearTimeout(queueLookaheadTimer);
|
||||
queueLookaheadTimer = setTimeout(() => {
|
||||
queueLookaheadTimer = null;
|
||||
void resolveNextPendingItem();
|
||||
}, ALBUM_FILL_DELAY_MS);
|
||||
}
|
||||
|
||||
async function resolveNextPendingItem() {
|
||||
if (player.shuffle) return; // the next index is unknowable
|
||||
const next = player.queueIndex + 1;
|
||||
const item = player.queue[next];
|
||||
if (!item || item.path || item.stream || item.unplayable) return;
|
||||
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();
|
||||
}
|
||||
|
||||
// ---- waveform ----
|
||||
|
||||
// 2000, up from 400: at 400 each bucket already averaged ~0.64s of audio, so
|
||||
@@ -6177,24 +6403,32 @@ let waveReqPath = null;
|
||||
// Throttle for progressive stream-waveform recomputes (see soulseek:stream).
|
||||
let waveRefreshAt = 0;
|
||||
|
||||
async function loadWaveform(path) {
|
||||
async function loadWaveform(path, range = null) {
|
||||
player.waveform = [];
|
||||
player.wavePending = !!path;
|
||||
waveReqPath = path;
|
||||
// A range = a set-recording slice's lane; each slice keys its own
|
||||
// request so two slices of one file never answer for each other.
|
||||
const req = path && range ? `${path}@${range.start}-${range.end ?? "end"}` : path;
|
||||
waveReqPath = req;
|
||||
if (!path) return;
|
||||
try {
|
||||
const peaks = await api.waveform(path, WAVE_BUCKETS);
|
||||
const peaks = await api.waveform(
|
||||
path,
|
||||
WAVE_BUCKETS,
|
||||
range?.start ?? null,
|
||||
range?.end ?? null,
|
||||
);
|
||||
// Ignore if the track changed while we were computing.
|
||||
if (waveReqPath === path) {
|
||||
if (waveReqPath === req) {
|
||||
player.waveform = peaks;
|
||||
player.waveformCoverage = 1;
|
||||
}
|
||||
} catch {
|
||||
if (waveReqPath === path) player.waveform = [];
|
||||
if (waveReqPath === req) player.waveform = [];
|
||||
} finally {
|
||||
// Either way the request is over; a failed local decode rests as dots
|
||||
// rather than swelling forever at a waveform that will never come.
|
||||
if (waveReqPath === path) player.wavePending = false;
|
||||
if (waveReqPath === req) player.wavePending = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6314,15 +6548,29 @@ export function nextTrack(auto = false) {
|
||||
playQueueIndex(next);
|
||||
}
|
||||
|
||||
/** Engine time for a TRACK-time value. A set-recording slice's engine
|
||||
* clock is the whole file's, so the slice offset is added exactly here —
|
||||
* every transport call that hands the engine a time (seek, scratch,
|
||||
* scratch release) must convert through this door. */
|
||||
export function engineTime(secs) {
|
||||
const seg = player.queue[player.queueIndex]?.segment;
|
||||
return seg ? seg.start + Math.max(0, secs) : secs;
|
||||
}
|
||||
|
||||
/** Seek in TRACK time; see `engineTime`. */
|
||||
export function seekPlayer(secs) {
|
||||
api.playerControl("seek", engineTime(secs));
|
||||
}
|
||||
|
||||
export function prevTrack() {
|
||||
// Restart the current track if we're more than 3s in.
|
||||
if (player.position > 3) {
|
||||
api.playerControl("seek", 0);
|
||||
seekPlayer(0);
|
||||
return;
|
||||
}
|
||||
const prev = player.queueIndex - 1;
|
||||
if (prev >= 0) playQueueIndex(prev);
|
||||
else api.playerControl("seek", 0);
|
||||
else seekPlayer(0);
|
||||
}
|
||||
|
||||
export function toggleShuffle() {
|
||||
|
||||
Reference in new issue
Block a user