Seafile sign-in follows the person across devices; sturdier Plex player; audiobooks frame with server-side secret sign-in
build-container / build (push) Successful in 3m16s
build-container / build (push) Successful in 3m16s
- Seafile token is saved on the server (encrypted) so Settings shows the real signed-in state everywhere - Player: whole-track cache for current and next two, stall recovery, instant track changes, shuffle/repeat, Plex play counts, transcode fallback, Plex request timeout - Audio tab: toolbar, full-height kept-alive frame; /api/audiobooks/enter adds the secret login link on the server Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1a29d500d3
commit
bc3710807d
8 files changed
+263
-44
No files matched your search
+180
-36
@@ -9,8 +9,8 @@ window.fetch = async (...a) => {
|
||||
const $ = s => document.querySelector(s);
|
||||
const esc = s => String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
// Preferences are kept in the browser for speed AND saved on the server, so they survive cleared browser data, a different
|
||||
// device, or the separate storage of the iOS home-screen app. Per-device things (Seafile token, music queue) stay local.
|
||||
const SYNCED = new Set(['mail_hints', 'ab_creds', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
||||
// device, or the separate storage of the iOS home-screen app. Only the music queue stays local.
|
||||
const SYNCED = new Set(['mail_hints', 'ab_creds', 'sf_token', 'plex_lib', 'office_loc', 'cal_default', 'cal_view', 'cal_range', 'task_list', 'task_show_done', 'recents']);
|
||||
const outbox = new Map(); let outTimer = null, warned = false;
|
||||
function flushSettings() {
|
||||
outTimer = null;
|
||||
@@ -62,12 +62,16 @@ const ICONS = {
|
||||
file: '<path d="M7 3h7l5 5v13H7z"/><path d="M14 3v5h5"/>',
|
||||
dl: '<path d="M12 4v12M7 11l5 5 5-5M5 20h14"/>',
|
||||
audio: '<path d="M4 14v-2a8 8 0 0116 0v2"/><rect x="3" y="14" width="4" height="7" rx="1.5"/><rect x="17" y="14" width="4" height="7" rx="1.5"/>',
|
||||
refresh: '<path d="M20 12a8 8 0 11-2.3-5.7M20 4v5h-5"/>',
|
||||
full: '<path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/>',
|
||||
ext: '<path d="M14 4h6v6M20 4l-9 9M18 14v5a1 1 0 01-1 1H5a1 1 0 01-1-1V7a1 1 0 011-1h5"/>',
|
||||
copy: '<rect x="8" y="8" width="12" height="12" rx="2"/><path d="M16 8V6a2 2 0 00-2-2H6a2 2 0 00-2 2v8a2 2 0 002 2h2"/>',
|
||||
music: '<path d="M9 18V5l11-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="17" cy="16" r="3"/>',
|
||||
play: '<path d="M7 4l13 8-13 8z" fill="currentColor"/>',
|
||||
pause: '<path d="M7 4h3v16H7zM14 4h3v16h-3z" fill="currentColor"/>',
|
||||
next: '<path d="M6 5l10 7-10 7zM18 5v14"/>',
|
||||
prev: '<path d="M18 5L8 12l10 7zM6 5v14"/>',
|
||||
repeat: '<path d="M4 11V9a3 3 0 013-3h12M16 3l3 3-3 3M20 13v2a3 3 0 01-3 3H5M8 21l-3-3 3-3"/>',
|
||||
shuffle: '<path d="M3 7h3c4 0 5 10 9 10h6M3 17h3c1.5 0 2.5-1.5 3.5-3M15 7h6M18 4l3 3-3 3M18 14l3 3-3 3"/>',
|
||||
more: '<circle cx="5" cy="12" r="1.6" fill="currentColor"/><circle cx="12" cy="12" r="1.6" fill="currentColor"/><circle cx="19" cy="12" r="1.6" fill="currentColor"/>',
|
||||
cal: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>',
|
||||
@@ -854,19 +858,30 @@ function viewPodcast() {
|
||||
if (canFrame) showFrame('podcast', url, 'Podcast', 'autoplay; fullscreen; picture-in-picture; clipboard-write');
|
||||
}
|
||||
|
||||
// The frame is created once and kept alive, so playback carries on while you use other tabs.
|
||||
// It loads /api/audiobooks/enter, where the server adds the site's secret sign-in link itself (the key never reaches this page).
|
||||
const ENTER = '/api/audiobooks/enter';
|
||||
function viewAudio() {
|
||||
const url = cfg.audiobooks, c = abCreds();
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Audio</h1>
|
||||
<p class="sub">${esc(new URL(url).host)}</p>
|
||||
<div class="bar" style="margin-bottom:0"><a class="btn" href="${esc(url)}" target="_blank" rel="noopener">Open in new tab</a>
|
||||
${c.u ? `<button class="btn ghost" id="cu">${icon('copy')}Username</button>` : ''}
|
||||
${c.p ? `<button class="btn ghost" id="cp">${icon('copy')}Password</button>` : ''}
|
||||
${c.u || c.p ? '' : '<a class="btn ghost" href="#/settings">Save sign-in</a>'}</div></div>`;
|
||||
showFrame('audio', url, 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write'); // created once, so playback carries on across tabs
|
||||
const copy = async (v, what) => { try { await navigator.clipboard.writeText(v); toast(what + ' copied. Paste it into the sign-in box.'); } catch { toast('Could not copy'); } };
|
||||
const cu = $('#cu'), cp = $('#cp');
|
||||
if (cu) cu.onclick = () => copy(c.u, 'Username');
|
||||
if (cp) cp.onclick = () => copy(c.p, 'Password');
|
||||
const c = abCreds(), old = $('#audiowrap iframe[data-k="audio"]');
|
||||
$('#view').innerHTML = `<div class="abbar"><span class="grow"><b>Audiobooks</b><small id="abst">${old ? '' : 'Opening…'}</small></span>
|
||||
${c.u ? `<button class="ibtn" id="cu" aria-label="Copy username" title="Copy username">${icon('copy')}</button>` : ''}
|
||||
<button class="ibtn" id="ar" aria-label="Reload" title="Reload">${icon('refresh')}</button>
|
||||
<button class="ibtn" id="af" aria-label="Full screen" title="Full screen">${icon('full')}</button>
|
||||
<a class="ibtn" href="${ENTER}" target="_blank" rel="noopener" aria-label="Open in new tab" title="Open in new tab">${icon('ext')}</a></div>`;
|
||||
const setSt = t => { const e = $('#abst'); if (e) e.textContent = t; };
|
||||
const open = fresh => {
|
||||
setSt('Opening…');
|
||||
let f = $('#audiowrap iframe[data-k="audio"]'); if (f) f.remove();
|
||||
showFrame('audio', ENTER + (fresh ? '?t=' + Date.now() : ''), 'Audiobooks', 'autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media');
|
||||
f = $('#audiowrap iframe[data-k="audio"]'); f.referrerPolicy = 'no-referrer';
|
||||
let done = false;
|
||||
f.addEventListener('load', () => { done = true; setSt(''); });
|
||||
setTimeout(() => { if (!done) setSt('Still loading… if it stays blank, tap the arrow to open it in its own tab'); }, 10000);
|
||||
};
|
||||
if (!old) open(false); else { showFrame('audio', '', 'Audiobooks', ''); setSt(''); }
|
||||
$('#ar').onclick = () => open(true);
|
||||
$('#af').onclick = () => { const f = $('#audiowrap iframe[data-k="audio"]'); if (f && f.requestFullscreen) f.requestFullscreen().catch(() => toast('Full screen is not available here')); else toast('Full screen is not available here'); };
|
||||
const cu = $('#cu'); if (cu) cu.onclick = async () => { try { await navigator.clipboard.writeText(c.u); toast('Username copied. Password is in Settings.'); } catch { toast('Could not copy'); } };
|
||||
}
|
||||
|
||||
// ---- Plex Music (via the server's /plex proxy; sign-in goes through plex.tv, no password handled here)
|
||||
@@ -1018,7 +1033,7 @@ async function musicRoute([kind = 'albums', key], st) {
|
||||
v.querySelectorAll('button[data-p]').forEach(b => b.onclick = () => playQueue(tracks, +b.dataset.p));
|
||||
v.querySelectorAll('button[data-m]').forEach(b => b.onclick = () => trackSheet(tracks[+b.dataset.m]));
|
||||
$('#pa').onclick = () => tracks.length && playQueue(tracks, 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0);
|
||||
$('#ps').onclick = () => tracks.length && playQueue(shuffled(tracks), 0, true);
|
||||
markPlaying();
|
||||
return;
|
||||
}
|
||||
@@ -1027,14 +1042,99 @@ async function musicRoute([kind = 'albums', key], st) {
|
||||
|
||||
// ---- Player: one <audio> element that lives outside the views, so music keeps playing as you move around.
|
||||
const shuffled = a => { const b = [...a]; for (let i = b.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [b[i], b[j]] = [b[j], b[i]]; } return b; };
|
||||
const pl = { q: [], i: -1, a: new Audio() };
|
||||
const pl = { q: [], i: -1, a: new Audio(), shuffle: false, repeat: 'off', orig: null, alt: new Set() };
|
||||
const cur = () => pl.q[pl.i];
|
||||
function playQueue(q, i = 0) { pl.q = q; pl.i = i; loadCur(); }
|
||||
function loadCur() {
|
||||
function playQueue(q, i = 0, shuffleOn = false) { pl.q = q; pl.i = i; pl.shuffle = shuffleOn; pl.orig = shuffleOn ? [...q] : null; loadCur(0); }
|
||||
// Shuffle mixes only what is still to come, and turning it off puts the queue back in its original order.
|
||||
function setShuffle(on) {
|
||||
if (on === pl.shuffle || !pl.q.length) return;
|
||||
const t = cur();
|
||||
if (on) { pl.orig = [...pl.q]; pl.q = [...pl.q.slice(0, pl.i + 1), ...shuffled(pl.q.slice(pl.i + 1))]; }
|
||||
else if (pl.orig) { const have = new Set(pl.q.map(x => x.key)), keep = pl.orig.filter(x => have.has(x.key)), known = new Set(keep.map(x => x.key));
|
||||
pl.q = [...keep, ...pl.q.filter(x => !known.has(x.key))]; pl.i = Math.max(0, pl.q.findIndex(x => x.key === t.key)); pl.orig = null; }
|
||||
pl.shuffle = on; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); saveState(); tcKick(); updateNow();
|
||||
}
|
||||
function setRepeat(r) { pl.repeat = r; store.set('plex_mode', { shuffle: pl.shuffle, repeat: pl.repeat }); updateNow(); }
|
||||
const srcOf = t => (pl.alt.has(t.key) ? altSrc(t) : t.src);
|
||||
// Some files a browser cannot decode directly; Plex can convert those to MP3 on the fly.
|
||||
const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSearchParams({ path: '/library/metadata/' + t.key, mediaIndex: 0, partIndex: 0, protocol: 'http', directPlay: 0, directStream: 0, audioCodec: 'mp3', maxAudioBitrate: 320 });
|
||||
// Track cache: the current track and the next two are downloaded whole (one at a time, after the current one has buffered),
|
||||
// so changing songs is instant and a slow or lost connection does not stop the music.
|
||||
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
|
||||
const tc = { m: new Map(), ctl: null, busy: false };
|
||||
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
|
||||
function tcTrim() {
|
||||
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
|
||||
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
|
||||
}
|
||||
async function tcFetch(src, signal) {
|
||||
for (let n = 0; n < 4 && !signal.aborted; n++) {
|
||||
try {
|
||||
const r = await fetch(src, { signal }); if (!r.ok) throw new Error('http ' + r.status);
|
||||
const b = await r.blob(); // throws if the connection drops part way
|
||||
if (!signal.aborted) tc.m.set(src, { url: URL.createObjectURL(b) });
|
||||
return;
|
||||
} catch { if (signal.aborted) return; await sleep(2000 * (n + 1)); }
|
||||
}
|
||||
}
|
||||
async function tcPump() {
|
||||
if (tc.busy) return; tc.busy = true;
|
||||
try {
|
||||
for (;;) {
|
||||
tcTrim();
|
||||
const src = tcWant().find(x => !tc.m.has(x)); if (!src) break;
|
||||
tc.ctl = new AbortController(); tc.cur = src;
|
||||
await tcFetch(src, tc.ctl.signal);
|
||||
if (!tc.ctl.signal.aborted && !tc.m.has(src)) break; // gave up on this one; try again on the next trigger
|
||||
}
|
||||
} finally { tc.busy = false; tc.ctl = null; }
|
||||
}
|
||||
function tcKick() { if (tc.ctl && !tcWant().includes(tc.cur)) tc.ctl.abort(); tcPump(); }
|
||||
addEventListener('online', () => { pl.retries = 0; tcKick(); recover(true); });
|
||||
|
||||
let loadTok = 0, loadTimer = null, stallTimer = null;
|
||||
pl.retries = 0; pl.buf = false;
|
||||
// Show the new track at once, but wait a moment before fetching it so quickly tapping Next several times only loads the last one.
|
||||
function loadCur(delay = 220) {
|
||||
const t = cur(); if (!t) return;
|
||||
pl.a.src = t.src; pl.a.play().catch(() => toast('Tap play to start'));
|
||||
if ('mediaSession' in navigator) navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
|
||||
renderMini(); syncUI(); markPlaying(); saveState();
|
||||
clearTimeout(loadTimer); clearTimeout(stallTimer); pl.retries = 0; pl.scrobbled = false;
|
||||
const tok = ++loadTok; pl.buf = true;
|
||||
if (delay) pl.a.pause();
|
||||
setMeta(t); renderMini(); updateNow(); syncUI(); markPlaying(); saveState();
|
||||
const go = () => {
|
||||
if (tok !== loadTok) return;
|
||||
const hit = tc.m.get(t.src);
|
||||
pl.a.src = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||
pl.a.play().catch(e => { if (e && e.name === 'NotAllowedError') { pl.buf = false; syncUI(); toast('Tap play to start'); } });
|
||||
tcKick();
|
||||
const n = pl.q[pl.i + 1]; if (n) new Image().src = px.art(n.thumb, 600);
|
||||
};
|
||||
if (delay) loadTimer = setTimeout(go, delay); else go();
|
||||
}
|
||||
function setMeta(t) {
|
||||
if (!('mediaSession' in navigator)) return;
|
||||
navigator.mediaSession.metadata = new MediaMetadata({ title: t.title, artist: t.artist, album: t.album, artwork: [{ src: px.art(t.thumb, 512), sizes: '512x512' }] });
|
||||
}
|
||||
// If the music stalls, switch to the fully downloaded copy (if there is one) or reload the stream from where it stopped.
|
||||
function recover(now) {
|
||||
clearTimeout(stallTimer); const t = cur(), a = pl.a;
|
||||
if (!t || a.paused) return;
|
||||
stallTimer = setTimeout(() => {
|
||||
if (a.paused || (a.readyState >= 3 && !a.error)) return;
|
||||
const hit = tc.m.get(t.src), pos = a.currentTime;
|
||||
const bad = a.error && (a.error.code === 3 || a.error.code === 4); // cannot decode this file (not a network problem)
|
||||
if (bad && !hit) {
|
||||
if (!pl.alt.has(t.key)) { pl.alt.add(t.key); pl.retries = 0; }
|
||||
else { toast('Cannot play "' + t.title + '". Skipping.'); return advance(true); }
|
||||
}
|
||||
const want = hit && !pl.alt.has(t.key) ? hit.url : srcOf(t);
|
||||
if (!hit && !navigator.onLine) return; // 'online' will call us again
|
||||
if (!hit && !bad && pl.retries >= 5) { pl.buf = false; syncUI(); toast('Lost the connection. It will carry on when it is back.'); return; }
|
||||
if (!hit || a.src !== hit.url) pl.retries++;
|
||||
a.src = want;
|
||||
a.addEventListener('loadedmetadata', () => { try { a.currentTime = pos; } catch {} a.play().catch(() => {}); }, { once: true });
|
||||
recover();
|
||||
}, now ? 300 : 4000);
|
||||
}
|
||||
function saveState() { store.set('plex_state', { q: pl.q, i: pl.i, pos: pl.a.currentTime || 0 }); }
|
||||
const playNextTrack = t => { if (!pl.q.length) return playQueue([t], 0); pl.q.splice(pl.i + 1, 0, t); saveState(); toast('Playing next'); };
|
||||
@@ -1049,6 +1149,7 @@ function trackSheet(t) {
|
||||
// Restore the last queue and position on launch (paused, so nothing starts by itself).
|
||||
function restorePlayer() {
|
||||
const st = store.get('plex_state'); if (!st || !st.q || !st.q[st.i]) return;
|
||||
const m = store.get('plex_mode', {}); pl.shuffle = !!m.shuffle; pl.repeat = m.repeat || 'off';
|
||||
pl.q = st.q; pl.i = st.i; pl.a.src = pl.q[pl.i].src;
|
||||
pl.a.addEventListener('loadedmetadata', () => { if (st.pos) pl.a.currentTime = st.pos; }, { once: true });
|
||||
renderMini(); syncUI();
|
||||
@@ -1058,25 +1159,42 @@ function openQueue() {
|
||||
const el = openSheet('Up next', pl.q.length + ' tracks', `<div class="list" style="background:var(--panel2);border:0;max-height:55dvh;overflow:auto">${upcoming.map(({ t, i }) => `
|
||||
<div class="row it ${i === pl.i ? 'playing' : ''}"><button class="rmain" data-j="${i}"><span class="num">${i === pl.i ? '▶' : i + 1}</span><span class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></span></button>
|
||||
${i === pl.i ? '' : `<button class="ibtn" data-x="${i}" aria-label="Remove">${icon('trash')}</button>`}</div>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
<div class="bar"><button class="btn ghost" id="qc" style="flex:1;justify-content:center">Clear up next</button><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('wide'); el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(); closeSheet(); });
|
||||
el.querySelector('#qc').onclick = () => { pl.q.length = pl.i + 1; if (pl.orig) pl.orig = null, pl.shuffle = false; saveState(); tcKick(); updateNow(); openQueue(); };
|
||||
el.querySelectorAll('[data-j]').forEach(b => b.onclick = () => { pl.i = +b.dataset.j; loadCur(0); closeSheet(); });
|
||||
el.querySelectorAll('[data-x]').forEach(b => b.onclick = () => { const k = +b.dataset.x; pl.q.splice(k, 1); if (k < pl.i) pl.i--; saveState(); openQueue(); });
|
||||
}
|
||||
const next = () => { if (pl.i < pl.q.length - 1) { pl.i++; loadCur(); } else { pl.a.pause(); } };
|
||||
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else { pl.i--; loadCur(); } };
|
||||
const toggle = () => (pl.a.paused ? pl.a.play() : pl.a.pause());
|
||||
pl.a.addEventListener('ended', next);
|
||||
let lastSave = 0;
|
||||
pl.a.addEventListener('timeupdate', () => { if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); } });
|
||||
function advance(auto) {
|
||||
if (auto && pl.repeat === 'one') { pl.a.currentTime = 0; pl.a.play().catch(() => {}); return; }
|
||||
if (pl.i < pl.q.length - 1) pl.i++; else if (pl.repeat === 'all') pl.i = 0; else { pl.a.pause(); syncUI(); return; }
|
||||
loadCur(auto ? 0 : 220);
|
||||
}
|
||||
const next = () => advance(false);
|
||||
const prev = () => { if (pl.a.currentTime > 3 || pl.i === 0) pl.a.currentTime = 0; else if (pl.i > 0) { pl.i--; loadCur(); } };
|
||||
const toggle = () => { if (pl.a.paused && pl.a.getAttribute('src') == null && cur()) return loadCur(0); return pl.a.paused ? pl.a.play() : pl.a.pause(); };
|
||||
pl.a.preload = 'auto';
|
||||
pl.a.addEventListener('ended', () => advance(true));
|
||||
let lastSave = 0, lastPos = 0;
|
||||
pl.a.addEventListener('timeupdate', () => {
|
||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveState(); }
|
||||
const t = cur(), d = pl.a.duration;
|
||||
if (t && !pl.scrobbled && d && pl.a.currentTime > Math.min(d * 0.5, 240)) { pl.scrobbled = true; fetch('/plex/:/scrobble?identifier=com.plexapp.plugins.library&key=' + encodeURIComponent(t.key)).catch(() => {}); } // counts as played in Plex
|
||||
if (Date.now() - lastPos > 1000 && 'mediaSession' in navigator && pl.a.duration) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: pl.a.duration, position: pl.a.currentTime, playbackRate: pl.a.playbackRate }); } catch {} }
|
||||
});
|
||||
addEventListener('pagehide', saveState);
|
||||
['play', 'pause', 'timeupdate', 'loadedmetadata'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
||||
pl.a.addEventListener('error', () => toast('Could not play this track'));
|
||||
['play', 'pause', 'timeupdate', 'loadedmetadata', 'durationchange'].forEach(e => pl.a.addEventListener(e, () => syncUI()));
|
||||
pl.a.addEventListener('playing', () => { pl.buf = false; pl.retries = 0; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; });
|
||||
pl.a.addEventListener('pause', () => { pl.buf = false; clearTimeout(stallTimer); syncUI(); if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; });
|
||||
pl.a.addEventListener('canplaythrough', () => tcKick());
|
||||
['waiting', 'stalled'].forEach(e => pl.a.addEventListener(e, () => { if (pl.a.paused) return; pl.buf = true; syncUI(); recover(); }));
|
||||
pl.a.addEventListener('error', () => { if (!cur() || !pl.a.getAttribute('src')) return; pl.buf = true; syncUI(); recover(); });
|
||||
if ('mediaSession' in navigator) {
|
||||
const ms = navigator.mediaSession;
|
||||
ms.setActionHandler('play', () => pl.a.play()); ms.setActionHandler('pause', () => pl.a.pause());
|
||||
ms.setActionHandler('previoustrack', prev); ms.setActionHandler('nexttrack', next);
|
||||
try { ms.setActionHandler('seekto', d => { pl.a.currentTime = d.seekTime; }); } catch {}
|
||||
try { ms.setActionHandler('seekbackward', d => { pl.a.currentTime = Math.max(0, pl.a.currentTime - (d.seekOffset || 10)); }); ms.setActionHandler('seekforward', d => { pl.a.currentTime += d.seekOffset || 10; }); } catch {}
|
||||
}
|
||||
function markPlaying() {
|
||||
const t = cur(); document.querySelectorAll('#view .row[data-i]').forEach(r => r.classList.toggle('playing', !!t && pl.q[+r.dataset.i]?.key === t.key));
|
||||
@@ -1094,25 +1212,40 @@ function syncUI() {
|
||||
const ic = icon(a.paused ? 'play' : 'pause');
|
||||
const mt = $('#mt'); if (mt && mt.dataset.s !== String(a.paused)) { mt.innerHTML = ic; mt.dataset.s = a.paused; }
|
||||
const mp = $('#mp'); if (mp) mp.style.width = pct + '%';
|
||||
const t = cur(), sub = t ? (pl.buf ? (navigator.onLine ? 'Buffering…' : 'Waiting for connection…') : t.artist) : '';
|
||||
const ms = $('#mo small'); if (ms && ms.textContent !== sub) ms.textContent = sub;
|
||||
const ns = $('#npsub'); if (ns && t) { const v = pl.buf ? sub : t.artist + (t.album ? ' · ' + t.album : ''); if (ns.textContent !== v) ns.textContent = v; }
|
||||
const nt = $('#npt'); if (nt && nt.dataset.s !== String(a.paused)) { nt.innerHTML = ic; nt.dataset.s = a.paused; }
|
||||
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
|
||||
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
|
||||
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
|
||||
}
|
||||
// The Now Playing sheet stays open while tracks change, so refresh what it shows.
|
||||
function updateNow() {
|
||||
const t = cur(), sk = $('#npk'); if (!t || !sk) return;
|
||||
$('#npti').textContent = t.title; $('#npart').src = px.art(t.thumb, 600);
|
||||
const q = $('#npq'); if (q) q.textContent = 'Queue · ' + (pl.q.length - pl.i - 1) + ' up next';
|
||||
delete sk.dataset.drag; sk.value = 0; sk.max = (t.dur || 0) / 1000;
|
||||
const sh = $('#nps'), rp = $('#npr');
|
||||
if (sh) { sh.classList.toggle('on', pl.shuffle); sh.setAttribute('aria-pressed', pl.shuffle); }
|
||||
if (rp) { rp.classList.toggle('on', pl.repeat !== 'off'); rp.dataset.r = pl.repeat === 'one' ? '1' : ''; rp.setAttribute('aria-label', 'Repeat: ' + pl.repeat); }
|
||||
}
|
||||
function openNow() {
|
||||
const t = cur(); if (!t) return;
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0">${esc(t.title)}</h3>
|
||||
<p class="sub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
const el = openSheet('', '', `<div class="np"><img class="art" id="npart" alt="" src="${px.art(t.thumb, 600)}"><h3 style="margin:0" id="npti">${esc(t.title)}</h3>
|
||||
<p class="sub" id="npsub" style="margin:2px 0 12px">${esc(t.artist)}${t.album ? ' · ' + esc(t.album) : ''}</p>
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button></div></div>`);
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const sk = el.querySelector('#npk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#npq').onclick = openQueue;
|
||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||
syncUI();
|
||||
updateNow(); syncUI();
|
||||
}
|
||||
|
||||
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
|
||||
@@ -1190,7 +1323,7 @@ function viewSettings() {
|
||||
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
||||
<div id="sec-conn"><h2>Connections</h2><div class="list">
|
||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
|
||||
<div class="row" id="row-sf"><span class="grow"><b>Seafile</b><small id="sfst">${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}</small></span></div>
|
||||
<div class="row" id="row-plex"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||
<div class="row" id="row-oo"><span class="grow"><b>OnlyOffice</b><small>${esc(cfg.onlyoffice)}</small></span></div>
|
||||
</div>
|
||||
@@ -1215,6 +1348,8 @@ function viewSettings() {
|
||||
});
|
||||
const saveAb = () => store.set('ab_creds', { u: $('#ab-u').value, p: $('#ab-p').value });
|
||||
if ($('#ab-u')) { $('#ab-u').oninput = saveAb; $('#ab-p').oninput = saveAb; }
|
||||
if (signed) sf.req('/api2/account/info/').then(a => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · Signed in' + (a && (a.email || a.name) ? ' as ' + (a.email || a.name) : ''); })
|
||||
.catch(e => { const el = $('#sfst'); if (el) el.textContent = cfg.seafile + ' · ' + (sf.token() ? 'Signed in (could not check: ' + e.message + ')' : 'Signed out'); });
|
||||
if (allowed('music')) jfetch('/api/plex/status').then(st => {
|
||||
const el = $('#plexst'); if (!el) return;
|
||||
el.textContent = st.signedIn ? (st.server ? 'Signed in · ' + st.server : 'Signed in · no server chosen') : 'Signed out';
|
||||
@@ -1556,3 +1691,12 @@ function dirSheet(repo, path, name, dir) {
|
||||
route();
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
})();
|
||||
|
||||
// Keyboard (desktop): space play/pause, arrows seek, N/P next/previous.
|
||||
addEventListener('keydown', e => {
|
||||
const tag = e.target.tagName || '', k = e.key;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey || !cur() || /^(INPUT|TEXTAREA|SELECT)$/.test(tag) || (k === ' ' && /^(BUTTON|A)$/.test(tag))) return;
|
||||
if (k === ' ') { e.preventDefault(); toggle(); }
|
||||
else if (k === 'ArrowRight') pl.a.currentTime += 10; else if (k === 'ArrowLeft') pl.a.currentTime = Math.max(0, pl.a.currentTime - 10);
|
||||
else if (k === 'n' || k === 'N') next(); else if (k === 'p' || k === 'P') prev();
|
||||
});
|
||||
Reference in new issue
Block a user