build-container / build (push) Canceled after 2m57s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
284 lines
24 KiB
JavaScript
284 lines
24 KiB
JavaScript
'use strict';
|
|
// ---- Player: one <audio> element that lives outside the views, so music keeps playing as you move around.
|
|
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
|
|
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
|
|
const cur = () => pl.q[pl.i];
|
|
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
|
|
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
|
|
function setShuffle(on) {
|
|
if (on === pl.shuffle || !pl.q.length) return;
|
|
const t = cur();
|
|
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
|
|
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
|
|
pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
|
|
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow(); modeUI();
|
|
}
|
|
function npQueueCount() { const q = $('#cnq'); if (q) q.textContent = 'Queue · ' + Math.max(0, pl.q.length - pl.i - 1) + ' up next'; }
|
|
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); modeUI(); }
|
|
const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
|
|
// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
|
|
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
|
|
// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
|
|
// so changing songs is instant and a slow or lost connection does not stop the music.
|
|
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
|
const tc = { m: new Map(), ctl: null, busy: false };
|
|
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
|
|
function tcTrim() {
|
|
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
|
|
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
|
|
}
|
|
async function tcFetch(src, signal) {
|
|
for (let n = 0; n < 4 && !signal.aborted; n++) {
|
|
try {
|
|
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
|
|
const b = await r.blob(); // throws if the connection drops part way
|
|
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
|
|
return;
|
|
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
|
}
|
|
}
|
|
async function tcPump() {
|
|
if (tc.busy) return; tc.busy = true;
|
|
try {
|
|
for (;;) {
|
|
tcTrim();
|
|
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
|
|
tc.ctl = new AbortController(); tc.cur = src;
|
|
await tcFetch(src, tc.ctl.signal);
|
|
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
|
|
}
|
|
} finally { tc.busy = false; tc.ctl = null; }
|
|
}
|
|
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
|
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
|
|
|
let loadTok = 0, loadTimer = null, stallTimer = null;
|
|
pl.retries = 0; pl.buf = false;
|
|
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
|
|
function loadCur(delay = 220) {
|
|
const t = cur(); if (!t) return;
|
|
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
|
|
const tok = ++loadTok; pl.buf = true;
|
|
if (delay) pl.a.pause();
|
|
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
|
if (lyr.open) { lyrShell(); lyrReload(); }
|
|
const go = () => {
|
|
if (tok !== loadTok) return;
|
|
const hit = tc.m.get(t.src);
|
|
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
|
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
|
|
tcKick();
|
|
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
|
|
};
|
|
if (delay) loadTimer = setTimeout(go, delay); else go();
|
|
}
|
|
function setMeta(t) {
|
|
if (!('mediaSession' in navigator)) return;
|
|
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
|
|
}
|
|
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
|
|
function recover(now) {
|
|
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
|
if (!t || a.paused) return;
|
|
stallTimer = setTimeout(() => {
|
|
if (a.paused || (a.readyState >= 3 && !a.error)) return;
|
|
const hit = tc.m.get(t.src), pos = a.currentTime;
|
|
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
|
if (bad && !hit) {
|
|
if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
|
|
else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
|
|
}
|
|
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
|
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
|
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
|
|
if (!hit || a.src !== hit.url) pl.retries++;
|
|
a.src = want;
|
|
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
|
recover();
|
|
}, now ? 300 : 4000);
|
|
}
|
|
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
|
|
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); npQueueCount(); toast('Playing next'); };
|
|
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
|
|
function trackSheet(t, extra) {
|
|
actionSheet(t.title, t.artist, [
|
|
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
|
|
{ label: 'Add to queue', icon: 'files', run: () => queueTrack(t) },
|
|
{ label: 'Play now', icon: 'play', run: () => playQueue([t], 0) },
|
|
{ label: 'Add to playlist…', icon: 'plus', run: () => addToPlaylist([t]) },
|
|
...(extra || []),
|
|
]);
|
|
}
|
|
// Restore the last queue and position on launch (paused, so nothing starts by itself).
|
|
function restorePlayer() {
|
|
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
|
|
const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
|
|
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
|
|
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
|
|
renderMini(); syncUI();
|
|
}
|
|
function openQueue() {
|
|
const upcoming = pl.q.map((t, i) => ({ t, i }));
|
|
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
|
|
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
|
|
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
|
|
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="qsp" style="flex:1;justify-content:center">Save as playlist</button></div>
|
|
<div class="bar" style="margin-top:0"><button class="btn ghost" id="qsl" style="flex:1;justify-content:center">${esc(sleepLabel())} timer</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
|
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
|
el.querySelector('#qsp').onclick = () => { const q = pl.q.slice(); closeSheet(); addToPlaylist(q, 'Queue ' + new Date().toLocaleDateString([], { month: 'short', day: 'numeric' })); };
|
|
el.querySelector('#qsl').onclick = () => { closeSheet(); sleepSheet(); };
|
|
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
|
|
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
|
|
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
|
|
}
|
|
function advance(auto) {
|
|
if (auto && sleepT.eot) { sleepClear(); pl.a.pause(); syncUI(); toast('Sleep timer: music stopped'); return; }
|
|
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
|
|
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
|
|
loadCur(auto ? 0 : 220);
|
|
}
|
|
const next = () => advance(false);
|
|
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
|
|
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
|
|
pl.a.preload = 'auto';
|
|
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
|
|
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
|
|
const tlSend = (state, key, pos, dur) => String(key).startsWith('pod:') ? 0 : fetch('/plex/:/timeline?' + new URLSearchParams({ ratingKey: key, key: '/library/metadata/' + key, identifier: 'com.plexapp.plugins.library', state, time: Math.round(pos), duration: Math.round(dur), hasMDE: 1 })).catch(() => {});
|
|
function tlReport(state) {
|
|
const t = cur(); if (!t) return;
|
|
if (tlKey && tlKey !== t.key) tlSend('stopped', tlKey, tlPos, tlDur); // the previous song is over
|
|
tlKey = t.key; tlAt = Date.now(); tlPos = (pl.a.currentTime || 0) * 1000; tlDur = isFinite(pl.a.duration) ? pl.a.duration * 1000 : 0;
|
|
tlSend(state, tlKey, tlPos, tlDur);
|
|
}
|
|
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
|
pl.a.addEventListener('ended', () => advance(true));
|
|
let lastSave = 0, lastPos = 0;
|
|
pl.a.addEventListener('timeupdate', () => {
|
|
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
|
if (tlKey) { tlPos = pl.a.currentTime * 1000; if (!pl.a.paused && Date.now() - tlAt > 10000) tlReport('playing'); }
|
|
const t = cur(), d = pl.a.duration;
|
|
if (t && String(t.key).startsWith('pod:')) { if (!pl.scrobbled && d && pl.a.currentTime > d * 0.9) { pl.scrobbled = true; fetch('/api/podcast/podcastitems/' + encodeURIComponent(t.key.slice(4)) + '/markPlayed').catch(() => {}); } } // a podcast episode counts as heard near its end
|
|
else if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
|
|
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
|
|
});
|
|
addEventListener('pagehide', saveState);
|
|
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
|
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
|
|
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
|
pl.a.addEventListener('canplaythrough', () => tcKick());
|
|
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
|
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
|
|
if ('mediaSession' in navigator) {
|
|
const ms = navigator.mediaSession;
|
|
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
|
|
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
|
|
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
|
|
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
|
|
}
|
|
function markPlaying() {
|
|
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
|
|
}
|
|
function renderMini() {
|
|
if (typeof videoBarOn === 'function' && videoBarOn()) return; // the bottom bar is showing the video's controls
|
|
const t = cur(), el = $('#player'); if (!t) return (el.hidden = true);
|
|
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" 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;
|
|
// 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; }; };
|
|
// swipe the track area: left for next, right for previous
|
|
const mini = el.querySelector('.mini'); let x0 = null, y0 = 0;
|
|
mini.onpointerdown = e => { if (e.target.closest('button:not(.meta), #mseek')) return; x0 = e.clientX; y0 = e.clientY; miniSwiped = false; };
|
|
mini.onpointerup = e => { if (x0 === null) return; const dx = e.clientX - x0, dy = e.clientY - y0; x0 = null;
|
|
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 2) { miniSwiped = true; setTimeout(() => { miniSwiped = false; }, 300); dx < 0 ? next() : prev(); } };
|
|
mini.onpointercancel = () => { x0 = null; };
|
|
modeUI(); syncUI();
|
|
}
|
|
let miniSwiped = false;
|
|
function stopPlayer() {
|
|
tlStop();
|
|
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
|
|
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
|
|
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
|
|
}
|
|
// 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); });
|
|
}
|
|
function syncUI() {
|
|
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
|
const state = pl.buf && !a.paused ? 'buf' : String(a.paused), ic = state === 'buf' ? icon('spin') : icon(a.paused ? 'play' : 'pause');
|
|
const mt = $('#mt'); if (mt && mt.dataset.s !== state) { mt.innerHTML = ic; mt.dataset.s = state; }
|
|
const bf = $('#mbuf'); if (bf) { let e = 0; try { for (let i = 0; i < a.buffered.length; i++) if (a.buffered.start(i) <= a.currentTime + 1) e = Math.max(e, a.buffered.end(i)); } catch {} bf.style.width = d ? Math.min(100, (e / d) * 100) + '%' : '0'; }
|
|
const tm = $('#mtm'); if (tm) { const v = fmtDur(a.currentTime * 1000) + ' / ' + fmtDur(d * 1000); if (tm.textContent !== v) tm.textContent = v; }
|
|
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
|
|
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
|
|
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
|
|
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
|
|
const nt = $('#npt'); if (nt && nt.dataset.s !== state) { nt.innerHTML = ic; nt.dataset.s = state; }
|
|
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
|
|
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
|
|
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
|
|
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
|
|
// the Now Playing card on the Music tab
|
|
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
|
|
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 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).
|
|
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;
|
|
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); }
|
|
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>
|
|
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
|
|
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
|
|
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button><button class="btn ghost npc-q" id="cnz">${esc(sleepLabel())}</button></div>`;
|
|
const sk = el.querySelector('#cnk');
|
|
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
|
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
|
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnz').onclick = sleepSheet; el.querySelector('#cnl').onclick = lyrOpen;
|
|
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
|
|
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
|
modeUI(); syncUI();
|
|
}
|
|
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
|
|
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';
|
|
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="btn ghost" id="npl">Lyrics</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 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('#nps').onclick = () => setShuffle(!pl.shuffle);
|
|
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
|
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
|
updateNow(); syncUI();
|
|
}
|