diff --git a/public/app.js b/public/app.js index 9b502db..8fe8c6e 100644 --- a/public/app.js +++ b/public/app.js @@ -55,6 +55,9 @@ async function loadSettings() { const ICONS = { film: '', + queue: '', + visuals: '', + moon: '', lyrics: '', chat: '', clip: '', diff --git a/public/lyrics-view.js b/public/lyrics-view.js index 197a775..938a75f 100644 --- a/public/lyrics-view.js +++ b/public/lyrics-view.js @@ -21,7 +21,7 @@ function lyrParseLrc(text) { } 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 lyr = { fs: false, 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 @@ -77,41 +77,49 @@ async function lyrLoad(t, force) { return lyrForRadio(t, res); } -function lyrOpen() { - const t = cur(); if (!t || lyr.open) return; - lyr.open = true; document.body.classList.add('lyropen'); +// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom) +function lyrOpen(fs) { + const t = cur(); if (!t) return; + if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; } + lyr.fs = fs === true; 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(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true }); 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'); + if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen'); } const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); }; +const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : ''); function lyrShell() { const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return; - lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : ''; + lyr.el.className = lyrCls(); lyr.el.style.setProperty('--lf', pref.size + 'px'); lyr.el.innerHTML = `
${esc(t.title)}${esc(t.artist)}
+
${esc(t.title)}${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}

Looking for the lyrics…

+
0:000:00
-
`; + `; 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) + const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; }; + $l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); + if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {}); $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; }; + $l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $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(); + modeUI(); syncUI(); } async function lyrReload(force) { const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2; @@ -129,14 +137,21 @@ function lyrDraw() { 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; + body.innerHTML = `

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

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

This one is instrumental.

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

This one is instrumental.

'; lyrQueue(body); 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; } +/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */ +function lyrQueue(body) { + if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return; + const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40); + body.insertAdjacentHTML('beforeend', `

Up next

${rows.map(({ t, i }) => ``).join('')}
`); + body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); }); +} function lyrSearchAgain() { const t = cur(); if (!t) return; const q = lyrQuery(t); const el = openSheet('Search for the lyrics', '', `
diff --git a/public/player.js b/public/player.js index c284d5a..22ae070 100644 --- a/public/player.js +++ b/public/player.js @@ -195,12 +195,12 @@ function renderMini() { el.hidden = false; el.innerHTML = `
- +
`; $('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); }; $('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); - $('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; + $('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = vizOpen; $('#mq').onclick = openQueue; $('#mz').onclick = sleepSheet; // tap or drag along the thin bar to seek const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; }; sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; }; @@ -221,8 +221,8 @@ function stopPlayer() { } // Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state. function modeUI() { - document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); }); - document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }); + document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); }); + document.querySelectorAll('#mr, #npr, #cnr, #fsr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }); } function syncUI() { const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0; @@ -246,15 +246,18 @@ function syncUI() { const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; } const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000); const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000); + const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT; + const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000); + const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000); const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; } } -// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it). +// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls). function renderNpCard() { - const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music'); - const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null; + const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'); + const host = onHome ? $('#npslot') : null; if (!host || !t || !allowed('music')) { if (old) old.remove(); return; } const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard'; - if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); } + if (!host.contains(el)) host.appendChild(el); el.innerHTML = `
NOW PLAYING${esc(t.title)}${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
0:000:00
@@ -273,22 +276,25 @@ function renderNpCard() { function updateNow() { const t = cur(), sk = $('#npk'); if (!t || !sk) return; $('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600); - const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next'; + const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1); delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000; modeUI(); } function openNow() { const t = cur(); if (!t) return; - const el = openSheet('', '', `

${esc(t.title)}

-

${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}

-
0:000:00
-
-
-
${castOk(pl.a) ? '' : ''}
`); + const chip = (id, ic, label) => ``; + const el = openSheet('', '', `

${esc(t.title)}

+

${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}

+
0:000:00
+
+
+
${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}
`); + el.querySelector('.sheet').classList.add('npsheet'); const sk = el.querySelector('#npk'); sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); }; sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; }; el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a); + el.querySelector('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); }; el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint); el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off'); diff --git a/public/style.css b/public/style.css index 53a8c0e..c25dd0d 100644 --- a/public/style.css +++ b/public/style.css @@ -1112,3 +1112,40 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text- .otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; } .otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; } .sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; } + +/* Now Playing sheet: cover, title, seek, controls, and a row of extras */ +#sheet .sheet.npsheet h3:empty { display: none; } +#sheet .sheet.npsheet { padding-top: 14px; } +.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); } +.np .npmeta { padding: 0 8px; } +.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.np .npseek { margin: 16px 0 0; } +.np .ctl { gap: 8px; margin: 6px 0 14px; } +.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; } +.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); } +.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; } +.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; } +.npchip.on { color: var(--danger); border-color: currentColor; } +/* full screen: art and words side by side, the player along the bottom */ +#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; } +#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; } +#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; } +#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; } +#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; } +#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; } +#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; } +#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; } +#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); } +#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; } +#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; } +@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } } + +/* no lyrics: the queue fills the space */ +.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; } +.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } +.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); } +.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); } +.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; } +#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }