166 lines
15 KiB
JavaScript
166 lines
15 KiB
JavaScript
'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 lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
||
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
||
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; }
|
||
}
|
||
/** A transcript: the show's own if it publishes one, else (on request) one made on this server. Polls while that is being made. */
|
||
async function lyrSpoken(t, start) {
|
||
const q = new URLSearchParams({ key: t.key, src: t.src || '' });
|
||
for (let n = 0; ; n++) { // (n restarts at -1 after an automatic start, so the POST goes out once)
|
||
if (!lyr.el || lyr.key !== t.key) return null;
|
||
let r; try { r = await jfetch('/api/transcript?' + q, start && n <= 0 ? { method: 'POST' } : undefined); } catch (e) { return { found: false, error: e.message }; }
|
||
if (r.state === 'ready') { r.transcript.spoken = true; return r.transcript; }
|
||
if (r.state === 'error') return { found: false, error: r.error, canTranscribe: r.canTranscribe, none: true };
|
||
if (r.state === 'none') {
|
||
if (r.canTranscribe && !start && String(t.key).startsWith('pod:') && !lyrPref().noAuto) { start = true; n = -1; continue; } // nothing published: make one (an episode, not a whole book)
|
||
return { found: false, none: true, canTranscribe: r.canTranscribe, why: r.why };
|
||
}
|
||
const body = lyr.el.querySelector('#lyrbody'); if (body) body.innerHTML = `<p class="empty">Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.<br><small>You can close this: it keeps going, and it is kept for next time.</small></p>`;
|
||
await new Promise(ok => setTimeout(ok, 4000));
|
||
}
|
||
}
|
||
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 && !lyrSpokenKey(t) ? 'kar' : '';
|
||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
||
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><span class="grow"></span>
|
||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
||
const $l = q => lyr.el.querySelector(q);
|
||
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
||
$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 = '<p class="empty">Looking for the lyrics…</p>';
|
||
const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !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 = '';
|
||
if (lyrSpokenKey(t)) { // no transcript yet: offer to make one
|
||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
||
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
||
}
|
||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
||
}
|
||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; 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 ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').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', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||
<div class="bar"><button class="btn ghost" type="button" id="lyrc" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Search</button></div></form>`);
|
||
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)
|
||
}
|
||
|
||
// Playing a podcast episode is also the signal to get its transcript going in the background (the published one if there is one, else one made on this
|
||
// server), so the words are usually ready, or well on the way, by the time Transcript is opened. Once per episode per visit; nothing for audiobooks.
|
||
const lyrAsked = new Set();
|
||
pl.a.addEventListener('playing', async () => {
|
||
const t = typeof cur === 'function' ? cur() : null;
|
||
if (!t || !String(t.key).startsWith('pod:') || !t.src || lyrAsked.has(t.key) || !cfg.transcribe || lyrPref().noAuto) return;
|
||
lyrAsked.add(t.key);
|
||
try {
|
||
const q = new URLSearchParams({ key: t.key, src: t.src }), r = await jfetch('/api/transcript?' + q);
|
||
if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' });
|
||
} catch { lyrAsked.delete(t.key); }
|
||
});
|