diff --git a/Dockerfile b/Dockerfile
index e7b6d55..ae65231 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index 3f9a44a..0332d54 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/index.html b/index.html
index 0a57fea..aff5377 100644
--- a/index.html
+++ b/index.html
@@ -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}
Admin
- Group by author Sort by title Recently played
+ Group by author Sort by title Recently played Downloaded only
+ You're offline or OpenAudible isn't reachable. Showing your downloaded books.
@@ -160,7 +164,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
30
⏭
- 1× Sleep: off
+ ↓ Download 1× Sleep: off
@@ -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 = ` [...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 = `Open this page through the player server, not as a file.Run python3 server.py and visit http://localhost:8080
`;
$('msg').classList.remove('hide'); return;
}
+ await initOffline();
$('grid').innerHTML = Array(12).fill('').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 = `Couldn't load your library.${e.message}
Try again `;
$('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(`${(title(b)[0] || '?').replace(/[<&]/g, '')} `);
@@ -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();