Music: drop the duplicate player card, add Visuals/Queue/Sleep to the bottom bar, redesign Now Playing sheet, add full-screen view, show queue when no lyrics
build-container / build (push) Failing after 52s
build-container / build (push) Failing after 52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f482a1e1a
commit
3db4e4aa1e
4 files changed
+87
-26
No files matched your search
@@ -55,6 +55,9 @@ async function loadSettings() {
|
||||
|
||||
const ICONS = {
|
||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
||||
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
|
||||
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
|
||||
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
|
||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||
|
||||
+26
-11
@@ -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 = `<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="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></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>`;
|
||||
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><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><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</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)
|
||||
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 = `<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;
|
||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
|
||||
}
|
||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; 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 ? `<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;
|
||||
}
|
||||
/** 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', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
|
||||
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', '', `<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">
|
||||
|
||||
+21
-15
@@ -195,12 +195,12 @@ function renderMini() {
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||
$('#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 = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
||||
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||
@@ -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('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</button>' : ''}<button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></div>
|
||||
<div class="npseek"><input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div></div>
|
||||
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
|
||||
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${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')}</div></div>`);
|
||||
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');
|
||||
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user