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
+42
-4
@@ -73,7 +73,8 @@ export const showInFolder = (path) => invoke("show_in_folder", { path });
|
||||
export const refreshArtistArt = () => invoke("refresh_artist_art");
|
||||
export const playStream = (username, path, size, duration) =>
|
||||
invoke("play_stream", { username, path, size, duration });
|
||||
export const playLocal = (path, duration) => invoke("play_local", { path, duration });
|
||||
export const playLocal = (path, duration, startSecs = null) =>
|
||||
invoke("play_local", { path, duration, startSecs });
|
||||
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
|
||||
export const playerControl = (action, value, rate) =>
|
||||
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
|
||||
@@ -81,7 +82,8 @@ export const playerControl = (action, value, rate) =>
|
||||
// playback from events, so after a reload this is the only way to find out that
|
||||
// audio is still running — see `restoreSession`.
|
||||
export const playerState = () => invoke("player_state");
|
||||
export const waveform = (path, buckets) => invoke("waveform", { path, buckets });
|
||||
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
|
||||
invoke("waveform", { path, buckets, startSecs, endSecs });
|
||||
export const libraryAlbums = () => invoke("library_albums");
|
||||
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
|
||||
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
||||
@@ -170,6 +172,8 @@ export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
||||
durationSecs: fields.durationSecs ?? null,
|
||||
path: fields.path ?? null,
|
||||
artPath: fields.artPath ?? null,
|
||||
trackId: fields.trackId ?? null,
|
||||
artUrl: fields.artUrl ?? null,
|
||||
});
|
||||
// Cover as a data URL: custom master, else composed mosaic, else null.
|
||||
export const playlistCover = (id, maxEdge = null) =>
|
||||
@@ -181,11 +185,44 @@ export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id })
|
||||
export const playlistImportProbe = () => invoke("playlist_import_probe");
|
||||
export const playlistImportFolder = () => invoke("playlist_import_folder");
|
||||
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
||||
export const playlistImportCommit = (name, description, kind, entries) =>
|
||||
invoke("playlist_import_commit", { name, description: description ?? null, kind, entries });
|
||||
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
||||
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
|
||||
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
||||
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
|
||||
// The local door is split pick/scan so the modal can collect an optional
|
||||
// pasted tracklist between the two.
|
||||
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
|
||||
export const playlistScanFile = (path, tracklist = null) =>
|
||||
invoke("playlist_scan_file", { path, tracklist });
|
||||
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
|
||||
export const ytdlpStatus = () => invoke("ytdlp_status");
|
||||
export const ytdlpDownload = () => invoke("ytdlp_download");
|
||||
export const denoDownload = () => invoke("deno_download");
|
||||
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
|
||||
invoke("playlist_import_commit", {
|
||||
name,
|
||||
description: description ?? null,
|
||||
kind,
|
||||
entries,
|
||||
coverUrl,
|
||||
});
|
||||
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
|
||||
// the written path, or null when the dialog was cancelled.
|
||||
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
|
||||
|
||||
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
|
||||
export const lyricModelStatus = () => invoke("lyric_model_status");
|
||||
export const lyricModelDownload = () => invoke("lyric_model_download");
|
||||
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
|
||||
export const vocalSepStatus = () => invoke("vocal_sep_status");
|
||||
export const vocalSepDownload = () => invoke("vocal_sep_download");
|
||||
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
|
||||
invoke("lyric_prep_request", {
|
||||
path: path ?? null,
|
||||
stream,
|
||||
durationSecs: durationSecs ?? null,
|
||||
lines,
|
||||
});
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
|
||||
@@ -220,6 +257,7 @@ export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { p
|
||||
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
|
||||
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
||||
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
||||
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
|
||||
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
||||
|
||||
Binary file not shown.
@@ -59,6 +59,7 @@ const ROOTS = new Set([
|
||||
"library",
|
||||
"downloads",
|
||||
"settings",
|
||||
"playlists",
|
||||
"catalog",
|
||||
]);
|
||||
|
||||
@@ -248,6 +249,18 @@ export function trail() {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// A playlist sits under the Playlists shelf the same way. The shelf is a
|
||||
// root, so when the page was reached through it the origin crumb already
|
||||
// says Playlists; this branch covers the direct arrivals (a pinned
|
||||
// playlist in the sidebar) by borrowing the shelf from history or
|
||||
// synthesising it.
|
||||
if (here.view === "playlist" && nav.origin?.view !== "playlists") {
|
||||
const known = [...nav.stack].reverse().find((e) => e.view === "playlists");
|
||||
crumbs.push(
|
||||
known ?? { view: "playlists", label: "Playlists", glyph: "playlist", key: "playlists" },
|
||||
);
|
||||
}
|
||||
crumbs.push(here);
|
||||
return crumbs;
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Background DJ-set listening. A scan OUTLIVES the import modal: starting a
|
||||
// listen closes the modal, progress rides a global chip above the player
|
||||
// bar, the app stays fully usable, and the finished draft waits on the chip
|
||||
// until it is reviewed or dismissed. One scan at a time — the backend's
|
||||
// cancel flag is global by design.
|
||||
import * as api from "./api.js";
|
||||
|
||||
export const setlistScan = $state({
|
||||
active: false,
|
||||
mode: null, // "youtube" | "file"
|
||||
label: "",
|
||||
stage: null, // "tool" | "runtime" | "audio" | "decode" | "listen"
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null, // finished ImportDraft awaiting review
|
||||
error: null,
|
||||
wantsReview: false, // chip clicked; the Playlists view consumes this
|
||||
});
|
||||
|
||||
let subscribed = false;
|
||||
function subscribe() {
|
||||
if (subscribed) return;
|
||||
subscribed = true;
|
||||
api.onEvent("playlist:scan-progress", (p) => {
|
||||
// A ready chip must not be mutated by stray late events.
|
||||
if (!setlistScan.active || setlistScan.draft || !p?.stage) return;
|
||||
setlistScan.stage = p.stage;
|
||||
if (p.window != null) setlistScan.window = p.window;
|
||||
if (p.total != null) setlistScan.total = p.total;
|
||||
setlistScan.pct = p.pct ?? null;
|
||||
if (p.hit != null) setlistScan.hit = p.hit;
|
||||
});
|
||||
}
|
||||
|
||||
async function run(mode, label, call) {
|
||||
subscribe();
|
||||
Object.assign(setlistScan, {
|
||||
active: true,
|
||||
mode,
|
||||
label,
|
||||
stage: mode === "youtube" ? "audio" : "decode",
|
||||
window: 0,
|
||||
total: 0,
|
||||
pct: null,
|
||||
hit: null,
|
||||
draft: null,
|
||||
error: null,
|
||||
wantsReview: false,
|
||||
});
|
||||
try {
|
||||
const draft = await call();
|
||||
if (draft) {
|
||||
setlistScan.draft = draft;
|
||||
setlistScan.label = draft.name ?? label;
|
||||
} else {
|
||||
// Defensive: a null draft must not wedge the chip.
|
||||
setlistScan.active = false;
|
||||
}
|
||||
} catch (e) {
|
||||
const message = String(e);
|
||||
if (message.includes("canceled")) setlistScan.active = false;
|
||||
else setlistScan.error = message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kick a YouTube listen into the background. False = one already runs. */
|
||||
export function startYoutubeListen(url) {
|
||||
if (setlistScan.active) return false;
|
||||
void run("youtube", "YouTube set", () => api.playlistScanYoutube(url));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Listen to an already-picked set recording in the background, with an
|
||||
* optionally pasted tracklist riding along. False = one already runs. */
|
||||
export function startFileListen(path, tracklist = null, label = "Set recording") {
|
||||
if (setlistScan.active) return false;
|
||||
void run("file", label, () => api.playlistScanFile(path, tracklist));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Hand the finished draft to the review and clear the chip. */
|
||||
export function takeScanDraft() {
|
||||
const draft = setlistScan.draft;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.active = false;
|
||||
setlistScan.wantsReview = false;
|
||||
return draft;
|
||||
}
|
||||
|
||||
/** Cancel a running scan, or drop a finished/failed one unseen. */
|
||||
export function dismissScan() {
|
||||
if (setlistScan.active && !setlistScan.draft && !setlistScan.error) {
|
||||
void api.playlistScanCancel();
|
||||
}
|
||||
setlistScan.active = false;
|
||||
setlistScan.draft = null;
|
||||
setlistScan.error = null;
|
||||
setlistScan.wantsReview = false;
|
||||
}
|
||||
+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