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
+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