New headphones app icon set, hardened streaming and playback, download removal controls
build / check (push) Successful in 10s
build / image (push) Successful in 31s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-19 14:11:31 -04:00
1 parent 5f88ab891b
commit 429a72f2c3
13 files changed
+375 -46

No files matched your search

+109 -21
View File
@@ -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)