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

+42 -4
View File
@@ -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.
+13
View File
@@ -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;
}
+101
View File
@@ -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
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() {