Home dashboard: live Now Playing card; report playback to Plex so it shows in the Plex dashboard
build-container / build (push) Successful in 12m40s

The Home Now Playing block is now the same live card as the Music tab.
The player sends Plex timeline updates (playing/paused/stopped, position every 10 s).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 15:14:34 -04:00
1 parent fc0ae05415
commit b0d3aec811
3 files changed
+31 -11

No files matched your search

+21 -8
View File
@@ -244,7 +244,7 @@ function viewHome() {
const t = typeof cur === 'function' ? cur() : null;
$('#view').innerHTML = `<div class="wrap"><h1>${greet}</h1>
${allowed('search') ? `<a class="searchbar" href="#/search">${icon('search')}<span>Search tasks, events, files, music</span></a>` : ''}
${t && allowed('music') ? `<h2>Now playing</h2><button class="row card2" id="hnp"><img class="art" style="width:48px;height:48px;border-radius:8px" alt="" src="${px.art(t.thumb, 100)}"><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>` : ''}
${allowed('music') ? '<div id="npslot"></div>' : ''}
<div id="tcard"></div>
<div id="agenda"></div>
${recents.length && allowed('files') ? `<h2>Recent files</h2><div class="list">${recents.map((r, i) => `<button class="row" data-r="${i}"><span class="ic" style="background:${OFFICE_EXT.has(ext(r.name)) ? '#e0742b' : '#6a7280'}">${icon('file')}</span><span class="grow"><b>${esc(r.name)}</b><small>${esc(r.path.slice(0, r.path.lastIndexOf('/')) || '/')}</small></span></button>`).join('')}</div>` : ''}
@@ -263,7 +263,7 @@ function viewHome() {
${allowed('mail') ? `<h2>Mail</h2><div class="grid">
${MAIL.map(m => tile(mailUrl(m, 'inbox'), m.color, 'mail', m.name, 'Open inbox', true)).join('')}
</div>` : ''}</div>`;
const np = $('#hnp'); if (np) np.onclick = openNow;
renderNpCard(); // the live Now Playing card (it follows the song and has the controls)
$('#view').querySelectorAll('button[data-r]').forEach(b => b.onclick = () => {
const r = recents[b.dataset.r]; openFile(r.repo, r.path, r.name, r.path.slice(0, r.path.lastIndexOf('/')) || '/');
});
@@ -1285,18 +1285,29 @@ 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) => 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 && !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', () => { pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
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(); });
@@ -1334,6 +1345,7 @@ function renderMini() {
}
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();
@@ -1365,12 +1377,13 @@ function syncUI() {
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, 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, and at the top of every Music screen (the albums, artists and playlists stay right below it).
function renderNpCard() {
const old = $('#npcard'), wrap = document.querySelector('#view .wrap'), t = cur();
if (!location.hash.startsWith('#/music') || !wrap || !t) { if (old) old.remove(); return; }
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 (!wrap.contains(el)) wrap.prepend(el);
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>