playlists + lyrics: setlists with pages of their own, and the words timed to the voice

This commit is contained in:
KeelrLeek committed 2026-08-17 18:05:31 -05:00
1 parent c4ff6269a3
commit df3d15553a
49 files changed
+14419 -599

No files matched your search

+69 -3
View File
@@ -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,
};
}