11 changed files with 46 additions and 23 deletions

No files matched your search

+5
View File
@@ -112,6 +112,11 @@ It listens to the sound the browser is playing. Where it cannot (iPhone Safari,
- **Continue reading:** Home lists the ebooks you have started, with progress, next to Continue watching and Continue listening.
- **Privacy:** Settings, Connections has a switch for looking up lyrics and song covers online. Off keeps the artist and title from LRCLIB and iTunes; radio then shows the station logo only.
## Music on a phone
- **Songs kept ready:** the song playing and the next few (3 to 5, set in Settings under Connections) are saved on the device while you listen, up to about 120 MB, so changing songs is instant and a weak signal does not stop the music.
- **A song that will not play** is tried once through Plex's converter, then skipped with a message, instead of leaving the player stuck on "Buffering".
## Install on iPhone / iPad (PWA)
Open the site in **Safari** (it must be the `https://` address), tap **Share -> Add to Home Screen**. The home-screen app has proper
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"test": "for t in smoke frontend tasks push users cal plex player lyrics signal mail mail-imap files podcast abapi abdirect books store librivox radio vbv savefiles bible lrclib visualizer body settingskeys transcript recipes contacts mystore caldav musicdata screentime chat notes chores kidlimits search inbox family board chores-view kidcontrols kidsearch; do node tools/$t-test.js || exit 1; done",
"audit": "npm audit --omit=dev --audit-level=high"
},
"keywords": [],
+1 -1
View File
@@ -11,7 +11,7 @@ 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. Only the music queue stays local.
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls']);
const SYNCED = new Set(['ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'music_feat_pls', 'song_cache']);
const outbox = new Map(); let outTimer = null, warned = false;
function flushSettings() {
outTimer = null;
+2 -2
View File
@@ -173,7 +173,8 @@ async function musicRoute([kind = 'featured', key, key2], st) {
v.innerHTML = `<div class="wrap"><div class="bar"><a class="btn ghost" href="#/music/${album ? 'albums' : 'playlists'}">${icon('back')}${album ? 'Albums' : 'Playlists'}</a></div>
<div class="mhead"><img class="art" alt="" src="${px.art(thumb, 400)}"><div><h1>${esc(title)}</h1><p class="sub">${esc(sub)}</p></div></div>
${info.summary ? `<details class="mmabout"><summary>About</summary><p>${esc(info.summary).replace(/\n/g, '<br>')}</p></details>` : ''}
<div class="bar"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button>${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="bar mprime"><button class="btn" id="pa">${icon('play')}Play</button><button class="btn ghost" id="ps">${icon('shuffle')}Shuffle</button></div>
<div class="bar msecond">${album ? '<button class="btn ghost" id="pof">Save offline</button>' : ''}${album && allowed('chat') ? `<button class="btn ghost" id="psh">${icon('send')}Share</button>` : ''}${mine ? '<button class="btn ghost" id="prn">Rename</button><button class="btn ghost" id="pmv">Move to NE Hub</button><button class="btn ghost" id="pdel">Delete</button>' : ''}</div>
<div class="list">${tracks.map((t, i) => `<div class="row it" data-i="${i}"><button class="rmain" data-p="${i}"><span class="num">${i + 1}</span>
<span class="grow"><b>${esc(t.title)}</b>${album ? '' : `<small>${esc(t.artist)}</small>`}</span><small>${fmtDur(t.dur)}</small></button>
<button class="ibtn" data-fv="${esc(t.key)}" data-fi="${i}" aria-label="Favorite">${icon('heart')}</button><button class="ibtn" data-m="${i}" aria-label="Track actions">${icon('more')}</button></div>`).join('') || '<p class="empty">No playable tracks</p>'}</div></div>`;
@@ -194,7 +195,6 @@ async function musicRoute([kind = 'featured', key, key2], st) {
$('#pdel').onclick = () => confirmSheet('Delete playlist?', title, 'Delete', async () => { try { await plx.del(key); toast('Playlist deleted'); location.hash = '#/music/playlists'; } catch (e) { toast(e.message); } });
}
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
if ($('#psh')) $('#psh').onclick = () => shareMedia({ kind: 'album', title, sub, art: px.art(thumb, 200), link: '#/music/album/' + key });
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
markPlaying();
+19 -15
View File
@@ -22,9 +22,12 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// 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) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
const tcCount = () => Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5)); // songs kept ready, counting the one playing (Settings: 3 to 5)
const TC_BYTES = 120e6; // however many songs, never hold more than this in memory (a lossless album could otherwise fill a phone)
const tcWant = () => { const w = pl.q.slice(Math.max(pl.i, 0), Math.max(pl.i, 0) + tcCount()).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); return w; }; // (the one playing first, so a dropped connection cannot stop it, then the ones coming up; long audio, such as audiobooks, is streamed, not saved whole)
const tcBytes = () => { let n = 0; for (const v of tc.m.values()) n += v.size || 0; return n; };
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), Math.max(pl.i, 0) + tcCount()).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) {
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
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) });
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b), size: b.size });
return;
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
}
@@ -42,7 +45,8 @@ async function tcPump() {
try {
for (;;) {
tcTrim();
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
const want = tcWant(), src = want.find(x => !tc.m.has(x)); if (!src) break;
if (tcBytes() > TC_BYTES && want.indexOf(src) > 1) break; // (the song playing and the next one are always kept; the ones after wait while the cache is full)
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
@@ -58,7 +62,7 @@ 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;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false; pl.recovering = false;
const tok = ++loadTok; pl.buf = true;
if (delay) pl.a.pause();
try { // (a bug in any screen's refresh must never stop the music from starting)
@@ -80,15 +84,15 @@ function loadCur(delay = 220) {
}
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' }] });
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [96, 128, 256, 384, 512].map(n => ({ src: px.art(t.thumb, n), sizes: n + 'x' + n })) });
}
// 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;
if (!t || (a.paused && !pl.recovering)) return;
stallTimer = setTimeout(() => {
const forced = pl.forceRecover; pl.forceRecover = false;
if (a.paused || (!forced && a.readyState >= 3 && !a.error)) return;
if ((a.paused && !pl.recovering) || (!forced && a.readyState >= 3 && !a.error)) return; // (a reload makes the element report "paused" by itself, which must not stop us trying again)
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) {
@@ -101,10 +105,10 @@ function recover(now) {
if (!pl.gaveUp) { pl.gaveUp = true; pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); }
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
if (!hit || a.src !== hit.url) pl.retries++;
a.src = want;
pl.recovering = true; a.src = want;
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
recover();
}, now ? 300 : 4000);
}, now ? 300 : a.error ? 1200 : 4000); // (a file that plainly will not load is not waited on for long)
}
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(); npQueueCount(); toast('Playing next'); };
@@ -156,7 +160,7 @@ function advance(auto) {
}
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(); };
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); pl.recovering = false; return pl.a.paused ? pl.a.play() : pl.a.pause(); };
pl.a.preload = 'auto';
// Tell Plex what is playing, so it shows under Now Playing / Activity in the Plex dashboard (as "NE Hub", under the signed-in Plex account).
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
@@ -183,14 +187,14 @@ pl.a.addEventListener('timeupdate', () => {
});
addEventListener('pagehide', saveState);
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
pl.a.addEventListener('playing', () => { tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (!pl.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
pl.a.addEventListener('playing', () => { pl.recovering = false; tlReport('playing'); pl.buf = false; pl.retries = 0; pl.gaveUp = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
pl.a.addEventListener('pause', () => { if (pl.recovering) return; if (!pl.buf) tlReport('paused'); 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(); });
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; pl.recovering = 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('play', () => pl.a.play()); ms.setActionHandler('pause', () => { pl.recovering = false; 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 {}
+2
View File
@@ -91,6 +91,7 @@ function viewSettings(args) {
<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>
${allowed('music') ? '<label class="row"><span class="grow"><b>Songs kept ready</b><small>The playing song and the ones coming up are saved on this device, so a weak signal does not stop the music</small></span><select id="songcache"><option value="3">3</option><option value="4">4</option><option value="5">5</option></select></label>' : ''}
${allowed('music') || allowed('radio') ? '<label class="row" style="cursor:pointer"><span class="grow"><b>Look up lyrics and song covers online</b><small>Sends the artist and title of what is playing to LRCLIB and iTunes. Turn off to keep that private.</small></span><input type="checkbox" id="lookups"></label>' : ''}
</div>
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</div>
@@ -146,6 +147,7 @@ function viewSettings(args) {
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server + (st.user ? ' · as ' + st.user.title : '') : 'Signed in · no server chosen') : 'Signed out';
if (st.signedIn) { const b = $('#plexout'); b.hidden = false; b.onclick = async () => { await jfetch('/api/plex/logout', { method: 'POST' }); pl.a.pause(); pl.q = []; pl.i = -1; renderMini(); toast('Signed out of Plex'); viewSettings(); }; }
}).catch(() => { const el = $('#plexst'); if (el) el.textContent = 'Unavailable'; });
const sc = $('#songcache'); if (sc) { sc.value = String(Math.min(5, Math.max(3, +store.get('song_cache', 5) || 5))); sc.onchange = () => { store.set('song_cache', +sc.value); if (typeof tcKick === 'function') tcKick(); toast('Saved'); }; }
const lk = $('#lookups'); if (lk) { lk.checked = store.get('lookups', true) !== false; lk.onchange = () => { store.set('lookups', lk.checked); toast(lk.checked ? 'Lookups on' : 'Lookups off'); }; }
if (isAdmin) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
if (isAdmin) jfetch('/api/health').then(h => {
+4
View File
@@ -1156,3 +1156,7 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
body.vfullwin #player { transition: transform .25s ease, opacity .25s; }
body.vfullwin:has(.vstage.vhide) #player { transform: translateY(100%); opacity: 0; pointer-events: none; }
body.vfullwin:has(.vstage.vhide) .vstage.vfull { bottom: 0; } body.vfullwin:has(.vstage.vhide) .vstage.vfull video { height: 100dvh; }
/* album and playlist pages on a phone: Play and Shuffle are the two big buttons, the rest sit under them */
.mprime .btn { flex: 1; justify-content: center; min-height: 48px; font-size: 16px; }
.msecond { margin-top: -4px; } .msecond:empty { display: none; }
.msecond .btn { min-height: 40px; font-size: 14px; }
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v136';
const CACHE = 'hub-v138';
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
+3 -2
View File
@@ -325,7 +325,8 @@ function videoMount() {
if (e.pointerType === 'touch') {
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
if (now - lastTap < 320 && (x < .35 || x > .65)) { videoSkip(x < .5 ? -10 : 10); lastTap = 0; wake(); return; }
lastTap = now; if (stage.classList.contains('vhide')) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
const wasHidden = vp.hidAtDown !== undefined ? vp.hidAtDown : stage.classList.contains('vhide'); vp.hidAtDown = undefined; // (the touch itself already woke the bar in full-window mode, so ask what it was when the finger went down)
lastTap = now; if (wasHidden) wake(); else { stage.classList.add('vhide'); clearTimeout(hideT); }
} else { if (e.detail === 2) { videoFullscreen(); return; } setTimeout(() => { if (Date.now() - lastDbl > 350) { el.paused ? el.play().catch(() => {}) : el.pause(); } }, 0); wake(); }
});
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
@@ -472,7 +473,7 @@ function videoFsExit() {
if (document.fullscreenElement || document.webkitFullscreenElement) { try { (document.exitFullscreen || document.webkitExitFullscreen).call(document); } catch {} }
}
// in full screen, touching or moving over the picture or the bar brings the hidden bar back
['pointermove', 'pointerdown', 'keydown'].forEach(ev => addEventListener(ev, () => { if (vp && vp.wake && document.body.classList.contains('vfullwin')) vp.wake(); }, true));
['pointermove', 'pointerdown', 'keydown'].forEach(ev => addEventListener(ev, () => { if (vp && vp.wake && document.body.classList.contains('vfullwin')) { if (ev === 'pointerdown') vp.hidAtDown = !!document.querySelector('.vstage.vhide'); vp.wake(); } }, true));
document.addEventListener('fullscreenchange', () => { if (!document.fullscreenElement && document.body.classList.contains('vfullwin')) videoFsExit(); });
/** The picture stopped on its own: start again from where it was, at most 4 times in a row, and say so. */
function videoRescue(why, at) {
+1 -1
View File
@@ -9,7 +9,7 @@ const users = require('./users');
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
// Only these keys are accepted. (The music queue stays in the browser.)
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins']);
const KEYS = new Set(['ab_creds', 'sf_token', 'plex_lib', 'music_feat_pls', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents', 'links', 'office_pins', 'nav_pins', 'lookups', 'song_cache']);
const SECRET = new Set(['ab_creds', 'sf_token']);
const MAX_VALUE = 20000; // bytes of JSON per key
+7
View File
@@ -0,0 +1,7 @@
// Every setting the app syncs must be one the server accepts, or the whole save fails.
const fs = require('fs'), assert = require('assert');
const client = fs.readFileSync(__dirname + '/../public/app.js', 'utf8'), server = fs.readFileSync(__dirname + '/../settings.js', 'utf8');
const list = s => [...s.matchAll(/'([a-z_]+)'/g)].map(m => m[1]);
const c = list(/const SYNCED = new Set\(\[([^\]]*)\]/.exec(client)[1]), s = new Set(list(/const KEYS = new Set\(\[([^\]]*)\]/.exec(server)[1]));
const missing = c.filter(k => !s.has(k)); assert.deepStrictEqual(missing, [], 'the server would refuse: ' + missing.join(', '));
console.log('settings keys: ' + c.length + ' synced settings, all accepted by the server');