'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 {} }; // Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds // behind that, so radio starts 5 seconds early and the - and + buttons move it a second at a time (remembered for that station). const lyrIsRadio = t => !!t && String(t.key).startsWith('radio:'); const lyrId = t => lyrIsRadio(t) ? t.key + '|' + t.artist + '|' + t.title : t.key; // (a station is one "track" that plays many songs) const lyrStep = t => lyrIsRadio(t) ? 1000 : 500; const lyrPos = t => lyrIsRadio(t) ? Date.now() - ((t.song && t.song.startedAt) || Date.now()) + lyrOffset(t.key) : pl.a.currentTime * 1000 + lyrOffset(t.key); const lyrOffset = k => { try { const v = localStorage.getItem('lyr_off:' + k); return v === null ? (String(k).startsWith('radio:') ? -5000 : 0) : +v || 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:' + lyrId(t))) || { 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 = `

Making a transcript on your server… ${Math.max(0, Math.round((Date.now() - r.since) / 60000))} min so far.
You can close this: it keeps going, and it is kept for next time.

`; await new Promise(ok => setTimeout(ok, 4000)); } } const lyrForRadio = (t, res) => lyrIsRadio(t) && res && res.found && res.sync !== 'none' && !(t.song && t.song.startedAt) ? { ...res, sync: 'none' } : res; // a live station only has a clock when it says when the song began async function lyrLoad(t, force) { const id = lyrId(t); if (!force && lyr.cache.has(id)) return lyrForRadio(t, lyr.cache.get(id)); if (lyrIsRadio(t) && !t.song) return { found: false, error: 'This station does not say which song is playing, so there is nothing to look up.' }; let res = force || lyrIsRadio(t) ? 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(id, res); return lyrForRadio(t, 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 = `
${esc(t.title)}${esc(t.artist)}

Looking for the lyrics…

`; 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(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur())); syncUI(); } async function lyrReload(force) { const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2; const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '

Looking for the lyrics…

'; const res = lyrSpokenKey(t) ? await lyrSpoken(t, force === 'start') : await lyrLoad(t, force); if (!res || !lyr.el || lyr.key !== lyrId(t)) 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 = `

${d && d.error ? esc(d.error) : 'This one has no published transcript.'}

${d && d.why && !d.error ? `

${esc(d.why)}

` : ''}${d && d.canTranscribe ? `

Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.

` : '

An admin can add the whisper service to turn on transcribing.

'}`; const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return; } 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') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : ''); 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' || lyrIsRadio(t)) 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:' + lyrId(t), JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(lyrId(t)); 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 = lyrPos(t), 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); } });