Offline downloads, service worker, iOS audio unlock fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7f59c6d27b
commit
5f88ab891b
5 files changed
+186
-29
No files matched your search
+1
-1
@@ -3,7 +3,7 @@ FROM python:3.13-slim
|
||||
ENV PYTHONUNBUFFERED=1 DATA_DIR=/data PORT=8080
|
||||
RUN useradd --system --uid 10001 --home /app app && mkdir /data && chown app /data
|
||||
WORKDIR /app
|
||||
COPY server.py index.html admin.html manifest.webmanifest icon-180.png icon-192.png icon-512.png ./
|
||||
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-180.png icon-192.png icon-512.png ./
|
||||
USER app
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -32,3 +32,15 @@ PLAYER_PASSWORD=...
|
||||
|
||||
`docker compose up -d`, then sign in at port 8080. The default account is only created if it doesn't exist, so changing its password later with
|
||||
`docker exec -it audiobook-player python server.py passwd nate` sticks. Add more people with `adduser`.
|
||||
|
||||
## Offline listening
|
||||
|
||||
Tap the ↓ on a book (or "Download" in the player) to save it on the device. Downloaded books show a ✓, and the
|
||||
"Downloaded only" view lists them. They play with no connection to the server, and progress syncs when you're back online.
|
||||
|
||||
- Keep the app open while a download runs; iOS pauses pages in the background.
|
||||
- The audio is stored in the browser (IndexedDB). iOS counts it against the site's storage quota, which is much larger
|
||||
once the site is added to the Home Screen.
|
||||
- **To open the app itself offline, serve it over HTTPS.** Browsers only allow the service worker (`sw.js`) on secure
|
||||
origins. Put a reverse proxy with a certificate in front of port 8080 (and a domain name). Over plain HTTP, downloads
|
||||
still play but the page can't load without a connection.
|
||||
+147
-26
@@ -57,6 +57,9 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
|
||||
.cov .eq{position:absolute;right:8px;top:8px;background:var(--acc);color:#fff;border-radius:99px;font-size:11px;font-weight:700;padding:2px 8px}
|
||||
.card b{display:block;margin-top:8px;font-size:13.5px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
||||
.card span{color:var(--mut);font-size:12.5px;display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cov .dlb{position:absolute;right:8px;bottom:12px;min-width:30px;height:30px;padding:0 8px;border-radius:99px;background:rgba(0,0,0,.62);color:#fff;font-size:13px;font-weight:700;display:grid;place-items:center;backdrop-filter:blur(6px);cursor:pointer}
|
||||
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
|
||||
#offline{margin:12px 0 0;padding:10px 14px;border-radius:12px;background:var(--surf);border:1px solid var(--bd);color:var(--mut);font-size:13.5px}
|
||||
.skel .cov{animation:pulse 1.2s infinite}
|
||||
@keyframes pulse{50%{opacity:.5}}
|
||||
#msg{padding:60px 0;text-align:center;color:var(--mut)}
|
||||
@@ -128,10 +131,11 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
|
||||
<div class="search"><svg><use href="#i-search"/></svg><input id="q" type="search" placeholder="Search title, author, series"></div>
|
||||
<a class="pill hide" id="adminlink" href="/admin" style="padding:8px 12px">Admin</a>
|
||||
<button class="pill" id="who" title="Sign out" style="padding:8px 12px"></button>
|
||||
<select id="sort" aria-label="Sort"><option value="author">Group by author</option><option value="title">Sort by title</option><option value="recent">Recently played</option></select>
|
||||
<select id="sort" aria-label="Sort"><option value="author">Group by author</option><option value="title">Sort by title</option><option value="recent">Recently played</option><option value="dl">Downloaded only</option></select>
|
||||
</div></header>
|
||||
|
||||
<main>
|
||||
<div id="offline" class="hide">You're offline or OpenAudible isn't reachable. Showing your downloaded books.</div>
|
||||
<div id="msg" class="hide"></div>
|
||||
<section id="contSec" class="hide"><h2>Continue listening</h2><div id="cont" class="shelf"></div></section>
|
||||
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
|
||||
@@ -160,7 +164,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
|
||||
<button class="ib" id="fwd" aria-label="Forward 30 seconds"><svg><use href="#i-fwd"/></svg><small>30</small></button>
|
||||
<button class="ib" id="nch" aria-label="Next chapter">⏭</button>
|
||||
</div>
|
||||
<div class="opts"><button class="pill" id="speed">1×</button><button class="pill" id="sleep">Sleep: off</button></div>
|
||||
<div class="opts"><button class="pill" id="dl">↓ Download</button><button class="pill" id="speed">1×</button><button class="pill" id="sleep">Sleep: off</button></div>
|
||||
<div class="chaps hide" id="chapBox"><h3>Chapters</h3><div class="chlist" id="chlist"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,7 +182,7 @@ const bookId = b => pick(b, 'id', 'asin', 'ASIN', 'book_id');
|
||||
const title = b => pick(b, 'title', 'title_short', 'name') || '(untitled)';
|
||||
const author = b => pick(b, 'author', 'author_name', 'authors', 'author_display') || '';
|
||||
const series = b => pick(b, 'series_name', 'series') || '';
|
||||
const cover = b => `/api/books/${encodeURIComponent(bookId(b))}/cover`;
|
||||
const cover = b => (typeof coverUrls !== 'undefined' && coverUrls.get(bookId(b))) || `/api/books/${encodeURIComponent(bookId(b))}/cover`;
|
||||
const fmt = s => { s = Math.max(0, Math.floor(s || 0)); const h = Math.floor(s/3600), m = Math.floor(s%3600/60), x = s%60;
|
||||
return (h ? h + ':' + String(m).padStart(2, '0') : m) + ':' + String(x).padStart(2, '0'); };
|
||||
const get = async p => { const r = await fetch(p); if (r.status === 401) { location.href = '/login'; throw new Error('Signing in…'); } if (!r.ok) { let m = ''; try { m = (await r.json()).error || ''; } catch {} throw new Error(m || p + ' → ' + r.status); } return r.json(); };
|
||||
@@ -190,20 +194,116 @@ const ico = (id, name) => { $(id).innerHTML = `<svg><use href="#i-${name}"/></sv
|
||||
const hue = s => [...s].reduce((a, c) => a * 31 + c.charCodeAt(0), 7) % 360;
|
||||
let toastT; const toast = t => { $('toast').textContent = t; $('toast').classList.add('on'); clearTimeout(toastT); toastT = setTimeout(() => $('toast').classList.remove('on'), 2200); };
|
||||
|
||||
// ---- offline downloads (IndexedDB; books are stored in 8 MB chunks so memory stays low) ----
|
||||
const CHUNK = 8 * 1024 * 1024;
|
||||
const OFF = (() => {
|
||||
let dbp;
|
||||
const open = () => dbp || (dbp = new Promise((res, rej) => {
|
||||
const r = indexedDB.open('audiobooks', 1);
|
||||
r.onupgradeneeded = () => { r.result.createObjectStore('meta', { keyPath: 'id' }); r.result.createObjectStore('chunks'); };
|
||||
r.onsuccess = () => res(r.result); r.onerror = () => rej(r.error);
|
||||
}));
|
||||
const run = (store, mode, fn) => open().then(d => new Promise((res, rej) => {
|
||||
const t = d.transaction(store, mode), r = fn(t.objectStore(store));
|
||||
t.oncomplete = () => res(r && r.result); t.onerror = t.onabort = () => rej(t.error);
|
||||
}));
|
||||
const range = id => IDBKeyRange.bound([id, 0], [id, Infinity]);
|
||||
return {
|
||||
list: () => run('meta', 'readonly', s => s.getAll()),
|
||||
putMeta: m => run('meta', 'readwrite', s => s.put(m)),
|
||||
putChunk: (id, i, blob) => run('chunks', 'readwrite', s => s.put(blob, [id, i])),
|
||||
chunks: id => run('chunks', 'readonly', s => s.getAll(range(id))),
|
||||
remove: async id => { await run('chunks', 'readwrite', s => s.delete(range(id))); await run('meta', 'readwrite', s => s.delete(id)); }
|
||||
};
|
||||
})();
|
||||
const downloaded = new Map(), dlState = new Map(), coverUrls = new Map();
|
||||
let curObjUrl = null;
|
||||
const setCoverUrl = m => { if (coverUrls.has(m.id)) URL.revokeObjectURL(coverUrls.get(m.id)); if (m.cover) coverUrls.set(m.id, URL.createObjectURL(m.cover)); };
|
||||
async function initOffline() {
|
||||
try { for (const m of await OFF.list()) { downloaded.set(m.id, m); setCoverUrl(m); } } catch {}
|
||||
}
|
||||
const mb = n => n >= 1e9 ? (n / 1e9).toFixed(1) + ' GB' : Math.round(n / 1e6) + ' MB';
|
||||
const dlLabel = id => { const st = dlState.get(id); return st ? (st.pct ? Math.round(st.pct * 100) + '%' : st.got ? mb(st.got) : '…') : downloaded.has(id) ? '✓' : '↓'; };
|
||||
function uiDl() {
|
||||
document.querySelectorAll('.dlb').forEach(el => { const id = el.dataset.id; el.textContent = dlLabel(id); el.classList.toggle('busy', dlState.has(id)); el.classList.toggle('done', !dlState.has(id) && downloaded.has(id)); });
|
||||
if (!current) return;
|
||||
const id = bookId(current), st = dlState.get(id);
|
||||
$('dl').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded' : '↓ Download';
|
||||
$('dl').classList.toggle('on', !!st || downloaded.has(id));
|
||||
}
|
||||
async function download(b) {
|
||||
const id = bookId(b), eid = encodeURIComponent(id);
|
||||
if (dlState.has(id)) return;
|
||||
const ctl = new AbortController(), st = { pct: 0, got: 0, ctl };
|
||||
dlState.set(id, st); uiDl();
|
||||
toast(window.isSecureContext ? 'Downloading. Keep this screen open until it finishes.' : 'Downloading. (Opening the app offline needs HTTPS.)');
|
||||
try { navigator.storage && navigator.storage.persist && navigator.storage.persist(); } catch {}
|
||||
let n = 0;
|
||||
try {
|
||||
const file = await findFile(eid);
|
||||
if (!file) throw new Error('no playable MP3/M4B for this book');
|
||||
const r = await fetch(`/api/books/${eid}/files/${encodeURIComponent(file)}`, { signal: ctl.signal });
|
||||
if (!r.ok) throw new Error('server said ' + r.status);
|
||||
const total = +r.headers.get('Content-Length') || 0;
|
||||
const type = r.headers.get('Content-Type') || (/\.mp3$/i.test(file) ? 'audio/mpeg' : 'audio/mp4');
|
||||
const rd = r.body.getReader();
|
||||
let buf = [], size = 0;
|
||||
const flush = async () => { if (size) { await OFF.putChunk(id, n++, new Blob(buf)); buf = []; size = 0; } };
|
||||
for (;;) {
|
||||
const { done, value } = await rd.read();
|
||||
if (done) break;
|
||||
buf.push(value); size += value.length; st.got += value.length; st.pct = total ? st.got / total : 0;
|
||||
if (size >= CHUNK) await flush();
|
||||
uiDl();
|
||||
}
|
||||
await flush();
|
||||
let coverBlob = null, chaps = null;
|
||||
try { const c = await fetch(cover(b)); if (c.ok) coverBlob = await c.blob(); } catch {}
|
||||
try { chaps = await get(`/api/books/${eid}/chapters/flat`); } catch {}
|
||||
const meta = { id, book: b, file, type, chunks: n, size: st.got, cover: coverBlob, chapters: chaps, t: Date.now() };
|
||||
await OFF.putMeta(meta); downloaded.set(id, meta); setCoverUrl(meta);
|
||||
toast(`Downloaded “${title(b)}”`);
|
||||
} catch (e) {
|
||||
await OFF.remove(id).catch(() => {}); // never leave half a book behind
|
||||
if (e.name !== 'AbortError') toast(e.name === 'QuotaExceededError' ? 'Not enough storage on this device.' : 'Download failed: ' + e.message);
|
||||
} finally { dlState.delete(id); uiDl(); render(); }
|
||||
}
|
||||
async function removeDl(id) {
|
||||
await OFF.remove(id).catch(() => {});
|
||||
downloaded.delete(id);
|
||||
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
|
||||
uiDl(); render(); toast('Removed download');
|
||||
}
|
||||
function toggleDl(b) {
|
||||
const id = bookId(b), st = dlState.get(id);
|
||||
if (st) return st.ctl.abort();
|
||||
if (downloaded.has(id)) { if (confirm(`Remove the downloaded copy of “${title(b)}”?`)) removeDl(id); return; }
|
||||
if (!window.indexedDB) return toast("Downloads aren't available in this browser");
|
||||
download(b);
|
||||
}
|
||||
$('dl').onclick = () => current && toggleDl(current);
|
||||
|
||||
// ---- library ----
|
||||
async function load() {
|
||||
if (!/^https?:$/.test(location.protocol)) {
|
||||
$('msg').innerHTML = `<div>Open this page through the player server, not as a file.<br><small>Run <code>python3 server.py</code> and visit <b>http://localhost:8080</b></small></div>`;
|
||||
$('msg').classList.remove('hide'); return;
|
||||
}
|
||||
await initOffline();
|
||||
$('grid').innerHTML = Array(12).fill('<div class="card skel"><div class="cov"></div></div>').join('');
|
||||
$('msg').classList.add('hide');
|
||||
try {
|
||||
const data = await get('/api/books');
|
||||
books = Array.isArray(data) ? data : (data.books || Object.values(data));
|
||||
$('offline').classList.add('hide');
|
||||
render();
|
||||
pull();
|
||||
} catch (e) {
|
||||
if (downloaded.size) { // can't reach the library: still let people listen to what they saved
|
||||
books = [...downloaded.values()].map(m => m.book);
|
||||
$('sort').value = 'dl'; $('offline').classList.remove('hide');
|
||||
render(); return;
|
||||
}
|
||||
$('grid').replaceChildren();
|
||||
$('msg').innerHTML = `<div>Couldn't load your library.<br><small>${e.message}</small></div><button onclick="load()">Try again</button>`;
|
||||
$('msg').classList.remove('hide');
|
||||
@@ -219,18 +319,23 @@ function card(b) {
|
||||
img.onerror = () => { const p = document.createElement('div'); p.className = 'ph'; p.style.background = `linear-gradient(135deg,hsl(${hue(title(b))} 55% 40%),hsl(${hue(title(b)) + 40} 55% 28%))`; p.textContent = title(b); img.replaceWith(p); };
|
||||
d.querySelector('b').textContent = title(b);
|
||||
d.querySelector('span').textContent = author(b);
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' : ''); badge.dataset.id = id; badge.textContent = dlLabel(id);
|
||||
badge.setAttribute('role', 'button'); badge.setAttribute('aria-label', 'Download for offline listening');
|
||||
badge.onclick = e => { e.stopPropagation(); toggleDl(b); };
|
||||
d.querySelector('.cov').append(badge);
|
||||
d.onclick = () => play(b, true);
|
||||
return d;
|
||||
}
|
||||
function render() {
|
||||
const q = $('q').value.trim().toLowerCase(), sort = $('sort').value;
|
||||
let list = books.filter(b => !q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q));
|
||||
let list = books.filter(b => (sort !== 'dl' || downloaded.has(bookId(b))) && (!q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q)));
|
||||
const last = b => (store.get('prog:' + bookId(b)) || {}).t || 0;
|
||||
list.sort(sort === 'author' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b))
|
||||
list.sort(sort === 'author' || sort === 'dl' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b))
|
||||
: sort === 'recent' ? (a, b) => last(b) - last(a)
|
||||
: (a, b) => title(a).localeCompare(title(b)));
|
||||
$('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` : '';
|
||||
if (sort === 'author') {
|
||||
$('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` + (sort === 'dl' ? ' · ' + mb(list.reduce((a, b) => a + ((downloaded.get(bookId(b)) || {}).size || 0), 0)) : '') : '';
|
||||
if (sort === 'author' || sort === 'dl') {
|
||||
const groups = new Map();
|
||||
const labels = new Map();
|
||||
for (const b of list) {
|
||||
@@ -254,7 +359,7 @@ function render() {
|
||||
$('grid').className = 'grid';
|
||||
$('grid').replaceChildren(...list.map(card));
|
||||
}
|
||||
if (!list.length && books.length) { $('msg').textContent = 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
|
||||
if (!list.length && books.length) { $('msg').textContent = sort === 'dl' ? 'Nothing downloaded yet. Tap the ↓ on a book to save it.' : 'No matches.'; $('msg').classList.remove('hide'); } else $('msg').classList.add('hide');
|
||||
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; })
|
||||
.sort((a, b) => last(b) - last(a)).slice(0, 12);
|
||||
$('contSec').classList.toggle('hide', !cont.length || !!q);
|
||||
@@ -272,11 +377,23 @@ async function findFile(id) {
|
||||
}
|
||||
async function play(b, open) {
|
||||
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
|
||||
const id = encodeURIComponent(bookId(b));
|
||||
let file;
|
||||
try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); }
|
||||
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
|
||||
const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
|
||||
let src;
|
||||
if (off) {
|
||||
try {
|
||||
const parts = await OFF.chunks(bookId(b));
|
||||
if (parts.length !== off.chunks) throw new Error('the saved copy is incomplete; remove it and download again');
|
||||
src = URL.createObjectURL(new Blob(parts, { type: off.type }));
|
||||
} catch (e) { return toast('Could not open the downloaded copy: ' + e.message); }
|
||||
} else {
|
||||
let file;
|
||||
try { file = await findFile(id); } catch (e) { return toast('Could not list files: ' + e.message); }
|
||||
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
|
||||
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
|
||||
}
|
||||
saveProgress();
|
||||
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
|
||||
curObjUrl = off ? src : null;
|
||||
current = b;
|
||||
$('mini').classList.remove('hide');
|
||||
const fallback = 'data:image/svg+xml,' + encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="hsl(${hue(title(b))} 55% 40%)"/><stop offset="1" stop-color="hsl(${hue(title(b)) + 40} 55% 28%)"/></linearGradient></defs><rect width="100" height="100" fill="url(#g)"/><text x="50" y="62" font-size="40" font-family="sans-serif" font-weight="700" text-anchor="middle" fill="#fff">${(title(b)[0] || '?').replace(/[<&]/g, '')}</text></svg>`);
|
||||
@@ -284,11 +401,13 @@ async function play(b, open) {
|
||||
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
|
||||
$('mtitle').textContent = $('ftitle').textContent = title(b);
|
||||
$('msub').textContent = $('fsub').textContent = author(b);
|
||||
audio.src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
|
||||
await unlockP;
|
||||
audio.src = src;
|
||||
uiDl();
|
||||
await pull();
|
||||
const saved = (store.get('prog:' + bookId(b)) || {}).pos;
|
||||
audio.addEventListener('loadedmetadata', () => { if (saved && saved < audio.duration - 30) audio.currentTime = saved; audio.playbackRate = speeds[spIdx]; audio.play().catch(() => {}); }, { once: true });
|
||||
loadChapters(id);
|
||||
loadChapters(id, off);
|
||||
render();
|
||||
if (open) openFull();
|
||||
if ('mediaSession' in navigator) {
|
||||
@@ -299,10 +418,10 @@ async function play(b, open) {
|
||||
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
|
||||
}
|
||||
}
|
||||
async function loadChapters(id) {
|
||||
async function loadChapters(id, off) {
|
||||
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
|
||||
try {
|
||||
const data = await get(`/api/books/${id}/chapters/flat`);
|
||||
const data = (off && off.chapters) || await get(`/api/books/${id}/chapters/flat`);
|
||||
const arr = Array.isArray(data) ? data : (data.chapters || []);
|
||||
chapters = arr.map(c => {
|
||||
let s = pick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
|
||||
@@ -403,19 +522,21 @@ document.addEventListener('keydown', e => {
|
||||
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
|
||||
document.addEventListener('visibilitychange', () => { if (document.hidden) { saveProgress(); push(true); } else if (audio.paused) pull(); });
|
||||
ico('play', 'play'); ico('mplay', 'play');
|
||||
// iOS only lets audio start from a tap. Unlock the element on the first touch so later play() calls
|
||||
// (which happen after async fetches) are still allowed.
|
||||
let unlocked = false;
|
||||
// iOS only lets audio start from a tap. Unlock the element on the first touch (before any click handler runs)
|
||||
// so the later play() calls, which happen after async fetches, are still allowed. play() waits for this to finish.
|
||||
const SILENT = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=';
|
||||
let unlocked = false, unlockP = Promise.resolve();
|
||||
const unlock = () => {
|
||||
if (unlocked) return; unlocked = true;
|
||||
const keep = audio.src;
|
||||
if (!keep) {
|
||||
audio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=';
|
||||
audio.play().then(() => audio.pause()).catch(() => {});
|
||||
}
|
||||
if (audio.src) return;
|
||||
audio.src = SILENT;
|
||||
unlockP = audio.play().then(() => audio.pause()).catch(() => {});
|
||||
};
|
||||
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true }));
|
||||
fetch('/me').then(r => r.json()).then(m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
|
||||
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true, capture: true }));
|
||||
const showMe = m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').classList.remove('hide'); $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; };
|
||||
$('who').classList.add('hide');
|
||||
fetch('/me').then(r => r.ok ? r.json() : Promise.reject()).then(m => { store.set('me', m); showMe(m); }).catch(() => { const m = store.get('me'); if (m) showMe(m); });
|
||||
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -41,7 +41,7 @@ SESSION_DAYS = 90
|
||||
|
||||
# Files the browser fetches without a session (home-screen icon and manifest).
|
||||
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/icon-180.png": "image/png",
|
||||
"/icon-192.png": "image/png", "/icon-512.png": "image/png"}
|
||||
"/icon-192.png": "image/png", "/icon-512.png": "image/png", "/sw.js": "text/javascript"}
|
||||
|
||||
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
|
||||
ALLOWED = [re.compile(p) for p in (
|
||||
@@ -223,7 +223,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.logout()
|
||||
if path in PUBLIC:
|
||||
with open(os.path.join(HERE, path[1:]), "rb") as f:
|
||||
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "public, max-age=86400")])
|
||||
return self.send_body(200, f.read(), PUBLIC[path], [("Cache-Control", "no-cache" if path in ("/sw.js", "/manifest.webmanifest") else "public, max-age=86400")])
|
||||
user = self.user()
|
||||
if not user:
|
||||
return self.deny(path)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Keeps the app shell so the player opens with no connection. Audio is stored separately by the page (IndexedDB).
|
||||
// Service workers only run on HTTPS (or localhost).
|
||||
const CACHE = 'shell-v1';
|
||||
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
|
||||
});
|
||||
self.addEventListener('activate', e => {
|
||||
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k !== CACHE).map(k => caches.delete(k)))).then(() => self.clients.claim()));
|
||||
});
|
||||
self.addEventListener('fetch', e => {
|
||||
const r = e.request, url = new URL(r.url);
|
||||
if (r.method !== 'GET' || url.origin !== location.origin) return;
|
||||
if (r.mode === 'navigate' && url.pathname === '/') {
|
||||
// newest page when online, the saved one when not. Never save a sign-in redirect.
|
||||
e.respondWith(fetch(r).then(res => {
|
||||
if (res.ok && !res.redirected) { const copy = res.clone(); caches.open(CACHE).then(c => c.put('/', copy)); }
|
||||
return res;
|
||||
}).catch(() => caches.match('/')));
|
||||
} else if (STATIC.includes(url.pathname)) {
|
||||
e.respondWith(caches.match(r).then(hit => hit || fetch(r)));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user