diff --git a/README.md b/README.md index 816f5b6..681c659 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package.json b/package.json index 93f0cf6..2d0fef8 100644 --- a/package.json +++ b/package.json @@ -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": [], diff --git a/public/app.js b/public/app.js index 8fe8c6e..85c9658 100644 --- a/public/app.js +++ b/public/app.js @@ -11,7 +11,7 @@ const $ = s => document.querySelector(s); 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 // 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; diff --git a/public/music.js b/public/music.js index 1701055..187f51a 100644 --- a/public/music.js +++ b/public/music.js @@ -173,7 +173,8 @@ async function musicRoute([kind = 'featured', key, key2], st) { v.innerHTML = `
${icon('back')}${album ? 'Albums' : 'Playlists'}

${esc(title)}

${esc(sub)}

${info.summary ? `
About

${esc(info.summary).replace(/\n/g, '
')}

` : ''} -
${album ? '' : ''}${album && allowed('chat') ? `` : ''}${album && allowed('chat') ? `` : ''}${mine ? '' : ''}
+
+
${album ? '' : ''}${album && allowed('chat') ? `` : ''}${mine ? '' : ''}
${tracks.map((t, i) => `
`).join('') || '

No playable tracks

'}
`; @@ -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(); diff --git a/public/player.js b/public/player.js index d393c92..1dd51d6 100644 --- a/public/player.js +++ b/public/player.js @@ -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 {} diff --git a/public/settings.js b/public/settings.js index 970155e..c338a6e 100644 --- a/public/settings.js +++ b/public/settings.js @@ -91,6 +91,7 @@ function viewSettings(args) {
Seafile${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}
PlexChecking…
OnlyOffice${esc(cfg.onlyoffice)}
+ ${allowed('music') ? '' : ''} ${allowed('music') || allowed('radio') ? '' : ''} ${signed ? '
' : ''} @@ -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 => { diff --git a/public/style.css b/public/style.css index 0e06c10..0d7ce62 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } diff --git a/public/sw.js b/public/sw.js index f6e8162..001e531 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // 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 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']; diff --git a/settings.js b/settings.js index 2c8477a..6e3530d 100644 --- a/settings.js +++ b/settings.js @@ -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 diff --git a/tools/settingskeys-test.js b/tools/settingskeys-test.js new file mode 100644 index 0000000..70d9cfb --- /dev/null +++ b/tools/settingskeys-test.js @@ -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');