From 522fdba4cda8d3c0346b072ab53aa3ada477fa82 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:47:15 -0400 Subject: [PATCH] Now Playing card on the Music tab; Signal alerts (chime, desktop notification, unread count in tab title and app icon) - 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 --- README.md | 7 ++-- public/app.js | 91 +++++++++++++++++++++++++++++++++++++++----- public/style.css | 23 +++++++++++ tools/player-test.js | 4 +- 4 files changed, 111 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 337e32a..5b37f5b 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke for iOS 13+, so for most accounts only NE Hub lists are available. - **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser. - **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks. -- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the queue (play next / add to queue) and position are remembered. +- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Music tab shows a Now Playing card with the controls above the albums, artists and playlists; the queue (play next / add to queue) and position are remembered. - **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons. Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page, @@ -55,11 +55,12 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke `{u, id, exp, n}` and `sig` is the base64url HMAC-SHA256 of `body`. Without either, the plain site opens and the saved username can be copied. - **Signal**: your Signal chats inside the app, **on desktop-class screens only** (a window at least 900 px wide with a mouse or trackpad; phones and touch tablets don't see it). Besides the Signal tab there is a chat button on **every** screen that opens a panel over whatever you are doing (close it with the x; the - expand button opens the full page). It talks to your account through the community `signal-cli-rest-api` bridge, which is one more *linked device* of your account + expand button opens the full page). New messages get your attention with a toast, a chime, the unread count in the browser tab title and on the installed app's icon, and + (if you switch it on in Settings -> Signal alerts) a desktop notification when the app is in the background; sound and notifications are saved per device. It talks to your account through the community `signal-cli-rest-api` bridge, which is one more *linked device* of your account (like Signal Desktop; not an official Signal product). The `signal` container in `docker-compose.yml` runs it; remove that service and the hub's `SIGNAL_API_URL` line to skip Signal. First time: open the Signal tab, then on your phone Signal -> Settings -> Linked devices -> Link new device and scan the code. You can send and receive text, photos, files, voice messages (played, not recorded), replies, reactions, groups and read receipts; there are no calls. Only messages from after linking appear (Signal does - not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume: whoever gets into your server can read your chats, + not copy older ones to a new device) and they are kept in `/data/signal`. The link keys live in the `nehub-signal` volume (a Docker *volume*, not a host folder: the bridge runs as user 1000 and a root-owned host folder gives "permission denied"; if you do want a folder, `chown -R 1000:1000` it first): whoever gets into your server can read your chats, so only give the **Signal** tab to people you would trust with your Signal (it is one shared account, admins always have it). Signal drops a linked device that is not connected for about a month; the hub stays connected, so this only matters if the stack is switched off. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs. diff --git a/public/app.js b/public/app.js index 19957c3..f6327e1 100644 --- a/public/app.js +++ b/public/app.js @@ -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 ? `${n > 99 ? '99+' : n}` : ''; const tab = ([id, label]) => `${icon(TAB_ICON[id])}${label}${id === 'signal' ? badge(sg.unread) : ''}`; const brand = `
NE Hub
`; @@ -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 = `

Music

${esc(e.message)}

`; @@ -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 = ` +
NOW PLAYING${esc(t.title)}${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}
+
0:000:00
+
+ +
`; + 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() { -

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; +

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

+
+
+

Install

iPhone/iPad: Share → Add to Home Screen. Desktop Chrome/Edge: install icon in the address bar.

`; // 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); } diff --git a/public/style.css b/public/style.css index 8b62215..8b56137 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } +} diff --git a/tools/player-test.js b/tools/player-test.js index c5398bd..554ae4a 100644 --- a/tools/player-test.js +++ b/tools/player-test.js @@ -7,8 +7,8 @@ class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration= addEventListener(e,f){(this._l[e]=this._l[e]||[]).push(f)} fire(e){(this._l[e]||[]).forEach(f=>f())} play(){this.paused=false;this.fire('play');return Promise.resolve()} pause(){if(!this.paused){this.paused=true;this.fire('pause')}} getAttribute(){return this.src||null} load(){}} const toasts=[];const store={d:{},get(k,d){return this.d[k]??d},set(k,v){this.d[k]=v},del(k){delete this.d[k]}}; -const el=()=>({classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); -const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[]}, +const el=()=>({remove(){},classList:{toggle(){},add(){}},setAttribute(){},dataset:{},style:{},querySelector:()=>el(),querySelectorAll:()=>[],set innerHTML(v){},set textContent(v){}}); +const ctx={Audio:FakeAudio,store,toast:m=>toasts.push(m),px:{art:()=>'x'},icon:()=>'',esc:s=>s,fmtDur:()=>'',$:()=>el(),document:{querySelectorAll:()=>[],querySelector:()=>null}, navigator:{onLine:true},addEventListener:(e,f)=>{listeners[e]=f},setTimeout,clearTimeout,fetch:()=>new Promise(()=>{}),URL:{createObjectURL:()=>'blob:x',revokeObjectURL(){}}, Image:class{},MediaMetadata:class{},openSheet:()=>({}),closeSheet(){},actionSheet(){},Response,URLSearchParams,AbortController,Promise,Set,Map,Date,Math,saveStateHook:0,location:{hash:''}}; vm.createContext(ctx); vm.runInContext(code+";this.pl=pl;this.next=next;this.prev=prev;this.setShuffle=setShuffle;this.setRepeat=setRepeat;this.playQueue=playQueue;this.advance=advance;this.cur=cur;this.recover=recover;this.srcOf=srcOf;",ctx);