Now Playing card on the Music tab; Signal alerts (chime, desktop notification, unread count in tab title and app icon)
build-container / build (push) Successful in 7m4s

- Music: a card with art, seek bar and controls above the albums, artists and playlists
- Signal: chime, optional desktop notification when the app is in the background, (n) in the tab title, app badge; Settings -> Signal alerts (per device)
- README, player test stand-in updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 10:47:15 -04:00
1 parent d0070aa0bb
commit 522fdba4cd
4 files changed
+111 -14

No files matched your search

+82 -9
View File
@@ -157,6 +157,7 @@ let activeTab = 'home';
function renderNav(active = activeTab) {
activeTab = active;
if (typeof sgDockBadge === 'function') sgDockBadge();
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : '';
const tab = ([id, label]) => `<a href="#/${id}" class="${id === active ? 'on' : ''}" ${id === active ? 'aria-current="page"' : ''}>${icon(TAB_ICON[id])}<span>${label}</span>${id === 'signal' ? badge(sg.unread) : ''}</a>`;
const brand = `<div class="brand"><img src="/icon.svg" alt=""><span>NE Hub</span></div>`;
@@ -1035,6 +1036,7 @@ async function viewMusic(args) {
if (!st.signedIn) return plexSignIn();
if (!st.server) return plexPickServer();
await musicRoute(args, st);
renderNpCard();
} catch (e) {
if (e.auth) return plexSignIn();
v.innerHTML = `<div class="wrap"><h1>Music</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="rs">Change server</button></div></div>`;
@@ -1160,6 +1162,7 @@ function setShuffle(on) {
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.
@@ -1206,7 +1209,7 @@ function loadCur(delay = 220) {
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(); syncUI(); markPlaying(); saveState();
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
@@ -1243,8 +1246,8 @@ function recover(now) {
}, 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(); toast('Playing next'); };
const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); toast('Added to queue'); };
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) {
actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
@@ -1331,12 +1334,12 @@ let miniSwiped = false;
function stopPlayer() {
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(); closeSheet(); markPlaying();
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying();
}
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() {
document.querySelectorAll('#ms, #nps').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
document.querySelectorAll('#mr, #npr').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').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;
@@ -1352,6 +1355,32 @@ function syncUI() {
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);
// 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, 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 el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
if (!wrap.contains(el)) wrap.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-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</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('#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() {
@@ -1469,12 +1498,28 @@ function viewSettings() {
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div>
</div><h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
</div><div id="sec-signal"><h2>Signal alerts</h2><p class="sub">How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.</p>
<div class="list"><label class="row" style="cursor:pointer"><span class="grow"><b>Play a sound</b><small>A short chime</small></span><input type="checkbox" id="sga-s"></label>
<label class="row" style="cursor:pointer"><span class="grow"><b>Desktop notification</b><small id="sga-ns">Pops up even when this tab is in the background</small></span><input type="checkbox" id="sga-n"></label></div>
<div class="bar"><button class="btn ghost" id="sga-t">Play test sound</button></div></div><h2>Install</h2><p class="sub">iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.</p></div>`;
// only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); };
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('mail'), '#sec-mail'); hide(!allowed('audio'), '#sec-audio'); hide(!allowed('signal'), '#sec-signal'); hide(!(allowed('files') || allowed('office')), '#row-sf, #out');
hide(!allowed('music'), '#row-plex'); hide(!allowed('office'), '#row-oo');
if (!document.querySelector('#sec-conn .list .row')) hide(true, '#sec-conn'); // nothing to show here for this person
if ($('#sga-s')) {
const a = sgAlerts(), ns = $('#sga-ns'); $('#sga-s').checked = a.sound; $('#sga-n').checked = a.notify && 'Notification' in window && Notification.permission === 'granted';
if (!('Notification' in window)) { $('#sga-n').disabled = true; ns.textContent = 'This browser does not support notifications'; }
else if (Notification.permission === 'denied') { $('#sga-n').disabled = true; ns.textContent = 'Blocked in the browser settings for this site; allow notifications there first'; }
$('#sga-s').onchange = e => { sgSetAlerts({ sound: e.target.checked }); if (e.target.checked) sgChime(); };
$('#sga-n').onchange = async e => {
if (!e.target.checked) return sgSetAlerts({ notify: false });
let p = Notification.permission; if (p === 'default') p = await Notification.requestPermission();
if (p === 'granted') { sgSetAlerts({ notify: true }); try { new Notification('Signal alerts are on', { body: 'You will see new messages here when this tab is in the background.', icon: '/icons/icon-192.png' }); } catch {} }
else { e.target.checked = false; sgSetAlerts({ notify: false }); toast('Notifications are blocked. Allow them for this site in the browser settings.'); }
};
$('#sga-t').onclick = () => { sgUnlockAudio(); sgChime(); if (!sgAC || sgAC.state !== 'running') toast('Click anywhere on the page once, then try again (the browser allows sound only after that)'); };
}
document.querySelectorAll('input[data-on]').forEach(c => c.onchange = () => {
const on = [...document.querySelectorAll('input[data-on]:checked')].map(x => x.dataset.on); store.set('mail_on', on);
const h = document.getElementById('h-' + c.dataset.on); if (h) h.hidden = !c.checked;
@@ -2059,10 +2104,38 @@ async function sgStart() { // keep the unread count and live updates running wha
sg.es.onmessage = e => { let ev; try { ev = JSON.parse(e.data); } catch { return; } sgEvent(ev); };
}
function sgStop() { if (sg.es) { sg.es.close(); sg.es = null; } }
// ---- Getting your attention: a chime, a desktop notification when the app is in the background, the count in the tab title and on the app icon.
const sgAlerts = () => { try { return { sound: true, notify: false, ...JSON.parse(localStorage.getItem('sg_alerts')) }; } catch { return { sound: true, notify: false }; } };
const sgSetAlerts = p => { try { localStorage.setItem('sg_alerts', JSON.stringify({ ...sgAlerts(), ...p })); } catch {} };
let sgAC = null;
function sgUnlockAudio() { try { sgAC = sgAC || new (window.AudioContext || window.webkitAudioContext)(); if (sgAC.state === 'suspended') sgAC.resume(); } catch {} } // a browser only allows sound after you have used the page once
['pointerdown', 'keydown', 'touchstart'].forEach(e => addEventListener(e, sgUnlockAudio, { passive: true }));
function sgChime() {
sgUnlockAudio(); if (!sgAC || sgAC.state !== 'running') return;
const t = sgAC.currentTime;
[[880, 0], [1318, 0.14]].forEach(([f, d]) => { const o = sgAC.createOscillator(), g = sgAC.createGain(); o.type = 'sine'; o.frequency.value = f;
g.gain.setValueAtTime(0.0001, t + d); g.gain.exponentialRampToValueAtTime(0.22, t + d + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, t + d + 0.4);
o.connect(g); g.connect(sgAC.destination); o.start(t + d); o.stop(t + d + 0.45); });
}
function sgBadgeEverywhere() {
document.title = (sg.unread ? '(' + (sg.unread > 99 ? '99+' : sg.unread) + ') ' : '') + 'NE Hub';
try { if (navigator.setAppBadge) { sg.unread ? navigator.setAppBadge(sg.unread) : navigator.clearAppBadge(); } } catch {}
}
function sgAlert(ev) { // a message from someone else arrived and you are not looking at that chat
const a = sgAlerts();
if (a.sound) sgChime();
if (a.notify && 'Notification' in window && Notification.permission === 'granted' && (document.hidden || !document.hasFocus())) {
try { const n = new Notification(ev.name || 'Signal', { body: ev.preview || 'New message', tag: 'sg:' + ev.conv, icon: '/icons/icon-192.png', silent: true });
n.onclick = () => { window.focus(); n.close(); sg.cur = ev.conv; sgSaveDock(); if (sgOnView()) location.hash = '#/signal/' + encodeURIComponent(ev.conv); else { sg.dockOpen = true; sgSaveDock(); sgDockSync(activeTab); } }; } catch { /* the browser refused */ }
}
}
let sgEvT = null;
function sgEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); }
if (ev.t === 'msg' && !ev.out && !(sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible')) toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || ''));
if (ev.t === 'msg' && !ev.out) {
const looking = sgMounted() && sg.cur === ev.conv && document.visibilityState === 'visible' && document.hasFocus();
if (!looking) { toast('Signal · ' + (ev.name || '') + ': ' + (ev.preview || '')); sgAlert(ev); }
}
if (!sgMounted()) return;
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150);
}
+23
View File
@@ -423,3 +423,26 @@ body:not(.editing) #view { padding-top: 8px; }
.sgdockin .sgm .sgmsgs { padding: 8px 4px; }
.sgdockin .sgb { max-width: 86%; }
@media (display-mode: standalone) { #sgfab { bottom: calc(var(--ph, 0px) + 22px + var(--sab, 0px)); } }
/* Now Playing card on the Music tab */
.npcard { display: grid; grid-template-columns: 112px 1fr; grid-template-areas: "art text" "art seek" "art ctl"; gap: 4px 18px; align-items: center; background: var(--panel); border: 1px solid var(--line); border-radius: 18px; padding: 14px; margin: 12px 0 20px; }
.npc-art { grid-area: art; border: 0; padding: 0; background: var(--panel2); width: 112px; height: 112px; border-radius: 14px; overflow: hidden; cursor: pointer; align-self: start; }
.npc-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.npc-text { grid-area: text; min-width: 0; align-self: end; }
.npc-text b, .npc-text small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.npc-text b { font-size: 18px; } .npc-text small { color: var(--muted); }
.npc-lbl { font-size: 11px; letter-spacing: .08em; color: var(--accent) !important; font-weight: 700; margin-bottom: 2px; }
.npc-seek { grid-area: seek; display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.npc-seek input { flex: 1; accent-color: var(--accent); min-width: 0; }
.npc-ctl { grid-area: ctl; display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.npc-ctl .ibtn.on { color: var(--accent); position: relative; }
.npc-ctl .ibtn { position: relative; }
.npc-ctl .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; right: 8px; bottom: 6px; }
.npc-ctl .ibtn.big { width: 52px; height: 52px; } .npc-ctl .ibtn.big svg { width: 26px; height: 26px; }
.npc-q { margin-left: auto; min-height: 38px; font-size: 14px; }
@media (max-width: 560px) {
.npcard { grid-template-columns: 76px 1fr; grid-template-areas: "art text" "seek seek" "ctl ctl"; padding: 12px; gap: 8px 12px; }
.npc-art { width: 76px; height: 76px; border-radius: 12px; align-self: center; }
.npc-text { align-self: center; } .npc-text b { font-size: 16px; }
.npc-ctl { justify-content: space-between; } .npc-q { display: none; }
}