Files
hub/public/lyrics-view.js
T
nmemmertandClaude Sonnet 5.5 2d5aaad41a
build-container / build (push) Successful in 6m21s
Music: Mixes, Favorites and Stats; smart and Family playlists; library radio; offline albums; more
- Mixes tab: daily mix, most played, forgotten favorites, moods, on this day, decades, genres, smart playlists (rules), library radio
- Favorites (hearts, kept by the hub) and Stats (plays, time, top artists/songs/albums, streak, recently played)
- Family playlists shared by everyone with Music; queue reorder; More like this; album/artist About notes
- Save albums offline; player takes colors from the cover; Share a lyric line as a picture; verse of the day on Featured
- musicdata.js keeps plays, favorites, smart rules and Family playlists, with tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 10:33:14 -04:00

205 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 || '', title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' });
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));
}
}
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 && store.get('lookups', true) !== false) {
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 = `<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><button class="btn ghost" id="lyrsh">Share a line</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; $l('#lyrsh').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; $l('#lyrsh').onclick = lyrShare;
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 = '<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 !== 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 = `<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') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
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' || 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', '', `<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:' + 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, title: t.title || '', artist: t.artist || '', thumb: t.thumb || '' }), r = await jfetch('/api/transcript?' + q);
if (r.state === 'none' && r.canTranscribe) await jfetch('/api/transcript?' + q, { method: 'POST' });
} catch { lyrAsked.delete(t.key); }
});
// ---- Share a line: the lyric over the album cover, as a picture (shared through the phone's share sheet, or downloaded)
function lyrShare() {
const t = cur(), d = lyr.data; if (!t || !d || !d.found || d.instrumental || !d.lines) return toast('There are no lyrics to share yet');
const text = l => l.words ? l.words.map(w => w.text).join(' ') : l.text, rows = d.lines.map((l, i) => ({ i, s: (text(l) || '').trim() })).filter(x => x.s).slice(0, 250);
const now = d.sync !== 'none' && lyr.idx >= 0 ? lyr.idx : -1, el = openSheet('Share a line', 'Tick up to four lines for the picture.', `<div class="list" style="max-height:48dvh;overflow:auto">${rows.map(x => `<label class="row" style="cursor:pointer"><input type="checkbox" data-l="${x.i}"${x.i === now ? ' checked' : ''}><span class="grow" style="white-space:normal">${esc(x.s)}</span></label>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="shx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="shgo" style="flex:1;justify-content:center">Make the picture</button></div>`);
el.querySelector('#shx').onclick = closeSheet;
el.querySelectorAll('input[data-l]').forEach(c => c.onchange = () => { if (el.querySelectorAll('input[data-l]:checked').length > 4) c.checked = false; });
el.querySelector('#shgo').onclick = async () => {
const picked = [...el.querySelectorAll('input[data-l]:checked')].map(c => rows.find(x => x.i === +c.dataset.l).s); if (!picked.length) return toast('Tick at least one line');
closeSheet();
try { const blob = await lyrCard(t, picked), file = new File([blob], (t.title || 'lyric').replace(/[^\w -]+/g, '') + '.png', { type: 'image/png' });
if (navigator.canShare && navigator.canShare({ files: [file] })) await navigator.share({ files: [file], title: t.title, text: t.title + ' — ' + t.artist }).catch(e => { if (e.name !== 'AbortError') throw e; });
else { const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = file.name; a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 4000); toast('Saved the picture'); } } catch (e) { toast(e.message || 'Could not make the picture'); }
};
}
async function lyrCard(t, lines) {
const W = 1080, H = 1350, cv = document.createElement('canvas'); cv.width = W; cv.height = H; const x = cv.getContext('2d');
const img = await new Promise(ok => { const im = new Image(); im.onload = () => ok(im); im.onerror = () => ok(null); im.src = px.art(t.thumb, 800); });
x.fillStyle = '#14161b'; x.fillRect(0, 0, W, H);
if (img) { x.save(); x.filter = 'blur(40px) saturate(1.3)'; const k = Math.max(W / img.width, H / img.height) * 1.2; x.drawImage(img, (W - img.width * k) / 2, (H - img.height * k) / 2, img.width * k, img.height * k); x.restore(); }
const g = x.createLinearGradient(0, 0, 0, H); g.addColorStop(0, 'rgba(0,0,0,.35)'); g.addColorStop(1, 'rgba(0,0,0,.75)'); x.fillStyle = g; x.fillRect(0, 0, W, H);
const text = lines.join('\n'), size = text.length > 160 ? 52 : text.length > 90 ? 62 : 74; x.fillStyle = '#fff'; x.font = `700 ${size}px system-ui, -apple-system, Segoe UI, sans-serif`; x.textAlign = 'center'; x.textBaseline = 'middle';
const wrapped = []; for (const ln of lines) { let cur1 = ''; for (const w of ln.split(/\s+/)) { const tryl = cur1 ? cur1 + ' ' + w : w; if (x.measureText(tryl).width > W - 160 && cur1) { wrapped.push(cur1); cur1 = w; } else cur1 = tryl; } wrapped.push(cur1); }
const lh = size * 1.3, y0 = H / 2 - ((wrapped.length - 1) * lh) / 2 - 40; wrapped.forEach((ln, i) => x.fillText(ln, W / 2, y0 + i * lh));
if (img) { const s = 150, ax = 100, ay = H - 250; x.save(); x.beginPath(); x.roundRect ? x.roundRect(ax, ay, s, s, 18) : x.rect(ax, ay, s, s); x.clip(); x.drawImage(img, ax, ay, s, s); x.restore(); }
x.textAlign = 'left'; x.font = '700 40px system-ui, sans-serif'; x.fillText(t.title.slice(0, 34), 280, H - 200); x.font = '400 34px system-ui, sans-serif'; x.fillStyle = 'rgba(255,255,255,.75)'; x.fillText((t.artist || '').slice(0, 40), 280, H - 150);
return new Promise((ok, no) => cv.toBlob(b => b ? ok(b) : no(new Error('Could not make the picture')), 'image/png'));
}