Web player overhaul; iOS random sort, stems-only filter, pooled libraries
Web: live Stem Now status that tracks every queued song (albums too), right-click menus with Stem Now, Songs and Genres pages, a Stems Only filter in place of the sidebar section, Random sort, several pooled Plex servers and libraries, themes plus custom CSS, a reorganised settings page that loads without a refresh, saved mixes, karaoke and synced lyrics, stem playlists and offline downloads. iOS: Random in every sort menu, a Stems Only toggle (sort menu and Settings), and several music libraries pooled from the signed-in server. Settings now persists karaoke and speed across launches. stemd writes a status file that stemserve serves at /api/queue. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
48faa6682f
commit
7cdfbef160
16 files changed
+2006
-730
No files matched your search
@@ -0,0 +1,46 @@
|
||||
// Synced lyrics from LRCLIB, same lookup as the iOS app: exact match first, then a search.
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
function parse(lrc) {
|
||||
const out = [];
|
||||
for (const line of (lrc || "").split("\n")) {
|
||||
const stamps = [...line.matchAll(/\[(\d+):(\d+(?:\.\d+)?)\]/g)];
|
||||
if (!stamps.length) continue;
|
||||
const text = line.replace(/\[[^\]]*\]/g, "").trim();
|
||||
for (const m of stamps) out.push({ t: +m[1] * 60 + +m[2], text });
|
||||
}
|
||||
return out.sort((a, b) => a.t - b.t);
|
||||
}
|
||||
|
||||
// -> {lines: [{t, text}] | null, plain: string | null, instrumental: bool} or null if not found.
|
||||
export async function lyricsFor(t) {
|
||||
const id = t.id;
|
||||
if (cache.has(id)) return cache.get(id);
|
||||
const p = (async () => {
|
||||
const q = { track_name: t.title, artist_name: t.artist, album_name: t.album };
|
||||
if (t.duration) q.duration = Math.round(t.duration / 1000);
|
||||
const get = async (path, params) => {
|
||||
const r = await fetch(`https://lrclib.net${path}?${new URLSearchParams(params)}`, { signal: AbortSignal.timeout(8000) });
|
||||
return r.ok ? r.json() : null;
|
||||
};
|
||||
let hit = await get("/api/get", q).catch(() => null);
|
||||
if (!hit) {
|
||||
const list = await get("/api/search", { track_name: t.title, artist_name: t.artist }).catch(() => null) || [];
|
||||
hit = list.find(x => x.syncedLyrics) || list[0] || null;
|
||||
}
|
||||
if (!hit) return null;
|
||||
return { lines: hit.syncedLyrics ? parse(hit.syncedLyrics) : null, plain: hit.plainLyrics || null, instrumental: !!hit.instrumental };
|
||||
})();
|
||||
cache.set(id, p);
|
||||
const res = await p;
|
||||
if (!res) cache.delete(id);
|
||||
return res;
|
||||
}
|
||||
|
||||
// Index of the line playing at `sec`, or -1 before the first.
|
||||
export function lineAt(lines, sec) {
|
||||
let lo = 0, hi = lines.length - 1, ans = -1;
|
||||
while (lo <= hi) { const mid = (lo + hi) >> 1; if (lines[mid].t <= sec + 0.15) { ans = mid; lo = mid + 1; } else hi = mid - 1; }
|
||||
return ans;
|
||||
}
|
||||
Reference in new issue
Block a user