New headphones app icon set, hardened streaming and playback, download removal controls
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
No files matched your search
@@ -7,3 +7,4 @@ sessions.json
|
||||
progress*.json
|
||||
*.tmp
|
||||
README.md
|
||||
tools
|
||||
@@ -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 sw.js icon-180.png icon-192.png icon-512.png ./
|
||||
COPY server.py index.html admin.html manifest.webmanifest sw.js icon-*.png ./
|
||||
USER app
|
||||
VOLUME /data
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -44,3 +44,23 @@ Tap the ↓ on a book (or "Download" in the player) to save it on the device. Do
|
||||
- **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.
|
||||
|
||||
## Troubleshooting playback
|
||||
|
||||
Run this on the player server (or `docker exec -it audiobook-player python server.py check`):
|
||||
|
||||
```bash
|
||||
python3 server.py check
|
||||
```
|
||||
|
||||
It logs in to OpenAudible, lists a few books' files, and tests whether each file can stream to a phone. iPhones will
|
||||
only play audio from a server that answers byte-range requests; if OpenAudible doesn't, the player server emulates
|
||||
them (seeking in very large books is slower). The container's log (`docker logs audiobook-player`) notes when that happens
|
||||
and records any upstream errors.
|
||||
|
||||
The player also recovers by itself from dropped connections and stalls (it reloads from the same spot, up to four times),
|
||||
and your position is saved every few seconds, on pause, and when the page is closed or backgrounded.
|
||||
|
||||
## Icons
|
||||
|
||||
`python3 tools/make_icons.py` regenerates the app icons (needs Pillow).
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 41 KiB |
@@ -11,6 +11,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="apple-touch-icon" href="/icon-180.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
||||
<title>Audiobook Player</title>
|
||||
<style>
|
||||
:root{--bg:#f6f6f4;--surf:#fff;--surf2:#ececea;--fg:#16161a;--mut:#74747c;--acc:#e8590c;--acc2:#ff8a3d;--bd:rgba(0,0,0,.08);--sh:0 8px 30px rgba(0,0,0,.12)}
|
||||
@@ -58,8 +59,11 @@ h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
|
||||
.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.rm{background:rgba(0,0,0,.72);font-size:12px;padding:0 12px}
|
||||
.cov .dlb.done{background:var(--acc)}.cov .dlb.busy{background:#2b6cb0}
|
||||
#dlbar{display:flex;align-items:center;justify-content:space-between;gap:10px;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}
|
||||
#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}
|
||||
.pp.buf{animation:pulse 1s infinite}
|
||||
.skel .cov{animation:pulse 1.2s infinite}
|
||||
@keyframes pulse{50%{opacity:.5}}
|
||||
#msg{padding:60px 0;text-align:center;color:var(--mut)}
|
||||
@@ -138,6 +142,7 @@ a.pill{text-decoration:none;color:var(--fg);display:inline-block}
|
||||
<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>
|
||||
<div id="dlbar" class="hide"><span id="dlinfo"></span><button class="pill" id="rmall" style="padding:7px 14px">Remove all</button></div>
|
||||
<section><h2>Library<span class="count" id="count"></span></h2><div id="grid" class="grid"></div></section>
|
||||
</main>
|
||||
|
||||
@@ -223,12 +228,13 @@ 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) ? '✓' : '↓'; };
|
||||
const inDlView = () => $('sort').value === 'dl';
|
||||
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) ? (inDlView() ? '✕ Remove' : '✓') : '↓'; };
|
||||
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)); });
|
||||
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)); el.classList.toggle('rm', !dlState.has(id) && downloaded.has(id) && inDlView()); });
|
||||
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').textContent = st ? `Downloading ${dlLabel(id)} · tap to cancel` : downloaded.has(id) ? '✓ Downloaded · tap to remove' : '↓ Download';
|
||||
$('dl').classList.toggle('on', !!st || downloaded.has(id));
|
||||
}
|
||||
async function download(b) {
|
||||
@@ -269,11 +275,24 @@ async function download(b) {
|
||||
} finally { dlState.delete(id); uiDl(); render(); }
|
||||
}
|
||||
async function removeDl(id) {
|
||||
if (current && bookId(current) === id && curObjUrl) userPause(); // it's playing from the copy we're about to delete
|
||||
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');
|
||||
}
|
||||
async function removeAll() {
|
||||
if (!downloaded.size) return;
|
||||
if (!confirm(`Remove all ${downloaded.size} downloaded books from this device? They stay in your library and can be downloaded again.`)) return;
|
||||
if (current && curObjUrl) userPause();
|
||||
for (const id of [...downloaded.keys()]) {
|
||||
await OFF.remove(id).catch(() => {});
|
||||
downloaded.delete(id);
|
||||
if (coverUrls.has(id)) { URL.revokeObjectURL(coverUrls.get(id)); coverUrls.delete(id); }
|
||||
}
|
||||
uiDl(); render(); toast('Removed all downloads');
|
||||
}
|
||||
$('rmall').onclick = removeAll;
|
||||
function toggleDl(b) {
|
||||
const id = bookId(b), st = dlState.get(id);
|
||||
if (st) return st.ctl.abort();
|
||||
@@ -320,7 +339,7 @@ function card(b) {
|
||||
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.className = 'dlb' + (dlState.has(id) ? ' busy' : downloaded.has(id) ? ' done' + (inDlView() ? ' rm' : '') : ''); 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);
|
||||
@@ -359,8 +378,15 @@ function render() {
|
||||
$('grid').className = 'grid';
|
||||
$('grid').replaceChildren(...list.map(card));
|
||||
}
|
||||
const showBar = sort === 'dl' && downloaded.size > 0;
|
||||
$('dlbar').classList.toggle('hide', !showBar);
|
||||
if (showBar) {
|
||||
const used = [...downloaded.values()].reduce((a, m) => a + (m.size || 0), 0);
|
||||
$('dlinfo').textContent = `${downloaded.size} downloaded · ${mb(used)} on this device`;
|
||||
try { navigator.storage.estimate().then(e => { if (e.quota && sort === $('sort').value) $('dlinfo').textContent += ` · room for about ${mb(Math.max(0, e.quota - e.usage))} more`; }); } catch {}
|
||||
}
|
||||
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; })
|
||||
const cont = books.filter(b => { const p = store.get('prog:' + bookId(b)); return (sort !== 'dl' || downloaded.has(bookId(b))) && 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);
|
||||
$('cont').replaceChildren(...cont.map(card));
|
||||
@@ -375,9 +401,12 @@ async function findFile(id) {
|
||||
const names = arr.map(f => typeof f === 'string' ? f : pick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
|
||||
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => PLAYABLE.test(n));
|
||||
}
|
||||
let playTok = 0, playIntent = false, retries = 0, curSrc = '', stallT, loadT, recoverT, needOnline = false;
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const setBuf = on => { $('play').classList.toggle('buf', on); $('mplay').classList.toggle('buf', on); };
|
||||
async function play(b, open) {
|
||||
if (current && bookId(current) === bookId(b)) { if (open) openFull(); return; }
|
||||
const id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
|
||||
const tok = ++playTok, id = encodeURIComponent(bookId(b)), off = downloaded.get(bookId(b));
|
||||
let src;
|
||||
if (off) {
|
||||
try {
|
||||
@@ -391,6 +420,10 @@ async function play(b, open) {
|
||||
if (!file) return toast('No playable MP3/M4B for this book. Convert it in OpenAudible first.');
|
||||
src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
|
||||
}
|
||||
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; } // a newer tap took over
|
||||
await Promise.race([pull(), wait(2500)]); // newest position from other devices, but never wait long
|
||||
await unlockP;
|
||||
if (tok !== playTok) { if (off) URL.revokeObjectURL(src); return; }
|
||||
saveProgress();
|
||||
if (curObjUrl) URL.revokeObjectURL(curObjUrl);
|
||||
curObjUrl = off ? src : null;
|
||||
@@ -401,12 +434,10 @@ async function play(b, open) {
|
||||
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
|
||||
$('mtitle').textContent = $('ftitle').textContent = title(b);
|
||||
$('msub').textContent = $('fsub').textContent = author(b);
|
||||
await unlockP;
|
||||
audio.src = src;
|
||||
playIntent = true; retries = 0; needOnline = false;
|
||||
clearTimeout(stallT); clearTimeout(recoverT); setBuf(true);
|
||||
startSource(src, (store.get('prog:' + bookId(b)) || {}).pos);
|
||||
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, off);
|
||||
render();
|
||||
if (open) openFull();
|
||||
@@ -415,9 +446,39 @@ async function play(b, open) {
|
||||
ms.metadata = new MediaMetadata({ title: title(b), artist: author(b), artwork: [{ src: cover(b) }] });
|
||||
ms.setActionHandler('seekbackward', () => skip(-30)); ms.setActionHandler('seekforward', () => skip(30));
|
||||
ms.setActionHandler('seekto', e => { audio.currentTime = e.seekTime; });
|
||||
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
|
||||
ms.setActionHandler('play', userPlay); ms.setActionHandler('pause', userPause);
|
||||
}
|
||||
}
|
||||
// The listener is attached BEFORE the source is set, so a fast source can't fire loadedmetadata unseen.
|
||||
function startSource(src, at, bust) {
|
||||
clearTimeout(loadT);
|
||||
curSrc = src;
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
clearTimeout(loadT);
|
||||
const d = audio.duration;
|
||||
if (at && isFinite(d) && at < d - 30) audio.currentTime = at;
|
||||
audio.playbackRate = speeds[spIdx];
|
||||
if (playIntent) audio.play().catch(e => { setBuf(false); if (e && e.name === 'NotAllowedError') { playIntent = false; toast('Tap ▶ to start'); } });
|
||||
}, { once: true });
|
||||
// browsers remember a failed address, so a retry asks for a fresh one (the server strips the extra parameter)
|
||||
audio.src = bust && !src.startsWith('blob:') ? src + (src.includes('?') ? '&' : '?') + '_r=' + Date.now() : src;
|
||||
audio.load();
|
||||
loadT = setTimeout(() => { if (audio.readyState < 1) recover('no response from the server'); }, 20000);
|
||||
}
|
||||
// Something went wrong (network error, stall, timeout): reload the same source and carry on from where we were.
|
||||
function recover(why) {
|
||||
clearTimeout(stallT); clearTimeout(loadT);
|
||||
if (!current) return;
|
||||
if (!navigator.onLine && !downloaded.has(bookId(current))) {
|
||||
needOnline = true; setBuf(true); toast('Offline. Playback resumes when the connection is back.'); return;
|
||||
}
|
||||
if (retries >= 4) { setBuf(false); playIntent = false; toast(`Playback failed (${why}). Tap ▶ to try again.`); return; }
|
||||
retries++;
|
||||
const at = audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos || 0;
|
||||
setBuf(true);
|
||||
clearTimeout(recoverT);
|
||||
recoverT = setTimeout(() => startSource(curSrc, at, true), Math.min(500 * 2 ** (retries - 1), 4000));
|
||||
}
|
||||
async function loadChapters(id, off) {
|
||||
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
|
||||
try {
|
||||
@@ -443,18 +504,44 @@ function jump(d) {
|
||||
if (d < 0 && audio.currentTime - chapters[Math.max(chIdx, 0)].start > 4) audio.currentTime = chapters[Math.max(chIdx, 0)].start;
|
||||
else audio.currentTime = chapters[Math.max(0, Math.min(chapters.length - 1, chIdx + d))].start;
|
||||
}
|
||||
const toggle = () => audio.paused ? audio.play() : audio.pause();
|
||||
function userPlay() {
|
||||
if (!current) return;
|
||||
playIntent = true; needOnline = false;
|
||||
if (audio.error || audio.networkState === 3 || audio.readyState === 0) { // errored out earlier: start over from here
|
||||
retries = 0; setBuf(true);
|
||||
return startSource(curSrc, audio.currentTime > 1 ? audio.currentTime : (store.get('prog:' + bookId(current)) || {}).pos, true);
|
||||
}
|
||||
audio.play().catch(e => { setBuf(false); if (e && e.name !== 'AbortError') toast('Could not start playback: ' + e.message); });
|
||||
}
|
||||
function userPause() { playIntent = false; audio.pause(); }
|
||||
const toggle = () => audio.paused ? userPlay() : userPause();
|
||||
for (const id of ['play', 'mplay']) $(id).onclick = e => { e.stopPropagation(); toggle(); };
|
||||
for (const id of ['back', 'mback']) $(id).onclick = e => { e.stopPropagation(); skip(-30); };
|
||||
for (const id of ['fwd', 'mfwd']) $(id).onclick = e => { e.stopPropagation(); skip(30); }; $('pch').onclick = () => jump(-1); $('nch').onclick = () => jump(1);
|
||||
const setSession = st => { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = st; };
|
||||
audio.onplay = () => { ico('play', 'pause'); ico('mplay', 'pause'); };
|
||||
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play'); saveProgress(); };
|
||||
audio.onended = () => { if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
|
||||
audio.onpause = () => { ico('play', 'play'); ico('mplay', 'play');
|
||||
// iOS: play even with the silent switch on, and keep audio going in the background
|
||||
try { if (navigator.audioSession) navigator.audioSession.type = 'playback'; } catch {} setSession('paused'); saveProgress(); };
|
||||
audio.addEventListener('playing', () => {
|
||||
clearTimeout(stallT); clearTimeout(loadT); retries = 0; needOnline = false; setBuf(false);
|
||||
audio.playbackRate = speeds[spIdx]; // some browsers reset the speed on a new source
|
||||
setSession('playing');
|
||||
});
|
||||
audio.addEventListener('waiting', () => { // out of buffered data: if it doesn't recover on its own, reload from here
|
||||
if (!playIntent) return;
|
||||
setBuf(true); clearTimeout(stallT); stallT = setTimeout(() => recover('stalled'), 12000);
|
||||
});
|
||||
audio.addEventListener('canplay', () => { if (audio.paused) setBuf(false); });
|
||||
audio.onended = () => { playIntent = false; setBuf(false); if (current) setProg(bookId(current), { pos: 0, dur: audio.duration, t: Date.now() }); render(); };
|
||||
audio.onerror = () => {
|
||||
const c = audio.error && audio.error.code, why = { 1: 'aborted', 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown';
|
||||
if (!current) return;
|
||||
const c = audio.error && audio.error.code;
|
||||
if (c === 1) return; // aborted: we changed the source ourselves
|
||||
console.error('audio error', c, audio.error && audio.error.message, audio.src);
|
||||
toast(`Playback failed: ${why}${current ? ' · ' + (audio.src.split('.').pop().slice(0, 5)) : ''}`);
|
||||
recover({ 2: 'network error', 3: 'could not decode', 4: 'format or stream not supported' }[c] || 'unknown error');
|
||||
};
|
||||
window.addEventListener('online', () => { pull(); push(); if (needOnline) { needOnline = false; retries = 0; recover('back online'); } });
|
||||
$('seek').oninput = e => { audio.currentTime = e.target.value; };
|
||||
|
||||
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
|
||||
@@ -478,14 +565,14 @@ async function pull() {
|
||||
push(); render();
|
||||
} catch {}
|
||||
}
|
||||
function saveProgress() { if (current && audio.duration) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
|
||||
function saveProgress() { if (current && isFinite(audio.duration) && audio.duration > 0) setProg(bookId(current), { pos: audio.currentTime, dur: audio.duration, t: Date.now() }); }
|
||||
let lastSave = 0, lastPos = 0;
|
||||
audio.ontimeupdate = () => {
|
||||
const d = audio.duration || 0, t = audio.currentTime;
|
||||
const d = isFinite(audio.duration) ? audio.duration : 0, t = audio.currentTime;
|
||||
$('seek').max = d || 1; $('seek').value = t; $('seek').style.setProperty('--p', d ? t / d * 100 + '%' : '0%');
|
||||
$('mbar').style.width = d ? t / d * 100 + '%' : '0';
|
||||
if (d && 'mediaSession' in navigator && navigator.mediaSession.setPositionState && Date.now() - lastPos > 1000) { lastPos = Date.now(); try { navigator.mediaSession.setPositionState({ duration: d, position: Math.min(t, d), playbackRate: audio.playbackRate }); } catch {} }
|
||||
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt((d - t) / audio.playbackRate);
|
||||
$('cur').textContent = fmt(t); $('left').textContent = '-' + fmt(Math.max(0, d - t) / audio.playbackRate);
|
||||
if (Date.now() - lastSave > 5000) { lastSave = Date.now(); saveProgress(); }
|
||||
let i = -1; chapters.forEach((c, k) => { if (t >= c.start) i = k; });
|
||||
if (i !== chIdx) {
|
||||
@@ -504,7 +591,7 @@ $('sleep').onclick = () => {
|
||||
slIdx = (slIdx + 1) % sleeps.length; clearTimeout(slTimer);
|
||||
const m = sleeps[slIdx];
|
||||
$('sleep').textContent = m ? `Sleep: ${m} min` : 'Sleep: off'; $('sleep').classList.toggle('on', !!m);
|
||||
if (m) slTimer = setTimeout(() => { audio.pause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
|
||||
if (m) slTimer = setTimeout(() => { userPause(); slIdx = 0; $('sleep').textContent = 'Sleep: off'; $('sleep').classList.remove('on'); toast('Sleep timer: paused'); }, m * 60000);
|
||||
};
|
||||
|
||||
// full-screen player
|
||||
@@ -520,6 +607,7 @@ document.addEventListener('keydown', e => {
|
||||
else if (e.code === 'Escape') closeFull();
|
||||
});
|
||||
window.addEventListener('beforeunload', () => { saveProgress(); push(true); });
|
||||
window.addEventListener('pagehide', () => { saveProgress(); push(true); }); // iOS fires this more reliably than beforeunload
|
||||
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 (before any click handler runs)
|
||||
|
||||
@@ -9,7 +9,23 @@
|
||||
"background_color": "#0f0f12",
|
||||
"theme_color": "#0f0f12",
|
||||
"icons": [
|
||||
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png"},
|
||||
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png"}
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
python3 server.py passwd NAME change a password
|
||||
python3 server.py deluser NAME remove an account
|
||||
python3 server.py users list accounts
|
||||
python3 server.py check test the OpenAudible connection and whether its files can stream to a phone
|
||||
python3 server.py [serve] run (--port 8080 --upstream http://172.16.16.106:8000)
|
||||
|
||||
Each person signs in with their own account and gets their own listening progress.
|
||||
@@ -24,6 +25,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from http.cookies import SimpleCookie
|
||||
from urllib.parse import unquote
|
||||
@@ -40,8 +42,12 @@ COOKIE = "oa_session"
|
||||
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", "/sw.js": "text/javascript"}
|
||||
PUBLIC = {"/manifest.webmanifest": "application/manifest+json", "/sw.js": "text/javascript"}
|
||||
ICON = re.compile(r"^/icon-[a-z0-9]+\.png$")
|
||||
AUDIO_TYPES = {".mp3": "audio/mpeg", ".m4b": "audio/mp4", ".m4a": "audio/mp4", ".aac": "audio/aac",
|
||||
".ogg": "audio/ogg", ".opus": "audio/ogg", ".flac": "audio/flac", ".wav": "audio/wav"}
|
||||
AUDIO_PATH = re.compile(r"^/api/books/[^/]+/files/([^/]+)$")
|
||||
RANGE = re.compile(r"^bytes=(\d*)-(\d*)$")
|
||||
|
||||
# Only read-only endpoints needed for listening are reachable; admin commands/import are not.
|
||||
ALLOWED = [re.compile(p) for p in (
|
||||
@@ -58,6 +64,10 @@ lock = threading.Lock()
|
||||
|
||||
|
||||
# ---------- tiny JSON stores ----------
|
||||
def log(*a):
|
||||
print(time.strftime("%H:%M:%S"), *a, flush=True)
|
||||
|
||||
|
||||
def read(path, default):
|
||||
try:
|
||||
with open(path) as f:
|
||||
@@ -154,10 +164,64 @@ def cli(argv):
|
||||
print("\n".join(n + (" (admin)" if users[n].get("admin") else "") for n in sorted(users)) or "(no users yet; run: python3 server.py adduser NAME)")
|
||||
|
||||
|
||||
def check(argv):
|
||||
"""Ask OpenAudible what it has and whether it can stream it to a phone."""
|
||||
ap = argparse.ArgumentParser(prog="server.py check")
|
||||
ap.add_argument("--upstream", default=os.environ.get("OPENAUDIBLE_URL", "http://172.16.16.106:8000"))
|
||||
ap.add_argument("--books", type=int, default=3, help="how many books to test")
|
||||
a = ap.parse_args(argv)
|
||||
base = a.upstream.rstrip("/")
|
||||
user, pw = oa_login()
|
||||
auth = {"Authorization": "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()} if user else {}
|
||||
|
||||
def fetch(path, **headers):
|
||||
return urllib.request.urlopen(urllib.request.Request(base + path, headers={**auth, **headers}), timeout=30)
|
||||
|
||||
def step(label, fn):
|
||||
try:
|
||||
return fn()
|
||||
except urllib.error.HTTPError as e:
|
||||
sys.exit(f"{label}: HTTP {e.code} ({'wrong OpenAudible login? run: python3 server.py setup' if e.code == 401 else e.reason})")
|
||||
except Exception as e:
|
||||
sys.exit(f"{label}: {e}")
|
||||
|
||||
print(f"OpenAudible at {base}")
|
||||
books = step("library", lambda: json.load(fetch("/api/books")))
|
||||
books = books if isinstance(books, list) else list(books.values())
|
||||
print(f" {len(books)} books")
|
||||
problems = []
|
||||
for b in books[: a.books]:
|
||||
bid = b.get("id") or b.get("asin") or b.get("ASIN") or b.get("book_id")
|
||||
print(f"\n{b.get('title', bid)} [{bid}]")
|
||||
files = step("file list", lambda: json.load(fetch(f"/admin/api/books/{bid}/files")))
|
||||
names = [f if isinstance(f, str) else (f.get("name") or f.get("filename") or f.get("file") or f.get("path") or "") for f in (files if isinstance(files, list) else files.get("files", []))]
|
||||
names = [n.split("/")[-1] for n in names if n]
|
||||
if not names:
|
||||
print(" no files (not downloaded in OpenAudible?)")
|
||||
problems.append(f"{b.get('title', bid)}: no files")
|
||||
for n in names:
|
||||
ext = os.path.splitext(n)[1].lower()
|
||||
if ext not in AUDIO_TYPES:
|
||||
print(f" {n}: not playable in a browser ({ext}); convert it to MP3/M4B in OpenAudible")
|
||||
continue
|
||||
try:
|
||||
r = fetch(f"/api/books/{bid}/files/{urllib.parse.quote(n)}", Range="bytes=0-1")
|
||||
got = r.headers
|
||||
ranged = r.status == 206 and bool(got.get("Content-Range"))
|
||||
print(f" {n}: HTTP {r.status}, {got.get('Content-Type')}, Content-Range={got.get('Content-Range')}, Accept-Ranges={got.get('Accept-Ranges')}")
|
||||
print(" byte ranges:", "supported" if ranged else "NOT supported by OpenAudible; the player server will emulate them (seeking is slower on big files)")
|
||||
if got.get("Content-Type", "").startswith(("application/octet", "binary/")):
|
||||
print(" content type is generic; the player server will correct it")
|
||||
except Exception as e:
|
||||
print(f" {n}: stream failed: {e}")
|
||||
problems.append(f"{n}: {e}")
|
||||
print("\n" + ("Problems found:\n " + "\n ".join(problems) if problems else "OK: OpenAudible is reachable and has playable files."))
|
||||
|
||||
|
||||
# ---------- login page ----------
|
||||
LOGIN = """<!doctype html><html lang=en><meta charset=utf-8>
|
||||
<meta name=viewport content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><title>Sign in</title>
|
||||
<meta name=theme-color content="#0f0f12"><meta name=apple-mobile-web-app-capable content=yes><link rel=manifest href=/manifest.webmanifest><link rel=apple-touch-icon href=/icon-180.png><link rel=icon href=/icon-32.png><title>Sign in</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;--bg:#f6f6f4;--s:#fff;--fg:#16161a;--m:#74747c;--a:#e8590c;--b:rgba(0,0,0,.1)}
|
||||
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--s:#1a1a1f;--fg:#f2f2f4;--m:#8f8f99;--a:#ff7a2f;--b:rgba(255,255,255,.1)}}
|
||||
@@ -221,9 +285,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.send_body(200, LOGIN, "text/html; charset=utf-8")
|
||||
if path == "/logout":
|
||||
return self.logout()
|
||||
if path in PUBLIC:
|
||||
if path in PUBLIC or (ICON.match(path) and os.path.exists(os.path.join(HERE, path[1:]))):
|
||||
with open(os.path.join(HERE, path[1:]), "rb") as f:
|
||||
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")])
|
||||
return self.send_body(200, f.read(), PUBLIC.get(path, "image/png"), [("Cache-Control", "no-cache" if path in PUBLIC else "public, max-age=86400")])
|
||||
user = self.user()
|
||||
if not user:
|
||||
return self.deny(path)
|
||||
@@ -245,6 +309,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return self.proxy()
|
||||
self.send_error(404)
|
||||
|
||||
def do_HEAD(self):
|
||||
path = self.path.split("?")[0]
|
||||
if not self.user():
|
||||
self.send_response(401)
|
||||
return self.end_headers()
|
||||
if any(p.match(path) for p in ALLOWED):
|
||||
return self.proxy(head=True)
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
n = int(self.headers.get("Content-Length", 0) or 0)
|
||||
@@ -365,34 +438,97 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_header("Location", "/login")
|
||||
self.end_headers()
|
||||
|
||||
def proxy(self):
|
||||
def proxy(self, head=False):
|
||||
path = self.path.split("?")[0]
|
||||
m = AUDIO_PATH.match(path)
|
||||
name = unquote(m.group(1)) if m else ""
|
||||
oa_user, oa_pass = oa_login()
|
||||
req = urllib.request.Request(self.upstream + self.path)
|
||||
parts = urllib.parse.urlsplit(self.path) # drop the player's own cache-busting parameter before forwarding
|
||||
q = [kv for kv in urllib.parse.parse_qsl(parts.query, keep_blank_values=True) if kv[0] != "_r"]
|
||||
up = parts.path + ("?" + urllib.parse.urlencode(q) if q else "")
|
||||
req = urllib.request.Request(self.upstream + up, method="HEAD" if head else "GET")
|
||||
if oa_user:
|
||||
cred = base64.b64encode(f"{oa_user}:{oa_pass}".encode()).decode()
|
||||
req.add_header("Authorization", "Basic " + cred)
|
||||
for h in PASS_HEADERS:
|
||||
if self.headers.get(h):
|
||||
req.add_header(h, self.headers[h])
|
||||
resp = None
|
||||
for attempt in (1, 2): # one quick retry for a flaky connection
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=30)
|
||||
resp = urllib.request.urlopen(req, timeout=60 if m else 30)
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
resp = e
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 2:
|
||||
log("upstream unreachable:", self.path, e)
|
||||
return self.json(502, {"error": f"can't reach OpenAudible: {e}"})
|
||||
time.sleep(0.4)
|
||||
code = getattr(resp, "status", None) or resp.code
|
||||
if code == 401: # the server-side OpenAudible login is wrong; not the listener's fault
|
||||
return self.json(502, {"error": "OpenAudible login rejected; run: python3 server.py setup"})
|
||||
self.send_response(code)
|
||||
for h in RESP_HEADERS:
|
||||
if resp.headers.get(h):
|
||||
self.send_header(h, resp.headers[h])
|
||||
if code >= 400 and m:
|
||||
log("upstream", code, "for", self.command, path, "range:", self.headers.get("Range"))
|
||||
h = resp.headers
|
||||
out = [(k, h[k]) for k in RESP_HEADERS if h.get(k)]
|
||||
skip, length = 0, None
|
||||
if m:
|
||||
# iPhones refuse to play audio unless the server answers byte-range requests.
|
||||
ctype = (h.get("Content-Type") or "").lower()
|
||||
if not ctype or ctype.startswith(("application/octet-stream", "binary/", "text/")):
|
||||
ext = os.path.splitext(name)[1].lower()
|
||||
if ext in AUDIO_TYPES:
|
||||
out = [(k, v) for k, v in out if k != "Content-Type"] + [("Content-Type", AUDIO_TYPES[ext])]
|
||||
if not any(k == "Accept-Ranges" for k, _ in out):
|
||||
out.append(("Accept-Ranges", "bytes"))
|
||||
rng = self.headers.get("Range")
|
||||
cl = h.get("Content-Length")
|
||||
total = int(cl) if cl and cl.isdigit() else None
|
||||
if rng and code == 200 and not h.get("Content-Range") and not head:
|
||||
# OpenAudible ignored the Range header: answer it ourselves by skipping ahead in the stream.
|
||||
mm = RANGE.match(rng.strip())
|
||||
if mm and total:
|
||||
a, b = mm.groups()
|
||||
if a == "" and b != "":
|
||||
start, end = max(0, total - int(b)), total - 1
|
||||
else:
|
||||
start, end = int(a or 0), min(int(b), total - 1) if b else total - 1
|
||||
if start >= total:
|
||||
self.send_response(416)
|
||||
self.send_header("Content-Range", f"bytes */{total}")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return resp.close()
|
||||
code, skip, length = 206, start, end - start + 1
|
||||
out = [(k, v) for k, v in out if k != "Content-Length"] + [
|
||||
("Content-Range", f"bytes {start}-{end}/{total}"), ("Content-Length", str(length))]
|
||||
if start:
|
||||
log("emulating Range for", name, f"(from byte {start}; OpenAudible doesn't support Range)")
|
||||
self.send_response(code)
|
||||
for k, v in out:
|
||||
self.send_header(k, v)
|
||||
self.end_headers()
|
||||
if head:
|
||||
return resp.close()
|
||||
try:
|
||||
while chunk := resp.read(64 * 1024):
|
||||
self.wfile.write(chunk)
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
pass # player seeked or closed the stream
|
||||
while skip > 0:
|
||||
d = resp.read(min(65536, skip))
|
||||
if not d:
|
||||
return
|
||||
skip -= len(d)
|
||||
while length is None or length > 0:
|
||||
d = resp.read(65536 if length is None else min(65536, length))
|
||||
if not d:
|
||||
break
|
||||
self.wfile.write(d)
|
||||
if length is not None:
|
||||
length -= len(d)
|
||||
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError, OSError):
|
||||
pass # player seeked, closed the stream, or the connection dropped
|
||||
finally:
|
||||
resp.close()
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
@@ -437,6 +573,8 @@ if __name__ == "__main__":
|
||||
if args[0] in ("adduser", "passwd", "deluser") and len(args) < 2:
|
||||
sys.exit(f"Usage: python3 server.py {args[0]} NAME")
|
||||
cli(args)
|
||||
elif args and args[0] == "check":
|
||||
check(args[1:])
|
||||
elif args and args[0] in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
else:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 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'];
|
||||
const CACHE = 'shell-v2';
|
||||
const STATIC = ['/manifest.webmanifest', '/icon-180.png', '/icon-192.png', '/icon-512.png', '/icon-32.png'];
|
||||
|
||||
self.addEventListener('install', e => {
|
||||
e.waitUntil(caches.open(CACHE).then(c => Promise.all(['/', ...STATIC].map(u => c.add(u).catch(() => {})))).then(() => self.skipWaiting()));
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Draws the app icons (headphones on an orange gradient). Run from the repo root: python3 tools/make_icons.py"""
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
S = 1024
|
||||
|
||||
|
||||
def gradient():
|
||||
# diagonal orange gradient with a soft highlight, built small and scaled up for smoothness
|
||||
n = 256
|
||||
img = Image.new("RGB", (n, n))
|
||||
px = img.load()
|
||||
for y in range(n):
|
||||
for x in range(n):
|
||||
t = (x + y) / (2 * n)
|
||||
r = 255 + (214 - 255) * t
|
||||
g = 142 + (66 - 142) * t
|
||||
b = 62 + (6 - 62) * t
|
||||
d = ((x - n * .25) ** 2 + (y - n * .2) ** 2) ** .5 / n # highlight at top-left
|
||||
k = max(0, .22 - d * .35)
|
||||
px[x, y] = (min(255, int(r + 255 * k)), min(255, int(g + 255 * k)), min(255, int(b + 255 * k)))
|
||||
return img.resize((S, S), Image.BICUBIC)
|
||||
|
||||
|
||||
def headphones():
|
||||
"""White headphones on a transparent 1024 canvas, centered."""
|
||||
layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(layer)
|
||||
white, pad = (255, 255, 255, 255), (214, 74, 10, 255)
|
||||
cx, cy, ro, w = 512, 530, 330, 76
|
||||
d.arc((cx - ro, cy - ro, cx + ro, cy + ro), 180, 360, fill=white, width=w) # headband
|
||||
for side in (-1, 1):
|
||||
ex = cx + side * (ro - w / 2) # band end
|
||||
d.rounded_rectangle((ex - 80, cy - 4, ex + 80, cy + 290), 78, fill=white) # ear cup
|
||||
ix = ex - side * 8 + (-side) * 0
|
||||
px0, px1 = (ex + 8, ex + 80) if side == -1 else (ex - 80, ex - 8)
|
||||
d.rounded_rectangle((px0, cy + 44, px1, cy + 246), 34, fill=pad) # cushion
|
||||
# content box ~ (130..894, 200..820)
|
||||
return layer
|
||||
|
||||
|
||||
def compose(scale, shadow=True):
|
||||
bg = gradient().convert("RGBA")
|
||||
hp = headphones()
|
||||
if scale != 1:
|
||||
size = int(S * scale)
|
||||
hp = hp.resize((size, size), Image.LANCZOS)
|
||||
full = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
full.paste(hp, ((S - size) // 2, (S - size) // 2 + int(S * (1 - scale) * .05)), hp)
|
||||
hp = full
|
||||
if shadow:
|
||||
sh = Image.new("RGBA", (S, S), (0, 0, 0, 0))
|
||||
sh.putalpha(hp.getchannel("A").filter(ImageFilter.GaussianBlur(22)).point(lambda a: int(a * .35)))
|
||||
offset = Image.new("RGBA", (S, S), (90, 25, 0, 0))
|
||||
offset.putalpha(sh.getchannel("A"))
|
||||
shifted = Image.new("RGBA", (S, S), (0, 0, 0, 0)); shifted.paste(offset, (0, 18))
|
||||
bg = Image.alpha_composite(bg, shifted)
|
||||
return Image.alpha_composite(bg, hp).convert("RGB")
|
||||
|
||||
|
||||
any_icon = compose(1.0) # 'any' and apple-touch: full-bleed, iOS rounds the corners itself
|
||||
maskable = compose(.70) # content kept inside the 80% safe zone so Android can crop to any shape
|
||||
for size, name, src in ((512, "icon-512.png", any_icon), (192, "icon-192.png", any_icon), (180, "icon-180.png", any_icon),
|
||||
(512, "icon-maskable-512.png", maskable), (32, "icon-32.png", any_icon)):
|
||||
src.resize((size, size), Image.LANCZOS).save(name, optimize=True)
|
||||
print("wrote", name)
|
||||