playlists + lyrics: setlists with pages of their own, and the words timed to the voice
This commit is contained in:
1 parent
c4ff6269a3
commit
df3d15553a
49 files changed
+14419
-599
No files matched your search
+111
-8
@@ -140,14 +140,85 @@ export const getSettings = () => invoke("get_settings");
|
||||
export const setSettings = (patch) => invoke("set_settings", patch);
|
||||
export const forgetCredentials = () => invoke("forget_credentials");
|
||||
export const pickFolder = () => invoke("pick_folder");
|
||||
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
|
||||
// Rows are OCCURRENCES with their own ids: the same recording may appear
|
||||
// twice, and every mutation addresses item ids, never positions.
|
||||
export const listPlaylists = () => invoke("list_playlists");
|
||||
export const createPlaylist = (name) => invoke("create_playlist", { name });
|
||||
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
|
||||
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
|
||||
export const setPlaylistDescription = (id, description) =>
|
||||
invoke("set_playlist_description", { id, description: description ?? null });
|
||||
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
|
||||
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
|
||||
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
|
||||
export const playlistTracks = (id) => invoke("playlist_tracks", { id });
|
||||
export const addToPlaylist = (playlistId, tracks) =>
|
||||
invoke("add_to_playlist", { playlistId, tracks });
|
||||
export const removeFromPlaylist = (playlistId, position) =>
|
||||
invoke("remove_from_playlist", { playlistId, position });
|
||||
export const playlistItems = (id) => invoke("playlist_items", { id });
|
||||
export const playlistAddItems = (playlistId, items, at = null) =>
|
||||
invoke("playlist_add_items", { playlistId, items, at });
|
||||
export const playlistRemoveItems = (playlistId, itemIds) =>
|
||||
invoke("playlist_remove_items", { playlistId, itemIds });
|
||||
export const playlistSetOrder = (playlistId, orderedIds) =>
|
||||
invoke("playlist_set_order", { playlistId, orderedIds });
|
||||
// Repair one row: fields null = leave as stored; path non-null relinks the
|
||||
// row to a playable file.
|
||||
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
||||
invoke("playlist_relink_item", {
|
||||
playlistId,
|
||||
itemId,
|
||||
title: fields.title ?? null,
|
||||
artist: fields.artist ?? null,
|
||||
album: fields.album ?? null,
|
||||
durationSecs: fields.durationSecs ?? null,
|
||||
path: fields.path ?? null,
|
||||
artPath: fields.artPath ?? null,
|
||||
});
|
||||
// Cover as a data URL: custom master, else composed mosaic, else null.
|
||||
export const playlistCover = (id, maxEdge = null) =>
|
||||
invoke("playlist_cover", { id, maxEdge });
|
||||
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
|
||||
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
|
||||
// Import: probe opens a file dialog and stages a reviewed draft; text stages
|
||||
// a pasted tracklist; commit writes the accepted draft in one transaction.
|
||||
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 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 });
|
||||
// Local-library candidates for the unresolved-row repair panel.
|
||||
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
||||
|
||||
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
|
||||
export const lyricsGet = (args) =>
|
||||
invoke("lyrics_get", {
|
||||
title: args.title,
|
||||
artist: args.artist,
|
||||
album: args.album ?? null,
|
||||
durationSecs: args.durationSecs ?? null,
|
||||
path: args.path ?? null,
|
||||
refresh: args.refresh ?? null,
|
||||
});
|
||||
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
|
||||
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
|
||||
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
|
||||
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
|
||||
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
|
||||
export const lyricsSaveText = (title, artist, durationSecs, text) =>
|
||||
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
|
||||
export const lyricsClear = (title, artist, durationSecs) =>
|
||||
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
|
||||
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
|
||||
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
|
||||
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
|
||||
// not a learned separator). Resolves to { path, cached } of a playable WAV.
|
||||
export const lyricsPrepareInstrumental = (path) =>
|
||||
invoke("lyrics_prepare_instrumental", { path });
|
||||
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
|
||||
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
|
||||
// Persist hand-synced lines as the track's own document (wins the waterfall).
|
||||
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 rememberFusedArtistPortrait = (artistId, portrait) =>
|
||||
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
||||
@@ -232,6 +303,11 @@ export const debugStats = () => invoke("debug_stats");
|
||||
// source folder is never written to.
|
||||
export const importScan = (root) => invoke("import_scan", { root });
|
||||
export const importSessionGet = () => invoke("import_session_get");
|
||||
// The source folder relative to the library folder, and back again. Saving
|
||||
// both is what lets a review started on one machine resume on another after
|
||||
// the drive lands at a different letter.
|
||||
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
|
||||
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
|
||||
export const importSessionSet = (state) => invoke("import_session_set", { state });
|
||||
export const importSessionClear = () => invoke("import_session_clear");
|
||||
export const importGetDest = () => invoke("import_get_dest");
|
||||
@@ -239,11 +315,29 @@ export const importSetDest = (dir, layout = null) => invoke("import_set_dest", {
|
||||
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
|
||||
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
||||
// the artwork panel's cover report. Both read-only.
|
||||
export const importIdentify = (artist, title) => invoke("import_identify", { artist, title });
|
||||
// `title` is the record's own claim (tags); `folder` is the second witness,
|
||||
// asked after it — a record whose tags are wrong still has its folder name.
|
||||
// `shape` is everything ELSE the scan knows — track count, track titles, year,
|
||||
// and where the files are so Rust can hash the record's own cover. That is what
|
||||
// the discography pass compares when no phrase can find the record; the cover
|
||||
// itself never crosses IPC (a master can be 8 MB).
|
||||
export const importIdentify = (artist, title, folder = null, shape = {}) =>
|
||||
invoke("import_identify", {
|
||||
artist, title, folder,
|
||||
tracks: shape.tracks ?? null,
|
||||
trackTitles: shape.trackTitles ?? null,
|
||||
year: shape.year ?? null,
|
||||
sourceRoot: shape.sourceRoot ?? null,
|
||||
rel: shape.rel ?? null,
|
||||
// Bounds the cover hash to the record's own audio — a folded release
|
||||
// gathers from these, an ordinary one from its folder alone.
|
||||
parts: shape.parts ?? null,
|
||||
});
|
||||
export const importCoverReport = (artist, title, referenceUrl) =>
|
||||
invoke("import_cover_report", { artist, title, referenceUrl });
|
||||
// The record's own artwork, measured (embedded first, else folder images).
|
||||
export const importLocalArt = (sourceRoot, rel) => invoke("import_local_art", { sourceRoot, rel });
|
||||
export const importLocalArt = (sourceRoot, rel, parts) =>
|
||||
invoke("import_local_art", { sourceRoot, rel, parts });
|
||||
// Prune rows whose files are gone, then index anything new — the real rescan.
|
||||
export const rescanLibrary = () => invoke("rescan_library");
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
@@ -252,6 +346,15 @@ export const rescanLibrary = () => invoke("rescan_library");
|
||||
export const importAudition = (username, rel, files, startIndex = 0) =>
|
||||
invoke("import_audition", { username, rel, files, startIndex });
|
||||
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
|
||||
// Which of a found copy's files the pen does not (fully) hold. Transfers die
|
||||
// with the app and the arrived-count dies with them; the files on disk are
|
||||
// what is left to ask.
|
||||
export const importAuditionMissing = (rel, files) =>
|
||||
invoke("import_audition_missing", { rel, files });
|
||||
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
|
||||
// The pen is keyed by RECORD, so without this the dropped peer's files sit
|
||||
// beside the replacement's until the session is cleared.
|
||||
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
|
||||
// Measured facts about one audio file (the version card's quality chips).
|
||||
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
||||
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,73 @@
|
||||
// How the import finder asks when it is not ALLOWED to ask.
|
||||
//
|
||||
// The Soulseek server drops a search outright when the query carries every
|
||||
// word of one of its excluded phrases. Measured 2026-08-11 against the live
|
||||
// server, with the record's own copies provably on the network:
|
||||
//
|
||||
// animal collective merriweather 578 peers · 2,664 FLAC files
|
||||
// merriweather pavilion 683 peers
|
||||
// post pavilion 712 peers
|
||||
// merriweather post pavilion 0 peers <- every time
|
||||
// pavilion merriweather post 0 peers <- order is irrelevant
|
||||
// merriweather post pavilion flac 0 peers <- extra terms don't help
|
||||
//
|
||||
// The published exclusions behave identically ("village people" 0, "bryan
|
||||
// adams" 0, "village people flac" 0, while "abba gold" answers with 608), so
|
||||
// this is that same mechanism — but the server publishes only SOME of its
|
||||
// list. `sanitizeSearchQuery` strips the phrases it does publish; an
|
||||
// unpublished one like this album's title reaches the wire and is swallowed.
|
||||
//
|
||||
// Nothing here can make the server answer a refused query. What it CAN do is
|
||||
// ask a shorter question the server does answer, and then apply the part we
|
||||
// were not allowed to say to the answers, where no server is involved.
|
||||
|
||||
const STOPWORDS = new Set(["the", "a", "an", "and", "or", "of", "in", "on", "at", "to", "for", "with"]);
|
||||
|
||||
/** Words, punctuation folded away. `&` and `-` are separators, not letters, so
|
||||
* "Rock & Roll" and "Rock-n-Roll" both yield asking words rather than one
|
||||
* token nothing will match. */
|
||||
export function queryWords(s) {
|
||||
return String(s ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** The record's name with its last word dropped — the shortest change that
|
||||
* breaks the refused word-set while still naming the record.
|
||||
*
|
||||
* Trailing stopwords go with it: "The Dark Side of the Moon" without "moon"
|
||||
* is "the dark side of the", which asks the network for three words that
|
||||
* carry no meaning and narrow the result set for nothing.
|
||||
*
|
||||
* Returns "" when there is nothing left worth asking. A one-word name
|
||||
* shortens to nothing at all, and a bare artist query asks the network for
|
||||
* that artist's entire discography — the same thing `findQueries` refuses
|
||||
* to do. */
|
||||
export function shortenName(name) {
|
||||
const w = queryWords(name);
|
||||
if (w.length < 2) return "";
|
||||
w.pop();
|
||||
while (w.length && STOPWORDS.has(w[w.length - 1])) w.pop();
|
||||
return w.join(" ");
|
||||
}
|
||||
|
||||
/** Does this peer's path claim to BE the record we could not ask for? Every
|
||||
* word of the full name has to appear in it.
|
||||
*
|
||||
* This is the half of the question the server was not allowed to answer, and
|
||||
* it is what keeps the shortened query honest: "animal collective merriweather
|
||||
* post" also matches a live set recorded AT Merriweather Post Pavilion, and
|
||||
* the venue is in the path of every bootleg taped there.
|
||||
*
|
||||
* Deliberately whole-word, not substring: "Pavillion" is not "Pavilion", and
|
||||
* a folder that misspells the record is refused rather than guessed at. That
|
||||
* costs a few real copies whose owners cannot spell; accepting near-misses
|
||||
* costs correctness, and there is no way to tell the two apart from here. */
|
||||
export function pathHasTitle(path, name) {
|
||||
const need = queryWords(name);
|
||||
if (!need.length) return false;
|
||||
const have = new Set(queryWords(path));
|
||||
return need.every((w) => have.has(w));
|
||||
}
|
||||
+30
-2
@@ -27,8 +27,36 @@
|
||||
*
|
||||
* 1. Deezer try-list, MusicBrainz fallback.
|
||||
* 2. + the artist's own catalogue, ranked by title similarity — finds records
|
||||
* whose folder name no release carries ("1989 Pauls Boutique"). */
|
||||
export const IDENTIFY_ENGINE = 2;
|
||||
* whose folder name no release carries ("1989 Pauls Boutique").
|
||||
* 3. + the artist the TAGS name, asked when the folder's artist found nothing
|
||||
* or the wrong record, and + a try-list that refuses to strip a title down
|
||||
* to its catalogue number ("Band Of Gypsys (SICP 2643)" → "(SICP 2643)").
|
||||
* 4. + the title without a trailing release-type word ("Willoughby's Beach EP"
|
||||
* → "Willoughby's Beach"). Both providers require every word of the
|
||||
* phrase, so the one appended word hid a record each of them holds.
|
||||
* 5. + the FOLDER as a second witness, asked after the record's own claim.
|
||||
* Tags carry typos the folder does not ("Nonagon Infiniry" on all nine
|
||||
* files, in a correctly-spelled folder), and until now one wrong tag was
|
||||
* the only thing any provider was ever asked about.
|
||||
* 6. + the record's TRACK COUNT breaks the tie when one title names several
|
||||
* MusicBrainz release groups ("Murder of the Universe" is a 21-track Album
|
||||
* and a 1-track Single, both scored 100, in an unstable order), and a
|
||||
* provider request that FAILED no longer caches as "nothing found".
|
||||
* 7. The last resort stopped being a title search dressed as a comparison. It
|
||||
* ranks the artist's WHOLE MusicBrainz discography — complete, where
|
||||
* Deezer's listing omitted EPs entirely — and decides on every fact the
|
||||
* scan has: wording, track count, year, the TRACKLIST, and the record's own
|
||||
* ARTWORK hashed against the discography's covers. A name is one fact about
|
||||
* a record and the one most often wrong; `Rock Bottom 7"` scores 0.667
|
||||
* against the release actually called "Rock Bottom / Octopus", and 2 of 2
|
||||
* on its songs.
|
||||
*
|
||||
* BUMP THIS AFTER THE RUST BUILD LANDS, never before. Vite hot-reloads this
|
||||
* constant instantly while the capability it describes lives in
|
||||
* `metadata::identify_titles`, so bumping it against a running old binary
|
||||
* makes that binary sweep the negatives and re-stamp the record on screen as
|
||||
* freshly looked at by code that learned nothing. */
|
||||
export const IDENTIFY_ENGINE = 7;
|
||||
|
||||
/** The value most of the list agrees on, ignoring blanks. Ties go to the first
|
||||
* value seen, which keeps the result stable for a re-render. */
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Rough line-timing estimation — plain lyrics + a measured vocal-activity
|
||||
// envelope → ESTIMATED line starts, as a starting point for the tap-to-sync
|
||||
// editor. Never presented as finished timing.
|
||||
//
|
||||
// The method deliberately stays above the research doc's forbidden floor
|
||||
// ("never estimate line positions by evenly distributing text across song
|
||||
// duration"): lines are laid out only across MEASURED vocal-active segments,
|
||||
// weighted by line length, and when the measurement shows nothing usable the
|
||||
// generator refuses instead of guessing. Real generated timing (ASR anchors
|
||||
// + forced alignment, per the research) is a later, model-gated feature.
|
||||
//
|
||||
// Pure functions, no imports — unit-tested by tests/lyrics/sync.test.mjs.
|
||||
|
||||
/** Hysteresis segmentation of a 0..1 activity envelope.
|
||||
* Returns [{ startMs, endMs }], merged and pruned. */
|
||||
export function activeSegments(frames, frameMs, opts = {}) {
|
||||
const on = opts.on ?? 0.32; // enter singing
|
||||
const off = opts.off ?? 0.16; // leave singing…
|
||||
const offRunMs = opts.offRunMs ?? 900; // …only after this long below
|
||||
const minSegMs = opts.minSegMs ?? 1200;
|
||||
const mergeGapMs = opts.mergeGapMs ?? 700;
|
||||
|
||||
const segments = [];
|
||||
let start = null;
|
||||
let belowRun = 0;
|
||||
for (let i = 0; i < frames.length; i++) {
|
||||
const v = frames[i];
|
||||
if (start == null) {
|
||||
if (v >= on) {
|
||||
start = i;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else if (v < off) {
|
||||
belowRun += frameMs;
|
||||
if (belowRun >= offRunMs) {
|
||||
const end = i * frameMs - belowRun + frameMs;
|
||||
segments.push({ startMs: start * frameMs, endMs: end });
|
||||
start = null;
|
||||
belowRun = 0;
|
||||
}
|
||||
} else {
|
||||
belowRun = 0;
|
||||
}
|
||||
}
|
||||
if (start != null) {
|
||||
segments.push({ startMs: start * frameMs, endMs: frames.length * frameMs });
|
||||
}
|
||||
// Merge close neighbours, then drop slivers.
|
||||
const merged = [];
|
||||
for (const seg of segments) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && seg.startMs - last.endMs <= mergeGapMs) {
|
||||
last.endMs = seg.endMs;
|
||||
} else {
|
||||
merged.push({ ...seg });
|
||||
}
|
||||
}
|
||||
return merged.filter((s) => s.endMs - s.startMs >= minSegMs);
|
||||
}
|
||||
|
||||
/** Plain text → the lines worth timing (section labels dropped). */
|
||||
export function lyricLinesOf(plainText) {
|
||||
return String(plainText ?? "")
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0)
|
||||
.filter((l) => !/^\[.*\]$/.test(l));
|
||||
}
|
||||
|
||||
/** Lay `lines` across `segments` by character weight over the concatenated
|
||||
* active timeline. Returns [{ time_ms, text }] (monotonic, every start
|
||||
* inside an active segment), or null when there is nothing to lay onto. */
|
||||
export function distributeLines(lines, segments) {
|
||||
if (!lines.length || !segments.length) return null;
|
||||
const activeTotal = segments.reduce((sum, s) => sum + (s.endMs - s.startMs), 0);
|
||||
if (activeTotal < 4000) return null;
|
||||
const weight = (line) => Math.max(line.length, 8);
|
||||
const totalWeight = lines.reduce((sum, l) => sum + weight(l), 0);
|
||||
// virtual position (0..activeTotal) → real ms via the segment list.
|
||||
const toReal = (virtual) => {
|
||||
let left = virtual;
|
||||
for (const seg of segments) {
|
||||
const len = seg.endMs - seg.startMs;
|
||||
if (left <= len) return seg.startMs + left;
|
||||
left -= len;
|
||||
}
|
||||
return segments[segments.length - 1].endMs;
|
||||
};
|
||||
const out = [];
|
||||
let cumulative = 0;
|
||||
for (const line of lines) {
|
||||
const virtual = (cumulative / totalWeight) * activeTotal;
|
||||
out.push({ time_ms: Math.round(toReal(virtual)), text: line });
|
||||
cumulative += weight(line);
|
||||
}
|
||||
// Monotonic by construction, but rounding can tie neighbours; nudge.
|
||||
for (let i = 1; i < out.length; i++) {
|
||||
if (out[i].time_ms <= out[i - 1].time_ms) out[i].time_ms = out[i - 1].time_ms + 300;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The whole rough pass. Returns [{ time_ms, text }] or null (refused). */
|
||||
export function roughSync(plainText, frames, frameMs) {
|
||||
const lines = lyricLinesOf(plainText);
|
||||
const segments = activeSegments(frames ?? [], frameMs ?? 100);
|
||||
return distributeLines(lines, segments);
|
||||
}
|
||||
Binary file not shown.
+69
-3
@@ -227,6 +227,8 @@ export const importStatus = $state({
|
||||
label: "", // applied: the "· 12 GB" tail; otherwise unused
|
||||
skipped: 0, // applied: records nothing was written for — the toast must
|
||||
// say so, because "Imported 0 records" on its own reads as a dead end
|
||||
error: "", // error: what went wrong, in words. "The import hit a problem"
|
||||
// is not a reason, and a silent failure is not even that.
|
||||
});
|
||||
|
||||
/** A paused import's presence on the Library page: the Import button wears
|
||||
@@ -267,6 +269,8 @@ export const ui = $state({
|
||||
// window is minimized. This is the authoritative shared activity gate for
|
||||
// hidden animation, canvas, carousel, and high-frequency position work.
|
||||
windowActive: true,
|
||||
// Fullscreen lyrics overlay (LyricsOverlay.svelte) over the content pane.
|
||||
lyricsFull: false,
|
||||
});
|
||||
|
||||
export function toggleNowPlaying() {
|
||||
@@ -311,16 +315,49 @@ export const libraryStore = $state({
|
||||
|
||||
export const playlistStore = $state({
|
||||
playlists: [],
|
||||
loaded: false,
|
||||
// Cover data URLs keyed by playlist id. Entries are revision-stamped so a
|
||||
// mutated playlist (new rows → new mosaic) refetches while an untouched
|
||||
// one costs nothing on re-render.
|
||||
covers: {},
|
||||
});
|
||||
|
||||
// revision the cached cover was composed against, and in-flight dedupe.
|
||||
const playlistCoverRev = new Map();
|
||||
const playlistCoverInflight = new Map();
|
||||
|
||||
export async function refreshPlaylists() {
|
||||
try {
|
||||
playlistStore.playlists = await api.listPlaylists();
|
||||
for (const pl of playlistStore.playlists) void playlistCoverFor(pl);
|
||||
} catch {
|
||||
// backend not available
|
||||
} finally {
|
||||
playlistStore.loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve (and cache) the cover for one playlist row from `listPlaylists`.
|
||||
/// Reads land in `playlistStore.covers[id]`; null means "no cover — draw the
|
||||
/// gradient fallback".
|
||||
export async function playlistCoverFor(pl, maxEdge = 512) {
|
||||
const id = pl.id;
|
||||
const cached = playlistStore.covers[id];
|
||||
if (cached !== undefined && playlistCoverRev.get(id) === pl.revision) return cached;
|
||||
if (playlistCoverInflight.has(id)) return playlistCoverInflight.get(id);
|
||||
const req = api
|
||||
.playlistCover(id, maxEdge)
|
||||
.then((url) => {
|
||||
playlistStore.covers[id] = url ?? null;
|
||||
playlistCoverRev.set(id, pl.revision);
|
||||
return url ?? null;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => playlistCoverInflight.delete(id));
|
||||
playlistCoverInflight.set(id, req);
|
||||
return req;
|
||||
}
|
||||
|
||||
// ---- recently played (the sidebar's list) ----
|
||||
// A play row is opened after ~1s of audio, so without a floor this list is a
|
||||
// log of skips. 45s is the floor the live table was measured against.
|
||||
@@ -3691,9 +3728,18 @@ export const RESOLVER_AUDIO_QUALITY = {
|
||||
// Formats that decode progressively while the file is still downloading.
|
||||
// MP4-family files (m4a/aac) put their index at the end and only play once
|
||||
// complete, so they're save-only.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "opus", "wav", "aiff"]);
|
||||
//
|
||||
// `opus` was here and must not be: this build has no Opus decoder, so the
|
||||
// resolver would pick an Opus source to stream, download it, and fail at the
|
||||
// moment of play. Streamable is a subset of PLAYABLE, never a superset —
|
||||
// see crates/audio-formats.
|
||||
const RESOLVER_STREAMABLE = new Set(["flac", "mp3", "ogg", "oga", "wav", "wave", "aiff", "aif"]);
|
||||
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "aiff", "alac", "wv"]);
|
||||
// Lossless for LABELLING — what the file claims to be, which is a different
|
||||
// question from whether we can decode it. `.ape` and `.wv` are genuinely
|
||||
// lossless and genuinely unplayable here; a peer's file is still worth naming
|
||||
// accurately in search results.
|
||||
const LOSSLESS_EXTS = new Set(["flac", "ape", "wav", "wave", "aiff", "aif", "alac", "wv", "tta"]);
|
||||
|
||||
// User source-selection preferences, independent per mode. Loaded from
|
||||
// settings at startup and refreshed by the Settings view after a commit;
|
||||
@@ -5031,10 +5077,24 @@ export function filesForGroup(key) {
|
||||
return searchFiles.get(key)?.files ?? [];
|
||||
}
|
||||
|
||||
// Mirrors crates/audio-formats: everything recognised as audio, playable or
|
||||
// not. Kept in sync by `audio_format_support`, which the backend answers from
|
||||
// the same table the scanner and share index use.
|
||||
export const AUDIO_EXTS = new Set([
|
||||
"flac", "mp3", "m4a", "ogg", "opus", "wav", "aac", "wma", "ape", "aiff",
|
||||
"flac", "mp3", "mp2", "mp1", "m4a", "m4b", "mp4", "m4r", "aac",
|
||||
"ogg", "oga", "wav", "wave", "aiff", "aif", "aifc", "caf", "mka", "webm",
|
||||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||||
]);
|
||||
|
||||
// Recognised but with no decoder in this build. Shown, searchable, shareable
|
||||
// and downloadable — never handed to the player, which refuses them anyway.
|
||||
export const UNPLAYABLE_EXTS = new Set([
|
||||
"opus", "wma", "ape", "wv", "mpc", "tta", "shn", "dsf", "dff", "spx", "ra", "au", "amr",
|
||||
]);
|
||||
export const isPlayableExt = (ext) =>
|
||||
AUDIO_EXTS.has(String(ext ?? "").toLowerCase())
|
||||
&& !UNPLAYABLE_EXTS.has(String(ext ?? "").toLowerCase());
|
||||
|
||||
export const downloads = $state({
|
||||
// id -> download payload (latest update wins)
|
||||
items: {},
|
||||
@@ -5771,9 +5831,14 @@ export function playAlbum(tracks, subLabel, start = 0, artPath = 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),
|
||||
// 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,
|
||||
}));
|
||||
player.catalogRef = { albumId: null, trackId: null };
|
||||
playQueueIndex(start);
|
||||
@@ -6588,5 +6653,6 @@ if (import.meta.env.DEV) {
|
||||
playLocalFile, playAlbum, togglePlayPause, nextTrack, prevTrack,
|
||||
catalogGo, refreshLibrary, refreshRecents,
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
playlistStore, refreshPlaylists, goToView,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user