Files
stemify/web/lyrics.js
T
rootandClaude Opus 5.5 7cdfbef160 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>
2026-10-20 18:29:45 -07:00

47 lines
1.8 KiB
JavaScript

// 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;
}