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
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:
1 parent
d0070aa0bb
commit
522fdba4cd
4 files changed
+111
-14
No files matched your search
+82
-9
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
Reference in new issue
Block a user