Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d025e250fe | ||
|
|
1a797c972e | ||
|
|
2b4da8d9a2 | ||
|
|
139ecf4ab9 | ||
|
|
45c4dbe120 | ||
|
|
b0d6473e4d | ||
|
|
0ae68d6842 | ||
|
|
fd5812c5c2 | ||
|
|
8b77bacb7a | ||
|
|
ee4674cdad | ||
|
|
1c6fff51bb | ||
|
|
3db4e4aa1e | ||
|
|
4f482a1e1a | ||
|
|
8b295dddef |
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": [],
|
||||||
|
|||||||
+4
-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;
|
||||||
@@ -55,6 +55,9 @@ async function loadSettings() {
|
|||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>',
|
||||||
|
queue: '<path d="M4 6h12M4 11h12M4 16h7M15 14v6l5-3z"/>',
|
||||||
|
visuals: '<path d="M5 10v4M9 6v12M13 9v6M17 4v16M21 11v2"/>',
|
||||||
|
moon: '<path d="M20 14.5A8 8 0 019.5 4 8 8 0 1020 14.5z"/>',
|
||||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||||
|
|||||||
+6
-6
@@ -19,9 +19,9 @@ function draw() {
|
|||||||
$('#tiles').innerHTML = d.people.map(p => `<button class="tile" data-id="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('');
|
$('#tiles').innerHTML = d.people.map(p => `<button class="tile" data-id="${esc(p.id)}"><span class="av" style="background:${p.color}">${esc((p.name.trim()[0] || '?').toUpperCase())}</span><b>${esc(p.name.split(' ')[0])}</b><small>${p.kid ? '<span class="kid">KID</span>' : ''}</small></button>`).join('');
|
||||||
$('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id)));
|
$('#tiles').querySelectorAll('.tile').forEach(b => b.onclick = () => pick(d.people.find(p => p.id === b.dataset.id)));
|
||||||
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}${e.by ? ` <small>${esc(e.by)}</small>` : ''}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
|
const day = (label, ds) => { const l = d.events.filter(e => e.allDay ? e.start <= ds && e.end > ds : ymd(new Date(e.start)) === ds).sort((a, b) => (b.allDay - a.allDay) || a.start - b.start); return `<small class="sub">${label}</small>` + (l.map(e => `<div class="ln"><span class="dot" style="background:${esc(e.color || 'var(--accent)')}"></span><span class="g">${esc(e.title)}${e.by ? ` <small>${esc(e.by)}</small>` : ''}</span><small>${e.allDay ? 'All day' : new Date(e.start).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}</small></div>`).join('') || none('Nothing')); };
|
||||||
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
|
const pill = (c, cls) => `<button class="pill ${cls || ''}" data-c="${esc(c.chore)}" data-u="${esc(c.user || '')}">${esc(c.title)}${c.points ? ` <small>${c.points} pts</small>` : ''}</button>`;
|
||||||
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.userName] = per[c.userName] || []).push(c); });
|
const pool = d.chores.filter(c => c.claimable && !c.done), per = {}; d.chores.filter(c => !(c.claimable && !c.done)).forEach(c => { (per[c.user || c.userName] = per[c.user || c.userName] || []).push(c); });
|
||||||
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(k)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
|
const SHOW = 6, chores = (pool.length ? `<button class="pill pool" id="poolall" style="width:100%;justify-content:space-between;font-size:17px"><span>Pick a chore: ${pool.length} up for grabs</span><span>See all ›</span></button>` : '') + '<div class="chcols">' + Object.keys(per).map(k => { const l = per[k], dn = l.filter(c => c.done).length, left = l.filter(c => !c.done); return `<div class="chp"><div class="top"><b>${esc(l[0].userName)}</b><small class="sub">${dn} of ${l.length}</small></div><div class="bar2"><i style="width:${Math.round(dn / l.length * 100)}%"></i></div><div class="chl">${left.map(c => pill(c)).join('')}${left.length ? '' : '<small class="sub">All done 🎉</small>'}</div></div>`; }).join('') + '</div>';
|
||||||
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
|
$('#grid').innerHTML = card('Chores today', chores || none('No chores today'), '', 'wide')
|
||||||
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
|
+ card('Today and tomorrow', day('Today', t0) + day('Tomorrow', t1), 'event')
|
||||||
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span>${i.by ? `<small>${esc(i.by)}</small>` : ''}</div>`).join('') || none('Nothing to buy'), 'shopping')
|
+ card('This week', weekCard(d.week)) + card('Shopping list', d.shopping.map(i => `<div class="ln"><span class="g">${esc(i.text)}</span>${i.by ? `<small>${esc(i.by)}</small>` : ''}</div>`).join('') || none('Nothing to buy'), 'shopping')
|
||||||
@@ -31,9 +31,9 @@ function draw() {
|
|||||||
}
|
}
|
||||||
function poolAll() {
|
function poolAll() {
|
||||||
const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`<h3 style="margin:0 0 4px">Up for grabs</h3><p class="sub">${list.length} chores. Tap one to pick it</p><div style="text-align:left;max-height:58dvh;overflow:auto">${list.map(c => `<button class="pill pool" data-c="${esc(c.chore)}" style="width:100%;justify-content:space-between;font-size:18px;padding:12px 14px;margin:4px 0"><span>${esc(c.title)}</span>${c.points ? `<small>${c.points} pts</small>` : ''}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button></p>`);
|
const list = data.chores.filter(c => c.claimable && !c.done), { box, close } = sheet(`<h3 style="margin:0 0 4px">Up for grabs</h3><p class="sub">${list.length} chores. Tap one to pick it</p><div style="text-align:left;max-height:58dvh;overflow:auto">${list.map(c => `<button class="pill pool" data-c="${esc(c.chore)}" style="width:100%;justify-content:space-between;font-size:18px;padding:12px 14px;margin:4px 0"><span>${esc(c.title)}</span>${c.points ? `<small>${c.points} pts</small>` : ''}</button>`).join('')}</div><p><button class="btn ghost" id="no" style="width:100%;justify-content:center">Close</button></p>`);
|
||||||
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c)));
|
box.querySelector('#no').onclick = close; box.querySelectorAll('.pill[data-c]').forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || ''))));
|
||||||
}
|
}
|
||||||
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c))); }
|
function wireChores() { document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => addFlow(b.dataset.add)); const all = document.getElementById('poolall'); if (all) all.onclick = poolAll; document.querySelectorAll(".pill[data-c]").forEach(b => b.onclick = () => choreTap(data.chores.find(c => c.chore === b.dataset.c && (c.user || '') === (b.dataset.u || '')))); }
|
||||||
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} }
|
async function load() { try { const r = await fetch('/api/board?day=' + ymd(new Date())); if (r.status === 403) { location.replace('/login'); return; } data = await r.json(); draw(); } catch {} }
|
||||||
|
|
||||||
function pick(p) {
|
function pick(p) {
|
||||||
@@ -49,7 +49,7 @@ function pick(p) {
|
|||||||
paint(); $('#no').onclick = close;
|
paint(); $('#no').onclick = close;
|
||||||
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await go(pin); return; } catch (e) { $('#e').textContent = e.message; pin = ''; } } paint(); });
|
box.querySelectorAll('.pad button').forEach(b => b.onclick = async () => { let k = b.dataset.k; $('#e').textContent = ''; if (k === '⌫') pin = pin.slice(0, -1); else if (k !== '✓' && pin.length < 8) { pin += k; if (p.pinLen && pin.length === p.pinLen) k = '✓'; } if (k === '✓') { try { await go(pin); return; } catch (e) { $('#e').textContent = e.message; pin = ''; } } paint(); });
|
||||||
}
|
}
|
||||||
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 60000);
|
clock(); setInterval(clock, 15000); load(); setInterval(() => { if (!document.hidden && $('#ov').hidden) load(); }, 15000); // (a chore someone just added shows up within a few seconds)
|
||||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
|
||||||
|
|
||||||
// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you
|
// ---- tap a chore: (if it is up for grabs, choose what you are doing), pick your name, PIN if you are not a child, then it happens as you
|
||||||
|
|||||||
+26
-11
@@ -21,7 +21,7 @@ function lyrParseLrc(text) {
|
|||||||
}
|
}
|
||||||
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
const lyrSpokenKey = t => !!t && /^(pod|ab):/.test(String(t.key)); // a podcast episode or an audiobook: words from a transcript instead of lyrics
|
||||||
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
const lyrLabel = () => lyrSpokenKey(typeof cur === 'function' ? cur() : null) ? 'Transcript' : 'Lyrics';
|
||||||
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
const lyr = { fs: false, open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
||||||
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
||||||
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
|
// Radio: lyrics for the song that is on now. A station's own clock says when the song began (The Journey FM does; most do not), and what you hear is a few seconds
|
||||||
@@ -77,41 +77,49 @@ async function lyrLoad(t, force) {
|
|||||||
return lyrForRadio(t, res);
|
return lyrForRadio(t, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
function lyrOpen() {
|
// fs: the full-screen Now Playing view (art on the left, the words on the right, the player along the bottom)
|
||||||
const t = cur(); if (!t || lyr.open) return;
|
function lyrOpen(fs) {
|
||||||
lyr.open = true; document.body.classList.add('lyropen');
|
const t = cur(); if (!t) return;
|
||||||
|
if (lyr.open) { if (!!fs !== lyr.fs) { lyr.fs = !!fs; lyrShell(); lyrReload(); } return; }
|
||||||
|
lyr.fs = fs === true; lyr.open = true; document.body.classList.add('lyropen');
|
||||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||||
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
document.body.appendChild(el); lyrShell(); lyrReload(); swipeToDismiss(el, lyrClose, { handle: '.lyrtop', always: true });
|
||||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||||
}
|
}
|
||||||
function lyrClose() {
|
function lyrClose() {
|
||||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
||||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
|
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.fs = false; if (document.fullscreenElement) document.exitFullscreen().catch(() => {}); lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||||
}
|
}
|
||||||
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||||
|
const lyrCls = () => (lyrPref().karaoke && !lyrSpokenKey(cur()) ? 'kar' : '') + (lyr.fs ? ' fs' : '');
|
||||||
function lyrShell() {
|
function lyrShell() {
|
||||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
||||||
lyr.el.className = pref.karaoke && !lyrSpokenKey(t) ? 'kar' : '';
|
lyr.el.className = lyrCls();
|
||||||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
||||||
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
||||||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||||||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||||||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||||||
|
<div class="fsside"><img class="fsimg" alt="" src="${px.art(t.thumb, 800)}"><b>${esc(t.title)}</b><small>${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||||
|
<div class="fsbar"><span id="fsc">0:00</span><input type="range" id="fsk" min="0" step="1" value="0" aria-label="Seek"><span id="fsd">0:00</span></div>
|
||||||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><button class="btn ghost" id="lyrsh">Share a line</button><span class="grow"></span>
|
||||||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
<button class="ibtn fsonly" id="fss" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button><button class="ibtn fsonly" id="fsr" aria-label="Repeat">${icon('repeat')}</button></div>`;
|
||||||
const $l = q => lyr.el.querySelector(q);
|
const $l = q => lyr.el.querySelector(q);
|
||||||
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
if (lyrSpokenKey(t)) { $l('#lyrk').hidden = true; $l('#lyrs').hidden = true; $l('#lyrsh').hidden = true; lyr.el.setAttribute('aria-label', 'Transcript'); } // (karaoke and "wrong lyrics" make no sense for speech)
|
||||||
|
const fk = $l('#fsk'); fk.oninput = () => { fk.dataset.drag = 1; $l('#fsc').textContent = fmtDur(fk.value * 1000); }; fk.onchange = () => { pl.a.currentTime = +fk.value; delete fk.dataset.drag; };
|
||||||
|
$l('#fss').onclick = () => setShuffle(!pl.shuffle); $l('#fsr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
|
if (lyr.fs && !document.fullscreenElement && lyr.el.requestFullscreen) lyr.el.requestFullscreen().catch(() => {});
|
||||||
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
||||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrCls(); $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
||||||
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
||||||
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
||||||
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
$l('#lyrs').onclick = lyrSearchAgain; $l('#lyrsh').onclick = lyrShare;
|
||||||
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
||||||
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
$l('#lyrm').onclick = () => nudge(-lyrStep(cur())); $l('#lyrp').onclick = () => nudge(lyrStep(cur()));
|
||||||
syncUI();
|
modeUI(); syncUI();
|
||||||
}
|
}
|
||||||
async function lyrReload(force) {
|
async function lyrReload(force) {
|
||||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
const t = cur(); if (!t || !lyr.el) return; lyr.key = lyrId(t); lyr.data = null; lyr.idx = -2;
|
||||||
@@ -129,14 +137,21 @@ function lyrDraw() {
|
|||||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'This one has no published transcript.'}</p>${d && d.why && !d.error ? `<p class="empty"><small>${esc(d.why)}</small></p>` : ''}${d && d.canTranscribe ? `<div class="bar" style="justify-content:center"><button class="btn" id="lyrtr">Transcribe on this server</button></div><p class="empty"><small>Takes roughly a quarter of the running time or more on a CPU, and a long audiobook can take hours. It is kept afterwards.</small></p>` : '<p class="empty"><small>An admin can add the whisper service to turn on transcribing.</small></p>'}`;
|
||||||
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
const b = body.querySelector('#lyrtr'); if (b) b.onclick = () => lyrReload('start'); return;
|
||||||
}
|
}
|
||||||
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return;
|
body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; lyrQueue(body); return;
|
||||||
}
|
}
|
||||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; lyrQueue(body); return; }
|
||||||
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced') + (lyrIsRadio(t) && d.sync !== 'none' ? ' · live timing is approximate, use - and +' : '');
|
||||||
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
||||||
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none' || lyrIsRadio(t)) return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
||||||
body.scrollTop = 0; lyr.idx = -2;
|
body.scrollTop = 0; lyr.idx = -2;
|
||||||
}
|
}
|
||||||
|
/** With no words to show, the space shows what is coming up instead (tap a song to jump to it). */
|
||||||
|
function lyrQueue(body) {
|
||||||
|
if (lyrIsRadio(cur()) || pl.i < 0 || pl.q.length < 2) return;
|
||||||
|
const rows = pl.q.map((t, i) => ({ t, i })).slice(pl.i, pl.i + 40);
|
||||||
|
body.insertAdjacentHTML('beforeend', `<div class="lyrq"><h4>Up next</h4>${rows.map(({ t, i }) => `<button class="lyrqrow${i === pl.i ? ' now' : ''}" data-j="${i}"><span class="num">${i === pl.i ? '\u25B6' : i - pl.i}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>`).join('')}</div>`);
|
||||||
|
body.querySelectorAll('.lyrqrow').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); });
|
||||||
|
}
|
||||||
function lyrSearchAgain() {
|
function lyrSearchAgain() {
|
||||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
||||||
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||||||
|
|||||||
+11
-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="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>`;
|
||||||
@@ -182,10 +183,18 @@ async function musicRoute([kind = 'featured', key, key2], st) {
|
|||||||
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
|
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => { const t = tracks[+b.dataset.m]; trackSheet(t, mine && t.pid ? [{ label: 'Remove from this playlist', icon: 'trash', danger: true, run: async () => { try { await plx.remove(key, t.pid); toast('Removed'); route(); } catch (e) { toast(e.message); } } }] : []); });
|
||||||
if (mine) {
|
if (mine) {
|
||||||
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
$('#prn').onclick = () => textSheet('Rename playlist', 'Playlist name', 'Rename', async n => { try { await plx.rename(key, n); route(); } catch (e) { toast(e.message); } }, title);
|
||||||
|
$('#pmv').onclick = () => tracks.length ? textSheet('Move to NE Hub', 'Name of the Family playlist', 'Move', async n => {
|
||||||
|
try {
|
||||||
|
const meta = tracks.map(mmMeta).filter(t => mmPlex(t.key)); if (!meta.length) return toast('No songs here can be moved');
|
||||||
|
const r = await mmApi('/family', mmOpt('POST', { name: n, tracks: meta.slice(0, 500) }));
|
||||||
|
for (let i = 500; i < meta.length; i += 500) await mmApi(`/family/${r.id}/add`, mmOpt('POST', { tracks: meta.slice(i, i + 500) }));
|
||||||
|
toast('Moved ' + meta.length + ' songs to NE Hub');
|
||||||
|
confirmSheet('Remove it from Plex too?', '“' + title + '” is now a Family playlist. Cancel keeps the Plex one.', 'Remove from Plex', async () => { try { await plx.del(key); } catch (e) { toast(e.message); } location.hash = '#/music/family/' + r.id; });
|
||||||
|
} catch (e) { toast(e.message); }
|
||||||
|
}, title) : toast('This playlist has no songs');
|
||||||
$('#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();
|
||||||
|
|||||||
+51
-32
@@ -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
|
||||||
@@ -51,19 +55,22 @@ async function tcPump() {
|
|||||||
}
|
}
|
||||||
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
||||||
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
||||||
|
addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && cur() && !pl.a.paused && (pl.a.readyState < 3 || pl.a.error)) { pl.retries = 0; recover(true); } }); // (back from sleep or another app)
|
||||||
|
|
||||||
let loadTok = 0, loadTimer = null, stallTimer = null;
|
let loadTok = 0, loadTimer = null, stallTimer = null;
|
||||||
pl.retries = 0; pl.buf = false;
|
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)
|
||||||
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||||
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
if (typeof vizTrackChanged === 'function') vizTrackChanged();
|
||||||
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
if (typeof mmCoverTheme === 'function') mmCoverTheme(t); if (typeof mmRadioTopUp === 'function') mmRadioTopUp(); if (typeof mfPaint === 'function') mfPaint();
|
||||||
|
} catch (e) { console.error('player refresh', e); }
|
||||||
const go = () => {
|
const go = () => {
|
||||||
if (tok !== loadTok) return;
|
if (tok !== loadTok) return;
|
||||||
const hit = tc.m.get(t.src);
|
const hit = tc.m.get(t.src);
|
||||||
@@ -77,14 +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(() => {
|
||||||
if (a.paused || (a.readyState >= 3 && !a.error)) return;
|
const forced = pl.forceRecover; pl.forceRecover = false;
|
||||||
|
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) {
|
||||||
@@ -93,12 +101,14 @@ function recover(now) {
|
|||||||
}
|
}
|
||||||
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||||
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
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 && !bad && pl.retries >= 5) { // keep trying every 15 s (the 'online' signal is not always sent, e.g. when only the server was unreachable)
|
||||||
|
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++;
|
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'); };
|
||||||
@@ -150,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;
|
||||||
@@ -162,7 +172,10 @@ function tlReport(state) {
|
|||||||
tlSend(state, tlKey, tlPos, tlDur);
|
tlSend(state, tlKey, tlPos, tlDur);
|
||||||
}
|
}
|
||||||
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
function tlStop() { if (tlKey) tlSend('stopped', tlKey, tlPos, tlDur); tlKey = null; }
|
||||||
pl.a.addEventListener('ended', () => advance(true));
|
pl.a.addEventListener('ended', () => { // a dropped connection can look like the end of the song: if it stopped well short of the length, carry on from there instead of skipping
|
||||||
|
const a = pl.a, t = cur(), d = t && t.dur ? t.dur / 1000 : 0;
|
||||||
|
if (t && d > 20 && !/^radio:/.test(String(t.key)) && a.currentTime < d - 8 && pl.endRetry !== t.key + '|' + Math.floor(a.currentTime / 5)) { pl.endRetry = t.key + '|' + Math.floor(a.currentTime / 5); pl.forceRecover = true; pl.buf = true; syncUI(); recover(true); return; }
|
||||||
|
advance(true); });
|
||||||
let lastSave = 0, lastPos = 0;
|
let lastSave = 0, lastPos = 0;
|
||||||
pl.a.addEventListener('timeupdate', () => {
|
pl.a.addEventListener('timeupdate', () => {
|
||||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
||||||
@@ -174,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; 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 {}
|
||||||
@@ -195,12 +208,12 @@ function renderMini() {
|
|||||||
el.hidden = false;
|
el.hidden = false;
|
||||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="mv" aria-label="Visuals" title="Visuals">${icon('visuals')}</button><button class="ibtn wide-only" id="mq" aria-label="Queue" title="Queue">${icon('queue')}</button><button class="ibtn wide-only" id="mz" aria-label="Sleep timer" title="Sleep timer">${icon('moon')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||||
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
||||||
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
|
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen; $('#mv').onclick = () => vizOpen(); $('#mq').onclick = openQueue; $('#mz').onclick = () => sleepSheet();
|
||||||
// tap or drag along the thin bar to seek
|
// tap or drag along the thin bar to seek
|
||||||
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
||||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||||
@@ -221,8 +234,8 @@ function stopPlayer() {
|
|||||||
}
|
}
|
||||||
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||||
function modeUI() {
|
function modeUI() {
|
||||||
document.querySelectorAll('#ms, #nps, #cns').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
document.querySelectorAll('#ms, #nps, #cns, #fss').forEach(b => { b.classList.toggle('on', pl.shuffle); b.setAttribute('aria-pressed', pl.shuffle); });
|
||||||
document.querySelectorAll('#mr, #npr, #cnr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
document.querySelectorAll('#mr, #npr, #cnr, #fsr').forEach(b => { b.classList.toggle('on', pl.repeat !== 'off'); b.dataset.r = pl.repeat === 'one' ? '1' : ''; b.setAttribute('aria-label', 'Repeat: ' + pl.repeat); });
|
||||||
}
|
}
|
||||||
function syncUI() {
|
function syncUI() {
|
||||||
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
const a = pl.a, liveT = /^radio:/.test(String(cur() && cur().key)), d = liveT ? 0 : a.duration || (cur()?.dur || 0) / 1000, pct = d ? (a.currentTime / d) * 100 : 0;
|
||||||
@@ -246,15 +259,18 @@ function syncUI() {
|
|||||||
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
||||||
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
const cc = $('#cnc'); if (cc) cc.textContent = fmtDur(a.currentTime * 1000);
|
||||||
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
const cd = $('#cnd'); if (cd) cd.textContent = fmtDur(d * 1000);
|
||||||
|
const fk = $('#fsk'); if (fk && !fk.dataset.drag) { fk.max = d || 0; fk.value = liveT ? 0 : a.currentTime; } if (fk) fk.hidden = liveT;
|
||||||
|
const fc = $('#fsc'); if (fc) fc.textContent = liveT ? '\u25CF LIVE' : fmtDur(a.currentTime * 1000);
|
||||||
|
const fd = $('#fsd'); if (fd) fd.textContent = liveT ? '' : fmtDur(d * 1000);
|
||||||
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
const cs = $('#cnsub'); if (cs && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (cs.textContent !== v) cs.textContent = v; }
|
||||||
}
|
}
|
||||||
// A Now Playing card with the controls: on the Home dashboard, and at the top of every Music screen (the albums, artists and playlists stay right below it).
|
// A Now Playing card with the controls on the Home dashboard (the Music screens don't repeat it: the bottom player has the same controls).
|
||||||
function renderNpCard() {
|
function renderNpCard() {
|
||||||
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home'), onMusic = h.startsWith('#/music');
|
const old = $('#npcard'), t = cur(), h = location.hash, onHome = h === '' || h === '#' || h.startsWith('#/home');
|
||||||
const host = onHome ? $('#npslot') : onMusic ? document.querySelector('#view .wrap') : null;
|
const host = onHome ? $('#npslot') : null;
|
||||||
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
if (!host || !t || !allowed('music')) { if (old) old.remove(); return; }
|
||||||
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
const el = old || document.createElement('div'); el.id = 'npcard'; el.className = 'npcard';
|
||||||
if (!host.contains(el)) { if (onHome) host.appendChild(el); else host.prepend(el); }
|
if (!host.contains(el)) host.appendChild(el);
|
||||||
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
el.innerHTML = `<button class="npc-art" id="cnart" aria-label="Open the full player"><img alt="" src="${px.art(t.thumb, 300)}"></button>
|
||||||
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
<div class="npc-text"><small class="npc-lbl">NOW PLAYING</small><b id="cnti">${esc(t.title)}</b><small id="cnsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</small></div>
|
||||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||||
@@ -273,22 +289,25 @@ function renderNpCard() {
|
|||||||
function updateNow() {
|
function updateNow() {
|
||||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
$('#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';
|
const q = $('#npq span'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1);
|
||||||
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||||
modeUI();
|
modeUI();
|
||||||
}
|
}
|
||||||
function openNow() {
|
function openNow() {
|
||||||
const t = cur(); if (!t) return;
|
const t = cur(); if (!t) return;
|
||||||
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>
|
const chip = (id, ic, label) => `<button class="npchip" id="${id}">${icon(ic)}<span>${label}</span></button>`;
|
||||||
<p class="sub" id="npsub" 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)}"><div class="npmeta"><h3 id="npti">${esc(t.title)}</h3>
|
||||||
<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>
|
<p class="sub" id="npsub">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p></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>
|
<div class="npseek"><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>
|
||||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
<div class="ctl"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}</button><button class="btn ghost" id="npl">${lyrLabel()}</button><button class="btn ghost" id="npv">Visuals</button>${castOk(pl.a) ? '<button class="btn ghost" id="npcast">Cast</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>`);
|
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div>
|
||||||
|
<div class="npchips"><button class="npchip" id="npfav" aria-label="Favorite" aria-pressed="false">${icon('heart')}<span>Favorite</span></button>${chip('npl', 'lyrics', lyrLabel())}${chip('npv', 'visuals', 'Visuals')}${chip('npq', 'queue', 'Queue · ' + (pl.q.length - pl.i - 1))}${castOk(pl.a) ? chip('npcast', 'ext', 'Cast') : ''}${chip('npz', 'moon', 'Sleep')}${chip('npfs', 'full', 'Full screen')}</div></div>`);
|
||||||
|
el.querySelector('.sheet').classList.add('npsheet');
|
||||||
const sk = el.querySelector('#npk');
|
const sk = el.querySelector('#npk');
|
||||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
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; };
|
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); }; el.querySelector('#npv').onclick = () => { closeSheet(); vizOpen(); }; if (el.querySelector('#npcast')) el.querySelector('#npcast').onclick = () => castTo(pl.a);
|
||||||
|
el.querySelector('#npz').onclick = sleepSheet; el.querySelector('#npfs').onclick = () => { closeSheet(); lyrOpen(true); };
|
||||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||||
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
el.querySelector('#npfav').onclick = () => { const t0 = cur(); if (t0) mfToggle(t0); }; if (typeof mfLoad === 'function') mfLoad().then(mfPaint);
|
||||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
+51
-3
@@ -659,8 +659,9 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
.vspin i { display: block; width: 44px; height: 44px; border-radius: 50%; border: 4px solid rgba(255, 255, 255, .3); border-top-color: #fff; animation: vspin 1s linear infinite; } @keyframes vspin { to { transform: rotate(360deg); } }
|
||||||
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
#vnextbox { position: absolute; right: 12px; bottom: 96px; z-index: 5; max-width: calc(100% - 24px); }
|
||||||
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
#vnextbox .vnext { background: rgba(20, 22, 28, .92); color: #fff; margin: 0; }
|
||||||
.vstage.vfull { position: fixed; inset: 0; z-index: 90; border-radius: 0; }
|
.vstage.vfull { position: fixed; inset: 0 0 var(--playerh, 0px) 0; z-index: 90; border-radius: 0; }
|
||||||
.vstage.vfull video, .vstage:fullscreen video { height: 100dvh; max-height: none; object-fit: contain; }
|
body.vfullwin #player { position: fixed; left: 0; right: 0; bottom: 0; z-index: 91; }
|
||||||
|
.vstage.vfull video, .vstage:fullscreen video { height: calc(100dvh - var(--playerh, 0px)); max-height: none; object-fit: contain; }
|
||||||
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
.vstage:fullscreen { border-radius: 0; background: #000; width: 100%; height: 100%; }
|
||||||
.vstage:fullscreen video { height: 100%; }
|
.vstage:fullscreen video { height: 100%; }
|
||||||
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
@media (max-width: 560px) { .vright input[type=range], #vmute { display: none; } .vb { min-width: 40px; } .vb.txt { font-size: 13px; padding: 0 6px; } }
|
||||||
@@ -705,7 +706,6 @@ body.nav-mini .ngrp span { display: none; }
|
|||||||
.vmb .vskip em { font-size: 8px; top: 6px; }
|
.vmb .vskip em { font-size: 8px; top: 6px; }
|
||||||
|
|
||||||
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
/* The video's controls live in the bottom bar (like Music); the overlay strip is only for full screen */
|
||||||
.vstage:not(:fullscreen):not(.vfull) .vctl { display: none; }
|
|
||||||
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
.vtop .vright { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
||||||
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
.vtop .vtitle { flex: 0 1 auto; max-width: 38%; }
|
||||||
.vtop .vtc { flex: 0 1 auto; }
|
.vtop .vtc { flex: 0 1 auto; }
|
||||||
@@ -1112,3 +1112,51 @@ html.hc #view a:not(.btn):not(.row):not(.vcard):not(.seg a):not(.sgrp a) { text-
|
|||||||
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
|
.otplbox { margin: 12px 0 0; } .otplbox summary { cursor: pointer; padding: 10px 2px; list-style: none; } .otplbox summary::-webkit-details-marker { display: none; } .otplbox summary::before { content: '▸ '; color: var(--accent); } .otplbox[open] summary::before { content: '▾ '; } .otplbox summary small { color: var(--muted); margin-left: 6px; }
|
||||||
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
|
.otpl { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; } .octile.otc small { white-space: normal; }
|
||||||
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
|
.sgin { display: flex; flex-direction: column; flex: 1; min-height: 0; height: 100%; }
|
||||||
|
|
||||||
|
/* Now Playing sheet: cover, title, seek, controls, and a row of extras */
|
||||||
|
#sheet .sheet.npsheet h3:empty { display: none; }
|
||||||
|
#sheet .sheet.npsheet { padding-top: 14px; }
|
||||||
|
.np .art { width: min(68vw, 320px); border-radius: 14px; margin: 4px auto 18px; box-shadow: 0 18px 40px rgba(0, 0, 0, .45), 0 2px 6px rgba(0, 0, 0, .3); }
|
||||||
|
.np .npmeta { padding: 0 8px; }
|
||||||
|
.np .npmeta h3 { margin: 0; font-size: 21px; font-weight: 700; letter-spacing: -.01em; word-break: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.np .npmeta .sub { margin: 4px 0 0; font-size: 14px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.np .npseek { margin: 16px 0 0; }
|
||||||
|
.np .ctl { gap: 8px; margin: 6px 0 14px; }
|
||||||
|
.np .ctl .ibtn.big { width: 64px; height: 64px; background: var(--accent); color: var(--accent-t); border-radius: 50%; }
|
||||||
|
.np .npchips { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--line); }
|
||||||
|
.npchip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 13px; border-radius: 999px; border: 1px solid var(--line); background: transparent; color: var(--text, inherit); font: inherit; font-size: 13px; cursor: pointer; transition: background .15s; }
|
||||||
|
.npchip:hover { background: var(--panel2); } .npchip svg { width: 16px; height: 16px; } .npchip[hidden] { display: none; }
|
||||||
|
.npchip.on { color: var(--danger); border-color: currentColor; }
|
||||||
|
/* full screen: art and words side by side, the player along the bottom */
|
||||||
|
#lyr .fsside, #lyr .fsbar, #lyr .fsonly { display: none; }
|
||||||
|
#lyr.fs { display: grid; grid-template-columns: minmax(300px, 42%) 1fr; grid-template-rows: auto 1fr auto auto; }
|
||||||
|
#lyr.fs .lyrtop { grid-column: 1 / -1; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: hidden; }
|
||||||
|
#lyr.fs .fsside { display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 6px; padding: 0 3vw; min-height: 0; text-align: center; grid-column: 1; grid-row: 2; }
|
||||||
|
#lyr.fs .fsimg { width: min(100%, 46vh, 460px); aspect-ratio: 1; object-fit: cover; border-radius: 16px; background: var(--panel2); box-shadow: 0 24px 60px rgba(0, 0, 0, .55); margin-bottom: 18px; }
|
||||||
|
#lyr.fs .fsside b { font-size: clamp(20px, 2.4vw, 30px); letter-spacing: -.01em; } #lyr.fs .fsside small { color: var(--muted); font-size: 15px; }
|
||||||
|
#lyr.fs .lyrbody { grid-column: 2; grid-row: 2; padding: 20vh 4vw 26vh 2vw; text-align: left; } #lyr.fs.kar .lyrbody { padding: 8vh 4vw 8vh 2vw; }
|
||||||
|
#lyr.fs .fsbar { display: flex; grid-column: 1 / -1; align-items: center; gap: 12px; padding: 8px max(4vw, 20px) 0; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
|
||||||
|
#lyr.fs .fsbar input { flex: 1; accent-color: var(--accent); }
|
||||||
|
#lyr.fs .lyrfoot { grid-column: 1 / -1; border-top: 0; background: transparent; justify-content: center; padding-top: 4px; }
|
||||||
|
#lyr.fs .lyrfoot .grow { flex: 0; } #lyr.fs .fsonly { display: inline-grid; }
|
||||||
|
@media (max-width: 760px) { #lyr.fs { display: flex; } #lyr.fs .fsside { display: none; } #lyr.fs .lyrtop .lyrart, #lyr.fs .lyrtop .grow { visibility: visible; } #lyr.fs .lyrbody { padding: 24vh 20px 30vh; text-align: center; } #lyr.fs .fsbar { display: none; } }
|
||||||
|
|
||||||
|
/* no lyrics: the queue fills the space */
|
||||||
|
.lyrq { max-width: 560px; margin: 18px auto 0; text-align: left; }
|
||||||
|
.lyrq h4 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.lyrqrow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 9px 10px; border: 0; border-radius: 10px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.lyrqrow:hover { background: color-mix(in srgb, var(--panel2) 70%, transparent); } .lyrqrow.now { background: var(--panel2); }
|
||||||
|
.lyrqrow .num { width: 22px; text-align: center; color: var(--muted); font-size: 13px; flex: none; } .lyrqrow.now .num { color: var(--accent); }
|
||||||
|
.lyrqrow .grow { min-width: 0; } .lyrqrow b, .lyrqrow small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrqrow small { color: var(--muted); font-size: 12px; }
|
||||||
|
#lyr .lyrbody:has(.lyrq) { padding-top: 10vh; padding-bottom: 16vh; } #lyr .lyrbody:has(.lyrq) .empty { padding-top: 0; text-align: center; }
|
||||||
|
|
||||||
|
.mini .vbvol { width: 84px; accent-color: var(--accent); } .mini .ibtn.txt { width: auto; min-width: 40px; font-size: 13px; font-weight: 600; padding: 0 6px; }
|
||||||
|
|
||||||
|
/* full screen: the bottom bar slides away with the other controls after a few idle seconds */
|
||||||
|
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
@@ -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-v134';
|
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'];
|
||||||
|
|||||||
+28
-33
@@ -310,38 +310,23 @@ function videoMount() {
|
|||||||
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
const b = (idn, ic, label, extra = '') => `<button class="vb${extra}" id="${idn}" aria-label="${label}" title="${label}">${ic}</button>`;
|
||||||
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
v.innerHTML = `<div class="vwrap"><div class="vstage" id="vstage">
|
||||||
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
<div class="vtop"><a class="btn ghost" id="vback" href="${ep ? '#/video/item/' + (m.parentRatingKey || m.grandparentRatingKey) : '#/video/item/' + id}">${icon('back')}Back</a><nav class="vcrumbs vtc" aria-label="Where you are">${vdCrumbs(m)}</nav><b class="vtitle">${esc(title)}</b>
|
||||||
<div class="vright">${b('vmute', icon('vol'), 'Mute')}<input type="range" id="vvol" min="0" max="100" value="100" aria-label="Volume">${b('vspd', vp.speed + '×', 'Speed', ' txt')}${castOk(vp.el) ? b('vcast', 'Cast', 'Play on a TV or speaker', ' txt') : ''}${b('vset', 'Quality & tracks', 'Quality and tracks', ' txt')}${document.pictureInPictureEnabled ? b('vpip', icon('pip'), 'Picture in picture') : ''}${b('vfs', icon('full'), 'Full screen')}${b('vclose', icon('x'), 'Close the video')}</div></div>
|
</div>
|
||||||
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
<div class="vflash" id="vflash" aria-hidden="true"></div><div class="vspin" id="vspin" hidden><i></i></div>
|
||||||
<div class="vctl"><div class="vseek"><span id="vcur">0:00</span><input type="range" id="vrange" min="0" max="1000" step="1" value="0" aria-label="Seek"><span id="vrem">-0:00</span></div>
|
|
||||||
<div class="vrow2"><div class="vleft">${ep ? b('vprev', icon('prev'), 'Previous episode') : ''}${b('vb10', `<span class="vskip">${icon('rew')}<em>10</em></span>`, 'Back 10 seconds')}${b('vpp', icon('play'), 'Play', ' big')}${b('vf10', `<span class="vskip">${icon('fwd')}<em>10</em></span>`, 'Forward 10 seconds')}${ep ? b('vnextep', icon('next'), 'Next episode') : ''}</div></div></div>
|
|
||||||
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
<div id="vnextbox"></div></div><p class="sub" id="vmsg" role="status"></p></div>`;
|
||||||
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
const stage = $('#vstage'); stage.prepend(el); el.controls = false;
|
||||||
$('#vvol').value = Math.round(el.volume * 100);
|
|
||||||
const total = () => (vp.m.duration || 0) / 1000;
|
|
||||||
let drag = false, hideT = null;
|
let drag = false, hideT = null;
|
||||||
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => stage.classList.add('vhide'), 3000); };
|
const wake = () => { stage.classList.remove('vhide'); clearTimeout(hideT); if (!el.paused && !drag) hideT = setTimeout(() => { if (document.querySelector('#player:hover, #player:focus-within')) wake(); else stage.classList.add('vhide'); }, 3000); }; // (in full screen the bottom bar hides with the rest, but not while you are on it)
|
||||||
vp.wake = wake; vp.drag = () => drag;
|
vp.wake = wake; vp.drag = () => drag;
|
||||||
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
vp.flash = text => { const f = $('#vflash'); if (!f) return; f.textContent = text; f.classList.remove('on'); void f.offsetWidth; f.classList.add('on'); };
|
||||||
$('#vpp').onclick = () => { el.paused ? el.play().catch(() => {}) : el.pause(); };
|
|
||||||
$('#vb10').onclick = () => videoSkip(-10); $('#vf10').onclick = () => videoSkip(10);
|
|
||||||
if ($('#vprev')) $('#vprev').onclick = () => videoGo(-1); if ($('#vnextep')) $('#vnextep').onclick = () => videoGo(1);
|
|
||||||
if ($('#vcast')) $('#vcast').onclick = () => castTo(vp && vp.el);
|
|
||||||
$('#vset').onclick = videoSettings; $('#vfs').onclick = videoFullscreen;
|
|
||||||
if ($('#vpip')) $('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el.requestPictureInPicture(); } catch {} };
|
|
||||||
$('#vmute').onclick = () => { el.muted = !el.muted; };
|
|
||||||
$('#vclose').onclick = () => { const back = $('#vback').getAttribute('href'); videoStop(); location.hash = back; };
|
|
||||||
$('#vvol').oninput = e => { el.volume = e.target.value / 100; el.muted = el.volume === 0; store.set('video_vol', el.volume); };
|
|
||||||
$('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el.playbackRate = r; $('#vspd').textContent = r + '×'; } })));
|
|
||||||
const range = $('#vrange');
|
|
||||||
range.oninput = () => { drag = true; vdUi(); wake(); }; range.onchange = () => { const t = total() || el.duration || 0; videoSeek(range.value / 1000 * t); drag = false; wake(); };
|
|
||||||
let lastTap = 0, lastDbl = 0;
|
let lastTap = 0, lastDbl = 0;
|
||||||
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
stage.addEventListener('dblclick', () => { lastDbl = Date.now(); });
|
||||||
stage.addEventListener('pointerup', e => {
|
stage.addEventListener('pointerup', e => {
|
||||||
if (e.target.closest('.vctl, .vtop, #vnextbox, .vmark')) return;
|
if (e.target.closest('.vtop, #vnextbox, .vmark')) return;
|
||||||
if (e.pointerType === 'touch') {
|
if (e.pointerType === 'touch') {
|
||||||
const now = Date.now(), r = stage.getBoundingClientRect(), x = (e.clientX - r.left) / r.width;
|
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; }
|
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(); }
|
} 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);
|
stage.addEventListener('pointermove', e => { if (e.pointerType !== 'touch') wake(); }); stage.addEventListener('focusin', wake);
|
||||||
@@ -414,11 +399,21 @@ function videoBar() {
|
|||||||
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
el.innerHTML = `<div class="mini vbar"><div class="bar2" id="vbseek" role="slider" aria-label="Seek"><i id="vbp"></i></div><img class="art" alt="" src="${vdArt(m.thumb || m.parentThumb || m.grandparentThumb, 100, 150)}" onerror="this.style.visibility='hidden'">
|
||||||
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
<button class="meta" id="vbo" aria-label="Open the player"><b>${esc(ep ? m.title : m.title)}</b><small>${esc(ep ? `${m.grandparentTitle} · S${m.parentIndex}E${m.index}` : [m.year, 'Movie'].filter(Boolean).join(' · '))}</small></button><span class="mtime" id="vbtm"></span>
|
||||||
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
${ep ? `<button class="ibtn wide-only" id="vbprev" aria-label="Previous episode" title="Previous episode">${icon('prev')}</button>` : ''}<button class="ibtn" id="vbb10" aria-label="Back 10 seconds" title="Back 10 seconds"><span class="vskip">${icon('rew')}<em>10</em></span></button><button class="ibtn" id="vbpp" aria-label="Play or pause"></button><button class="ibtn" id="vbf10" aria-label="Forward 10 seconds" title="Forward 10 seconds"><span class="vskip">${icon('fwd')}<em>10</em></span></button>${ep ? `<button class="ibtn wide-only" id="vbnext" aria-label="Next episode" title="Next episode">${icon('next')}</button>` : ''}
|
||||||
|
<button class="ibtn wide-only" id="vmute" aria-label="Mute" title="Mute">${icon(vp.el.muted || vp.el.volume === 0 ? 'mute' : 'vol')}</button><input class="wide-only vbvol" type="range" id="vvol" min="0" max="100" value="${Math.round(vp.el.volume * 100)}" aria-label="Volume">
|
||||||
|
<button class="ibtn wide-only txt" id="vspd" aria-label="Playback speed" title="Playback speed">${vp.speed}×</button>${castOk(vp.el) ? `<button class="ibtn wide-only" id="vcast" aria-label="Play on a TV or speaker" title="Play on a TV or speaker">${icon('ext')}</button>` : ''}
|
||||||
|
<button class="ibtn" id="vset" aria-label="Quality and tracks" title="Quality and tracks">${icon('cog')}</button>${document.pictureInPictureEnabled ? `<button class="ibtn wide-only" id="vpip" aria-label="Picture in picture" title="Picture in picture">${icon('pip')}</button>` : ''}<button class="ibtn" id="vfs" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
||||||
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
<button class="ibtn wide-only" id="vbx" aria-label="Close the video" title="Close the video">${icon('x')}</button></div>`;
|
||||||
const q = s => el.querySelector(s);
|
const q = s => el.querySelector(s);
|
||||||
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
q('#vbpp').onclick = () => { const v = vp && vp.el; if (v) v.paused ? v.play().catch(() => {}) : v.pause(); };
|
||||||
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
q('#vbb10').onclick = () => videoSkip(-10); q('#vbf10').onclick = () => videoSkip(10);
|
||||||
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
if (q('#vbprev')) q('#vbprev').onclick = () => videoGo(-1); if (q('#vbnext')) q('#vbnext').onclick = () => videoGo(1);
|
||||||
|
const el0 = vp.el;
|
||||||
|
q('#vmute').onclick = () => { el0.muted = !el0.muted; };
|
||||||
|
q('#vvol').oninput = e => { el0.volume = e.target.value / 100; el0.muted = el0.volume === 0; store.set('video_vol', el0.volume); };
|
||||||
|
q('#vspd').onclick = () => actionSheet('Playback speed', '', [0.75, 1, 1.25, 1.5, 2].map(r => ({ label: r + '×' + (r === vp.speed ? ' ✓' : ''), icon: 'play', run: () => { vp.speed = r; el0.playbackRate = r; q('#vspd').textContent = r + '×'; } })));
|
||||||
|
if (q('#vcast')) q('#vcast').onclick = () => castTo(vp && vp.el);
|
||||||
|
q('#vset').onclick = videoSettings; q('#vfs').onclick = videoFullscreen;
|
||||||
|
if (q('#vpip')) q('#vpip').onclick = () => { try { document.pictureInPictureElement ? document.exitPictureInPicture() : el0.requestPictureInPicture(); } catch {} };
|
||||||
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
q('#vbo').onclick = () => { if (vp) location.hash = '#/video/watch/' + vp.id; };
|
||||||
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
q('#vbx').onclick = () => { const onPlayer = location.hash.startsWith('#/video/watch/'); const back = onPlayer && $('#vback') ? $('#vback').getAttribute('href') : null; videoStop(); if (back) location.hash = back; };
|
||||||
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
|
// drag along the line: the position and time follow your finger at once, and the video jumps once when you let go (a seek on every move would make a video stutter)
|
||||||
@@ -467,19 +462,19 @@ function videoGo(dir) {
|
|||||||
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
/** Full screen: the browser's own full screen when it allows it (the player and its buttons stay), the phone's video full screen on iPhone,
|
||||||
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
* otherwise a full-window mode that covers the app. Esc, F or the button leaves it. */
|
||||||
function videoFullscreen() {
|
function videoFullscreen() {
|
||||||
if (!vp) return; const box = document.querySelector('.vstage'), el = vp.el; if (!box) return;
|
if (!vp) return; const box = document.querySelector('.vstage'); if (!box) return;
|
||||||
const native = document.fullscreenElement || document.webkitFullscreenElement;
|
if (box.classList.contains('vfull')) return videoFsExit();
|
||||||
if (native) { (document.exitFullscreen || document.webkitExitFullscreen).call(document); return; }
|
box.classList.add('vfull'); document.body.classList.add('vfullwin'); // (the picture fills the window; the bottom bar stays under it with the controls)
|
||||||
if (box.classList.contains('vfull')) { box.classList.remove('vfull'); document.body.classList.remove('vfullwin'); return; }
|
const root = document.documentElement, req = root.requestFullscreen || root.webkitRequestFullscreen;
|
||||||
const req = box.requestFullscreen || box.webkitRequestFullscreen;
|
if (req) { try { const p = req.call(root); if (p && p.catch) p.catch(() => {}); } catch {} } // (if the browser refuses, the full-window mode above is what you get)
|
||||||
const fallback = () => { if (el.webkitEnterFullscreen && /iPhone|iPad|iPod/.test(navigator.userAgent)) { try { el.webkitEnterFullscreen(); return; } catch {} } box.classList.add('vfull'); document.body.classList.add('vfullwin'); };
|
|
||||||
if (!req) return fallback();
|
|
||||||
try { // some embedded browsers accept the request and then do nothing: if full screen has not started a moment later, use the full-window mode
|
|
||||||
const p = req.call(box); if (p && p.catch) p.catch(fallback);
|
|
||||||
setTimeout(() => { if (!document.fullscreenElement && !document.webkitFullscreenElement && !box.classList.contains('vfull') && vp) fallback(); }, 450);
|
|
||||||
} catch { fallback(); }
|
|
||||||
}
|
}
|
||||||
document.addEventListener('fullscreenchange', () => { const b = $('#vfs'); if (b) b.textContent = document.fullscreenElement ? 'Exit full screen' : 'Full screen'; });
|
function videoFsExit() {
|
||||||
|
const box = document.querySelector('.vstage.vfull'); if (box) box.classList.remove('vfull'); document.body.classList.remove('vfullwin');
|
||||||
|
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')) { 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. */
|
/** 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) {
|
function videoRescue(why, at) {
|
||||||
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
|
if (!vp) return false; const now = Date.now(); if (now - (vp.rescueAt || 0) > 90000) vp.rescues = 0; if ((vp.rescues || 0) >= 4) return false;
|
||||||
@@ -568,7 +563,7 @@ addEventListener('keydown', e => {
|
|||||||
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
else if (k === 'ArrowUp') { e.preventDefault(); el.volume = Math.min(1, el.volume + .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
else if (k === 'ArrowDown') { e.preventDefault(); el.volume = Math.max(0, el.volume - .1); store.set('video_vol', el.volume); const s = $('#vvol'); if (s) s.value = Math.round(el.volume * 100); }
|
||||||
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
else if (k === 'f') { e.preventDefault(); videoFullscreen(); }
|
||||||
else if (k === 'Escape') { const b = document.querySelector('.vstage.vfull'); if (b) { b.classList.remove('vfull'); document.body.classList.remove('vfullwin'); } }
|
else if (k === 'Escape') videoFsExit();
|
||||||
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
||||||
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||||
}, true);
|
}, true);
|
||||||
|
|||||||
+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');
|
||||||
@@ -10,6 +10,6 @@ for (const k of ['12345', 'radio:journeyfm']) ok(ctx.vizKind({ key: k }) === 'mu
|
|||||||
ok(ctx.VIZ_MODES.length === 6 && ctx.VIZ_MODES[0][0] === 'classic', 'six looks, Classic first');
|
ok(ctx.VIZ_MODES.length === 6 && ctx.VIZ_MODES[0][0] === 'classic', 'six looks, Classic first');
|
||||||
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
|
const root = __dirname + '/../public/', rd = f => fs.readFileSync(root + f, 'utf8');
|
||||||
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
|
ok(rd('index.html').includes('/visualizer.js'), 'script is loaded');
|
||||||
ok(rd('player.js').includes("id=\"npv\"") && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
|
ok(/id=\\"npv\\"|chip\('npv'/.test(rd('player.js')) && rd('player.js').includes("id=\"cnv\""), 'buttons in the player and the Music card');
|
||||||
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
|
ok(rd('player.js').includes('vizTrackChanged') && rd('radio-view.js').includes('vizTrackChanged'), 'follows track changes');
|
||||||
console.log('visualizer: ' + n + ' checks passed');
|
console.log('visualizer: ' + n + ' checks passed');
|
||||||
Reference in new issue
Block a user