Seafile sign-in follows the person across devices; sturdier Plex player; audiobooks frame with server-side secret sign-in
build-container / build (push) Successful in 3m16s

- Seafile token is saved on the server (encrypted) so Settings shows the real signed-in state everywhere
- Player: whole-track cache for current and next two, stall recovery, instant track changes, shuffle/repeat, Plex play counts, transcode fallback, Plex request timeout
- Audio tab: toolbar, full-height kept-alive frame; /api/audiobooks/enter adds the secret login link on the server

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-21 08:26:18 -04:00
1 parent 1a29d500d3
commit bc3710807d
8 files changed
+263 -44

No files matched your search

+6
View File
@@ -13,3 +13,9 @@ VERSE_URL=https://versebyversewithnate.us/admin
PODCAST_URL=https://podcast.necloud.us/
# Days you stay signed in without using the app (it renews every time you do)
SESSION_DAYS=180
# Audiobooks secret sign-in link (optional; anyone holding it is signed in there, so never commit it)
AUDIOBOOKS_LOGIN_LINK=
# or per-person signed links (optional): shared secret the audiobooks site uses to verify signed links
AUDIOBOOKS_SSO_SECRET=
#AUDIOBOOKS_SSO_URL=https://audiobooks.necloud.us/sso
+8 -3
View File
@@ -39,10 +39,15 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
- **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud
account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded
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. Per-device items (Seafile sign-in token, music queue) stay 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.
- **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 queue (play next / add to queue) and position are remembered.
- **Audio**: embeds audiobooks.necloud.us; sign-in details saved in Settings can be copied with one tap.
- **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.
- **Audio**: 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 Audio tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
script or logs. Anyone holding the link is signed in there with full access, so keep it out of the repo and `.env` backups. Or set `AUDIOBOOKS_SSO_SECRET`
(and optionally `AUDIOBOOKS_SSO_URL`, default `<AUDIOBOOKS_URL>/sso`) for short-lived signed links: `?token=<body>.<sig>`, where `body` is base64url JSON
`{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.
- **Podcast** and **Podcast Admin**: launchers that open `PODCAST_URL` (a home-network address) and `VERSE_URL` in their own tabs.
- **Office**: embeds onlyoffice.necloud.us in a frame, with an "open in new tab" fallback if it blocks framing.
- **Mail**: deep links to Gmail, Outlook.com, Microsoft 365 and iCloud webmail (inbox + compose).
+3
View File
@@ -15,6 +15,9 @@ services:
SEAFILE_URL: ${SEAFILE_URL:-https://seafile.necloud.us}
ONLYOFFICE_URL: ${ONLYOFFICE_URL:-https://onlyoffice.necloud.us}
AUDIOBOOKS_URL: ${AUDIOBOOKS_URL:-https://audiobooks.necloud.us}
AUDIOBOOKS_LOGIN_LINK: ${AUDIOBOOKS_LOGIN_LINK:-}
AUDIOBOOKS_SSO_SECRET: ${AUDIOBOOKS_SSO_SECRET:-}
AUDIOBOOKS_SSO_URL: ${AUDIOBOOKS_SSO_URL:-}
VERSE_URL: ${VERSE_URL:-https://versebyversewithnate.us/admin}
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
+180 -36
View File
@@ -9,8 +9,8 @@ window.fetch = async (...a) => {
const $ = s => document.querySelector(s);
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
// device, or the separate storage of the iOS home-screen app. Per-device things (Seafile token, music queue) stay local.
const SYNCED = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
const SYNCED = new Set(['mail_hints', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
@@ -62,12 +62,16 @@ const ICONS = {
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
refresh: '<path d="M20 12a8 8 0 11-2.3-5.7M20 4v5h-5"/>',
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
repeat: '<path d="M4 11V9a3 3 0 013-3h12M16 3l3 3-3 3M20 13v2a3 3 0 01-3 3H5M8 21l-3-3 3-3"/>',
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
@@ -854,19 +858,30 @@ function viewPodcast() {
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
}
// The frame is created once and kept alive, so playback carries on while you use other tabs.
// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
const ENTER = '/api/audiobooks/enter';
function viewAudio() {
const url = cfg.audiobooks, c = abCreds();
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
<p class="sub">${esc(new URL(url).host)}</p>
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a>
${c.u ? `<button class="btn ghost" id="cu">${icon('copy')}Username</button>` : ''}
${c.p ? `<button class="btn ghost" id="cp">${icon('copy')}Password</button>` : ''}
${c.u || c.p ? '' : '<a class="btn ghost" href="#/settings">Save sign-in</a>'}</div></div>`;
showFrame('audio', url, 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); // created once, so playback carries on across tabs
const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } };
const cu = $('#cu'), cp = $('#cp');
if (cu) cu.onclick = () => copy(c.u, 'Username');
if (cp) cp.onclick = () => copy(c.p, 'Password');
const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Audiobooks</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
${c.u ? `<button class="ibtn" id="cu" aria-label="Copy username" title="Copy username">${icon('copy')}</button>` : ''}
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
<a class="ibtn" href="${ENTER}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
const open = fresh => {
setSt('Opening…');
let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
let done = false;
f.addEventListener('load', () => { done = true; setSt(''); });
setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
};
if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
$('#ar').onclick = () => open(true);
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
}
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
@@ -1018,7 +1033,7 @@ async function musicRoute([kind = 'albums', key], st) {
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying();
return;
}
@@ -1027,14 +1042,99 @@ async function musicRoute([kind = 'albums', key], st) {
// ---- Player: one <audio> element that lives outside the views, so music keeps playing as you move around.
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
const pl = { q: [], i: -1, a: new Audio() };
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
const cur = () => pl.q[pl.i];
function playQueue(q, i = 0) { pl.q = q; pl.i = i; loadCur(); }
function loadCur() {
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
function setShuffle(on) {
if (on === pl.shuffle || !pl.q.length) return;
const t = cur();
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
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();
}
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); }
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.
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
}
async function tcFetch(src, signal) {
for (let n = 0; n < 4 && !signal.aborted; n++) {
try {
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
const b = await r.blob(); // throws if the connection drops part way
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
}
async function tcPump() {
if (tc.busy) return; tc.busy = true;
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
tc.ctl = new AbortController(); tc.cur = src;
await tcFetch(src, tc.ctl.signal);
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
}
} finally { tc.busy = false; tc.ctl = null; }
}
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
let loadTok = 0, loadTimer = null, stallTimer = null;
pl.retries = 0; pl.buf = false;
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
function loadCur(delay = 220) {
const t = cur(); if (!t) return;
pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start'));
if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
renderMini(); syncUI(); markPlaying(); saveState();
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();
const go = () => {
if (tok !== loadTok) return;
const hit = tc.m.get(t.src);
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
tcKick();
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
};
if (delay) loadTimer = setTimeout(go, delay); else go();
}
function setMeta(t) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
}
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
function recover(now) {
clearTimeout(stallTimer); const t = cur(), a = pl.a;
if (!t || a.paused) return;
stallTimer = setTimeout(() => {
if (a.paused || (a.readyState >= 3 && !a.error)) return;
const hit = tc.m.get(t.src), pos = a.currentTime;
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
if (bad && !hit) {
if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
}
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
if (!hit && !navigator.onLine) return; // 'online' will call us again
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, 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'); };
@@ -1049,6 +1149,7 @@ function trackSheet(t) {
// Restore the last queue and position on launch (paused, so nothing starts by itself).
function restorePlayer() {
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
renderMini(); syncUI();
@@ -1058,25 +1159,42 @@ function openQueue() {
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(); closeSheet(); });
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
}
const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } };
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } };
const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause());
pl.a.addEventListener('ended', next);
let lastSave = 0;
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } });
function advance(auto) {
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
loadCur(auto ? 0 : 220);
}
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';
pl.a.addEventListener('ended', () => advance(true));
let lastSave = 0, lastPos = 0;
pl.a.addEventListener('timeupdate', () => {
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
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'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('error', () => toast('Could not play this track'));
['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('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(); });
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
}
function markPlaying() {
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
@@ -1094,25 +1212,40 @@ function syncUI() {
const ic = icon(a.paused ? 'play' : 'pause');
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
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 sheet stays open while tracks change, so refresh what it shows.
function updateNow() {
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
const sh = $('#nps'), rp = $('#npr');
if (sh) { sh.classList.toggle('on', pl.shuffle); sh.setAttribute('aria-pressed', pl.shuffle); }
if (rp) { rp.classList.toggle('on', pl.repeat !== 'off'); rp.dataset.r = pl.repeat === 'one' ? '1' : ''; rp.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }
}
function openNow() {
const t = cur(); if (!t) return;
const el = openSheet('', '', `<div class="np"><img class="art" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0">${esc(t.title)}</h3>
<p class="sub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
<div class="bar" style="justify-content:center"><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button></div></div>`);
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
const sk = el.querySelector('#npk');
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
el.querySelector('#npq').onclick = openQueue;
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
syncUI();
updateNow(); syncUI();
}
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
@@ -1190,7 +1323,7 @@ function viewSettings() {
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
<div id="sec-conn"><h2>Connections</h2><div class="list">
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
</div>
@@ -1215,6 +1348,8 @@ function viewSettings() {
});
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); })
.catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); });
if (allowed('music')) jfetch('/api/plex/status').then(st => {
const el = $('#plexst'); if (!el) return;
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
@@ -1556,3 +1691,12 @@ function dirSheet(repo, path, name, dir) {
route();
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
})();
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
addEventListener('keydown', e => {
const tag = e.target.tagName || '', k = e.key;
if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
if (k === ' ') { e.preventDefault(); toggle(); }
else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
});
+9 -1
View File
@@ -199,7 +199,11 @@ body.inedit #view { padding: 0; display: flex; flex-direction: column; }
#audiowrap.off { position: fixed; left: -9999px; top: 0; width: 360px; height: 480px; overflow: hidden; }
#audiowrap.on { flex: 1; min-height: 0; padding: 0 calc(var(--sar) + 16px) 16px calc(var(--sal) + 16px); }
#audiowrap iframe { width: 100%; height: 100%; border: 1px solid var(--line); border-radius: 16px; background: var(--panel); }
body.onaudio #view { flex: none; overflow: visible; padding-bottom: 8px; }
body.onaudio #view { flex: none; overflow: visible; padding-bottom: 0; }
.abbar { display: flex; align-items: center; gap: 4px; padding: 6px calc(var(--sar) + 16px) 6px calc(var(--sal) + 16px); }
.abbar .grow { min-width: 0; display: flex; flex-direction: column; }
.abbar small { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.abbar a.ibtn { display: inline-flex; align-items: center; justify-content: center; color: inherit; }
@media (min-width: 768px) { #audiowrap.on { padding-right: 32px; padding-left: 32px; padding-bottom: 24px; } }
/* Month calendar */
@@ -252,3 +256,7 @@ input, textarea, [contenteditable], .sheet p, .card p, #view .sub { -webkit-user
background: var(--text); color: var(--bg); padding: 8px 8px 8px 16px; border-radius: 14px; box-shadow: 0 6px 24px rgba(0,0,0,.35); max-width: 92vw; }
#upd .btn { min-height: 36px; background: var(--accent); color: var(--accent-t); }
@media (min-width: 768px) { #upd { bottom: 24px; } }
.np .ibtn.on { color: var(--accent); }
.np .ibtn[data-r="1"]::after { content: '1'; position: absolute; font-size: 10px; font-weight: 700; margin: 0 0 -14px 14px; }
.np .ibtn { position: relative; }
+20 -1
View File
@@ -10,6 +10,18 @@ const PORT = process.env.PORT || 8080;
const SEAFILE_URL = (process.env.SEAFILE_URL || 'https://seafile.necloud.us').replace(/\/$/, '');
const ONLYOFFICE_URL = (process.env.ONLYOFFICE_URL || 'https://onlyoffice.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_URL = (process.env.AUDIOBOOKS_URL || 'https://audiobooks.necloud.us').replace(/\/$/, '');
const AUDIOBOOKS_SSO_URL = process.env.AUDIOBOOKS_SSO_URL || AUDIOBOOKS_URL + '/sso';
const AUDIOBOOKS_SSO_SECRET = process.env.AUDIOBOOKS_SSO_SECRET || '';
// Back-door sign-in for the audiobooks site. AUDIOBOOKS_LOGIN_LINK is the site's own secret link (the key is part of it); anyone who has it
// is signed in there with full access, so it lives only in the server's environment: never sent to the page, never logged.
// Alternatively AUDIOBOOKS_SSO_SECRET makes short-lived signed links per person.
const AUDIOBOOKS_LOGIN_LINK = process.env.AUDIOBOOKS_LOGIN_LINK || '';
function audiobooksLogin(user) {
if (AUDIOBOOKS_LOGIN_LINK) return AUDIOBOOKS_LOGIN_LINK;
if (!AUDIOBOOKS_SSO_SECRET) return '';
const body = Buffer.from(JSON.stringify({ u: user.name, id: user.id, exp: Math.floor(Date.now() / 1000) + 120, n: crypto.randomBytes(8).toString('hex') })).toString('base64url');
return AUDIOBOOKS_SSO_URL + '?token=' + body + '.' + crypto.createHmac('sha256', AUDIOBOOKS_SSO_SECRET).update(body).digest('base64url');
}
const auth = require('./auth');
const cal = require('./cal');
const tasks = require('./tasks');
@@ -306,7 +318,9 @@ async function plexProxy(req, res) {
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
const ac = new AbortController();
res.on('close', () => ac.abort());
const attempt = s => fetch(s.srv + req.url.slice('/plex'.length), { signal: ac.signal,
// A server that accepts the connection but never answers must not leave the player waiting forever: give up after 15 s without a response.
const attempt = async s => { const hc = new AbortController(), t = setTimeout(() => hc.abort(), 15000); try { return await tryFetch(s, AbortSignal.any([ac.signal, hc.signal])); } finally { clearTimeout(t); } };
const tryFetch = (s, signal) => fetch(s.srv + req.url.slice('/plex'.length), { signal,
headers: { ...PLEX_HEADERS, 'X-Plex-Token': s.stk, Accept: req.headers.accept || 'application/json', 'Accept-Encoding': 'identity', ...(req.headers.range ? { range: req.headers.range } : {}) } });
let up = null;
try { up = await attempt(st); } catch { up = null; }
@@ -381,6 +395,7 @@ const ACCESS = [
[/^\/sf\//, ['files', 'office']],
[/^\/api\/oo\/config$/, ['office', 'files']],
[/^\/(api\/plex\/|plex\/)/, ['music']],
[/^\/api\/audiobooks\//, ['audio']],
[/^\/api\/cal\//, ['calendar']],
[/^\/api\/tasks/, ['tasks']],
[/^\/api\/(search|ddg)$/, ['search']],
@@ -424,6 +439,10 @@ async function handleRequest(req, res) {
if (pathname === '/api/search') return search.handle(req, res);
if (req.url.startsWith('/api/tasks')) return tasks.handle(req, res);
if (req.url.startsWith('/api/cal/')) return cal.handle(req, res);
if (pathname === '/api/audiobooks/enter') { // the secret sign-in link is added here, on the server, and never appears in the app's page or script
const to = audiobooksLogin(user);
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
}
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
if (req.url.startsWith('/plex/') && req.method === 'GET') return plexProxy(req, res);
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
+3 -3
View File
@@ -8,9 +8,9 @@ const auth = require('./auth');
const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (Per-device things like the Seafile sign-in token and the music queue stay in the browser.)
const KEYS = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
const SECRET = new Set(['ab_creds']);
// Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['mail_hints', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
let _key; const key = () => _key || (_key = crypto.scryptSync(auth.secret, 'nehub-settings', 32));
+34
View File
@@ -0,0 +1,34 @@
const fs=require('fs'), vm=require('vm');
const src=fs.readFileSync(''+require('path').join(__dirname,'..','public','app.js')+'','utf8');
const a=src.indexOf("const shuffled = a =>"), b=src.indexOf("// ---- People (admin)");
let code=src.slice(a,b);
const listeners={};
class FakeAudio{constructor(){this.paused=true;this.currentTime=0;this.duration=100;this.readyState=4;this.error=null;this._l={};this.src='';}
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:()=>null,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:()=>[]},
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);
const T=n=>Array.from({length:n},(_,i)=>({key:'k'+i,title:'t'+i,artist:'a',album:'',thumb:'',src:'/plex/p'+i,dur:1000}));
const eq=(n,c)=>{console.log((c?'PASS ':'FAIL ')+n); if(!c)process.exitCode=1};
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
(async()=>{
const {pl}=ctx;
ctx.playQueue(T(5),0); eq('plays first',pl.i===0&&pl.a.src==='/plex/p0');
ctx.next(); ctx.next(); ctx.next(); eq('rapid next shows track 3 at once',pl.i===3); await sleep(300); eq('only last loaded',pl.a.src==='/plex/p3');
ctx.setShuffle(true); eq('shuffle keeps current + same tracks',pl.q[pl.i].key==='k3'&&pl.q.length===5&&new Set(pl.q.map(x=>x.key)).size===5&&pl.q.slice(0,4).map(x=>x.key).join()==='k0,k1,k2,k3');
ctx.setShuffle(false); eq('shuffle off restores order',pl.q.map(x=>x.key).join()==='k0,k1,k2,k3,k4'&&pl.i===3);
ctx.setRepeat('all'); ctx.advance(true); eq('advance to end',pl.i===4); await sleep(10); ctx.advance(true); eq('repeat all wraps',pl.i===0);
ctx.setRepeat('one'); pl.a.currentTime=50; ctx.advance(true); eq('repeat one restarts',pl.i===0&&pl.a.currentTime===0);
ctx.setRepeat('off'); pl.i=4; ctx.advance(true); eq('end of queue pauses',pl.a.paused&&pl.i===4);
// undecodable file -> transcode fallback, then skip
ctx.playQueue(T(3),0); await sleep(10); pl.a.paused=false; pl.a.readyState=0; pl.a.error={code:4};
ctx.recover(true); await sleep(400); eq('falls back to transcode',pl.alt.has('k0')&&pl.a.src.includes('transcode'));
pl.a.error={code:4}; ctx.recover(true); await sleep(400); await sleep(300); eq('then skips unplayable track',pl.i===1&&toasts.some(t=>t.includes('Skipping')));
// network stall retries same source
pl.a.paused=false;pl.a.readyState=1;pl.a.error=null; const before=pl.retries; ctx.recover(true); await sleep(400); eq('stall reloads stream',pl.retries===before+1);
process.exit(process.exitCode||0);
})();