dj sets + lyrics: a mix that names its own tracks, and words pulled off the separated voice

This commit is contained in:
KeelrLeek committed 2026-08-24 10:49:10 -05:00
1 parent 4efe85b6bf
commit 1dd8573808
41 files changed
+9288 -647

No files matched your search

+275 -27
View File
@@ -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() {