Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e248bac143 | ||
|
|
d025e250fe | ||
|
|
1a797c972e |
No files matched your search
@@ -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.
|
- **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.
|
- **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)
|
## 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
|
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
@@ -5,7 +5,7 @@
|
|||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"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"
|
"audit": "npm audit --omit=dev --audit-level=high"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ const $ = s => document.querySelector(s);
|
|||||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
// 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.
|
// 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;
|
const outbox = new Map(); let outTimer = null, warned = false;
|
||||||
function flushSettings() {
|
function flushSettings() {
|
||||||
outTimer = null;
|
outTimer = null;
|
||||||
|
|||||||
+2
-2
@@ -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>
|
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>
|
<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>` : ''}
|
${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>
|
<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>
|
<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>`;
|
<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); } });
|
$('#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 });
|
||||||
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);
|
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||||
markPlaying();
|
markPlaying();
|
||||||
|
|||||||
+19
-15
@@ -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.
|
// 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 sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||||
const tc = { m: new Map(), ctl: null, busy: false };
|
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() {
|
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); }
|
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
|
||||||
}
|
}
|
||||||
async function tcFetch(src, signal) {
|
async function tcFetch(src, signal) {
|
||||||
@@ -32,7 +35,7 @@ async function tcFetch(src, signal) {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
|
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
|
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;
|
return;
|
||||||
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
||||||
}
|
}
|
||||||
@@ -42,7 +45,8 @@ async function tcPump() {
|
|||||||
try {
|
try {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
tcTrim();
|
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;
|
tc.ctl = new AbortController(); tc.cur = src;
|
||||||
await tcFetch(src, tc.ctl.signal);
|
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
|
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.
|
// 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) {
|
function loadCur(delay = 220) {
|
||||||
const t = cur(); if (!t) return;
|
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;
|
const tok = ++loadTok; pl.buf = true;
|
||||||
if (delay) pl.a.pause();
|
if (delay) pl.a.pause();
|
||||||
try { // (a bug in any screen's refresh must never stop the music from starting)
|
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) {
|
function setMeta(t) {
|
||||||
if (!('mediaSession' in navigator)) return;
|
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.
|
// 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) {
|
function recover(now) {
|
||||||
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
||||||
if (!t || a.paused) return;
|
if (!t || (a.paused && !pl.recovering)) return;
|
||||||
stallTimer = setTimeout(() => {
|
stallTimer = setTimeout(() => {
|
||||||
const forced = pl.forceRecover; pl.forceRecover = false;
|
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 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)
|
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
||||||
if (bad && !hit) {
|
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.'); }
|
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; }
|
stallTimer = setTimeout(() => { pl.retries = 0; recover(true); }, 15000); return; }
|
||||||
if (!hit || a.src !== hit.url) pl.retries++;
|
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 });
|
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
||||||
recover();
|
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 }); }
|
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'); };
|
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 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 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';
|
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).
|
// 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;
|
let tlKey = null, tlAt = 0, tlPos = 0, tlDur = 0;
|
||||||
@@ -183,14 +187,14 @@ pl.a.addEventListener('timeupdate', () => {
|
|||||||
});
|
});
|
||||||
addEventListener('pagehide', saveState);
|
addEventListener('pagehide', saveState);
|
||||||
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
['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('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.buf) tlReport('paused'); pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
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());
|
pl.a.addEventListener('canplaythrough', () => tcKick());
|
||||||
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
['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) {
|
if ('mediaSession' in navigator) {
|
||||||
const ms = navigator.mediaSession;
|
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);
|
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
|
||||||
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
|
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 {}
|
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 {}
|
||||||
|
|||||||
@@ -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-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-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 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>' : ''}
|
${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>
|
</div>
|
||||||
${signed ? '<div class="bar"><button class="btn danger" id="out">Sign out of Seafile</button></div>' : ''}</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';
|
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(); }; }
|
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'; });
|
}).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'); }; }
|
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) { loadPeople(); $('#addperson').onclick = () => addPersonSheet(); $('#stbtn').onclick = () => screenTimeSheet(); }
|
||||||
if (isAdmin) jfetch('/api/health').then(h => {
|
if (isAdmin) jfetch('/api/health').then(h => {
|
||||||
|
|||||||
@@ -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 #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) #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; }
|
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
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v137';
|
const CACHE = 'hub-v138';
|
||||||
const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version
|
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 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'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ const users = require('./users');
|
|||||||
const file = () => path.join(users.dir(), 'settings.json'); // each person has their own
|
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.)
|
// 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 SECRET = new Set(['ab_creds', 'sf_token']);
|
||||||
const MAX_VALUE = 20000; // bytes of JSON per key
|
const MAX_VALUE = 20000; // bytes of JSON per key
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
Reference in new issue
Block a user