'use strict'; // ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song. function lyrParseLrc(text) { const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000); const lines = []; for (const raw of String(text || '').split(/\r?\n/)) { const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)]; if (!tags.length) continue; let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = []; if (/<\d+:\d{1,2}/.test(body)) { const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)]; words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim()); words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; }); body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim(); } for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) }); } lines.sort((a, b) => a.t - b.t); lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } }); return lines; } const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false }; const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } }; const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} }; const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } }; const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} }; const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } }; const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim(); async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex) try { const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key); if (!st) return null; const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null; let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics) const timed = lyrParseLrc(text); if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) }; const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null; } catch { return null; } } async function lyrLoad(t, force) { if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key); let res = force ? null : await lyrFromPlex(t); if (!res && cfg.lyrics) { const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' }); try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; } } res = res || { found: false }; if (!res.error) lyr.cache.set(t.key, res); return res; } function lyrOpen() { const t = cur(); if (!t || lyr.open) return; lyr.open = true; document.body.classList.add('lyropen'); const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics'); document.body.appendChild(el); lyrShell(); lyrReload(); addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick); } function lyrClose() { if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey); if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen'); } const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); }; function lyrShell() { const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return; lyr.el.className = pref.karaoke ? 'kar' : ''; lyr.el.style.setProperty('--lf', pref.size + 'px'); lyr.el.innerHTML = `
${esc(t.title)}${esc(t.artist)}

Looking for the lyrics…

`; const $l = q => lyr.el.querySelector(q); $l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle; $l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; }; const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); }; $l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4); $l('#lyrs').onclick = lyrSearchAgain; const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; }; $l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500); syncUI(); } async function lyrReload(force) { const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2; const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '

Looking for the lyrics…

'; const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return; lyr.data = res; lyrDraw(); } function lyrDraw() { const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return; const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge'); nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s'; if (!d || !d.found) { src.textContent = ''; body.innerHTML = `

${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}

`; return; } if (d.instrumental) { src.textContent = ''; body.innerHTML = '

This one is instrumental.

'; return; } src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced'); body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `
${l.words ? l.words.map((w, j) => `${esc(w.text)}`).join('') : esc(l.text)}
` : '
').join(''); body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); }; body.scrollTop = 0; lyr.idx = -2; } function lyrSearchAgain() { const t = cur(); if (!t) return; const q = lyrQuery(t); const el = openSheet('Search for the lyrics', '', `
`); el.querySelector('#lyrc').onclick = closeSheet; el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); }; } function lyrTick() { // follows the song: which line is current, and how far into it we are lyr.raf = requestAnimationFrame(lyrTick); const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return; const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines; let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; } const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll'); if (idx !== lyr.idx) { lyr.idx = idx; let n = -1; nodes.forEach(x => { const i = +x.dataset.i; x.classList.toggle('on', i === idx); x.classList.toggle('past', i < idx); x.classList.toggle('near', Math.abs(i - idx) === 1); x.classList.toggle('upnext', i === idx + 1); if (i === idx) n = i; }); const cur1 = body.querySelector('.ll.on'); if (cur1) cur1.scrollIntoView({ block: 'center', behavior: 'smooth' }); } const on = body.querySelector('.ll.on'); if (!on) return; const l = L[idx]; if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); }); else { const e = l.e && l.e > l.t ? l.e : l.t + 4000, p = Math.max(0, Math.min(1, (pos - l.t) / Math.max(400, Math.min(e - l.t, 7000)))); on.style.setProperty('--p', p); } // (no word timing: the colour sweeps across the line) }