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

+4 -3
View File
@@ -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. 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. - **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. - **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. - **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 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, 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. `{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 - **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 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. (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, 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 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 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. 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. - **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
+82 -9
View File
@@ -157,6 +157,7 @@ let activeTab = 'home';
function renderNav(active = activeTab) { function renderNav(active = activeTab) {
activeTab = active; activeTab = active;
if (typeof sgDockBadge === 'function') sgDockBadge(); if (typeof sgDockBadge === 'function') sgDockBadge();
if (typeof sgBadgeEverywhere === 'function') sgBadgeEverywhere();
const badge = n => n ? `<i class="nbadge">${n > 99 ? '99+' : n}</i>` : ''; 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 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>`; 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.signedIn) return plexSignIn();
if (!st.server) return plexPickServer(); if (!st.server) return plexPickServer();
await musicRoute(args, st); await musicRoute(args, st);
renderNpCard();
} catch (e) { } catch (e) {
if (e.auth) return plexSignIn(); 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>`; 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.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(); 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(); } 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); 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. // 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; clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
const tok = ++loadTok; pl.buf = true; const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause(); if (delay) pl.a.pause();
setMeta(t); renderMini(); updateNow(); syncUI(); markPlaying(); saveState(); setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
const go = () => { const go = () => {
if (tok !== loadTok) return; if (tok !== loadTok) return;
const hit = tc.m.get(t.src); const hit = tc.m.get(t.src);
@@ -1243,8 +1246,8 @@ function recover(now) {
}, now ? 300 : 4000); }, now ? 300 : 4000);
} }
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); } 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 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(); toast('Added to queue'); }; const queueTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.push(t); saveState(); npQueueCount(); toast('Added to queue'); };
function trackSheet(t) { function trackSheet(t) {
actionSheet(t.title, t.artist, [ actionSheet(t.title, t.artist, [
{ label: 'Play next', icon: 'next', run: () => playNextTrack(t) }, { label: 'Play next', icon: 'next', run: () => playNextTrack(t) },
@@ -1331,12 +1334,12 @@ let miniSwiped = false;
function stopPlayer() { 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 {} 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'; } 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. // Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
function modeUI() { function modeUI() {
document.querySelectorAll('#ms, #nps').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); }); document.querySelectorAll('#ms, #nps, #cns').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('#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() { function syncUI() {
const a = pl.a, d = a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0; 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 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 c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 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. // The Now Playing sheet stays open while tracks change, so refresh what it shows.
function updateNow() { function updateNow() {
@@ -1469,12 +1498,28 @@ function viewSettings() {
<input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}"> <input type="text" id="ab-u" autocomplete="off" autocapitalize="off" value="${esc(abCreds().u)}">
<label for="ab-p">Password</label> <label for="ab-p">Password</label>
<input type="password" id="ab-p" autocomplete="new-password" value="${esc(abCreds().p)}"></div> <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 // only show what this person may use
const hide = (cond, sel) => { if (cond) document.querySelectorAll(sel).forEach(e => e.remove()); }; 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'); 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 (!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 = () => { 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 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; 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); }; 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; } } 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; let sgEvT = null;
function sgEvent(ev) { function sgEvent(ev) {
if (typeof ev.unread === 'number' && ev.unread !== sg.unread) { sg.unread = ev.unread; renderNav(activeTab); } 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; if (!sgMounted()) return;
clearTimeout(sgEvT); sgEvT = setTimeout(() => { if (ev.t === 'status') return sgMount(sg.root, sg.dock); sgRefresh(); }, 150); 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 .sgm .sgmsgs { padding: 8px 4px; }
.sgdockin .sgb { max-width: 86%; } .sgdockin .sgb { max-width: 86%; }
@media (display-mode: standalone) { #sgfab { bottom: calc(var(--ph, 0px) + 22px + var(--sab, 0px)); } } @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; }
}
+2 -2
View File
@@ -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())} 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(){}} 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 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 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:()=>[]}, 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(){}}, 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:''}}; 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); 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);