Files
audiobook-player/index.html
T
nmemmertandClaude Sonnet 5.5 5f88ab891b
build / check (push) Successful in 9s
build / image (push) Successful in 27s
Offline downloads, service worker, iOS audio unlock fix
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 17:02:39 -04:00

544 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<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">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Audiobooks">
<meta name="format-detection" content="telephone=no">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/icon-180.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)}
@media(prefers-color-scheme:dark){:root{--bg:#0f0f12;--surf:#1a1a1f;--surf2:#26262d;--fg:#f2f2f4;--mut:#8f8f99;--acc:#ff7a2f;--acc2:#ffb067;--bd:rgba(255,255,255,.08);--sh:0 8px 30px rgba(0,0,0,.5)}}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none}
html{-webkit-text-size-adjust:100%;overscroll-behavior-y:none}
button,a,.card,input[type=range]{touch-action:manipulation}
button,.card,.ib,.pill{-webkit-user-select:none;user-select:none}
html,body{margin:0}
body{background:var(--bg);color:var(--fg);font:15px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;padding-bottom:calc(100px + env(safe-area-inset-bottom))}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;padding:0}
svg{width:1em;height:1em;fill:currentColor;display:block}
.hide{display:none!important}
/* header */
header{position:sticky;top:0;z-index:5;backdrop-filter:blur(18px) saturate(1.4);background:color-mix(in srgb,var(--bg) 82%,transparent);border-bottom:1px solid var(--bd)}
.hd{max-width:1200px;margin:auto;padding:calc(14px + env(safe-area-inset-top)) 20px 14px;display:flex;gap:12px;align-items:center}
.logo{font-weight:800;font-size:20px;letter-spacing:-.02em;display:flex;align-items:center;gap:8px}
.logo i{width:30px;height:30px;border-radius:9px;background:linear-gradient(135deg,var(--acc),var(--acc2));display:grid;place-items:center;color:#fff;font-size:16px}
.search{flex:1;position:relative;max-width:520px;margin-left:auto}
.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--mut);font-size:16px}
#q{width:100%;font-size:16px;padding:10px 14px 10px 38px;border-radius:12px;border:1px solid var(--bd);background:var(--surf);color:var(--fg);font:inherit;outline:0}
#q:focus{border-color:var(--acc)}
#sort{font-size:16px;padding:10px 12px;border-radius:12px;border:1px solid var(--bd);background:var(--surf);color:var(--fg);font:inherit}
main{max-width:1200px;margin:auto;padding:8px 20px}
h2{font-size:18px;margin:26px 0 12px;letter-spacing:-.01em}
.ah{display:flex;align-items:baseline;gap:10px;margin:28px 0 12px;padding-bottom:8px;border-bottom:1px solid var(--bd);font-size:17px;letter-spacing:-.01em}
.ah small{color:var(--mut);font-weight:500;font-size:13px}
#grid>section:first-child .ah{margin-top:4px}
.count{color:var(--mut);font-weight:500;font-size:14px;margin-left:6px}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:20px 16px}
.shelf{display:flex;gap:16px;overflow-x:auto;padding-bottom:8px;scroll-snap-type:x proximity;scrollbar-width:thin}
.shelf .card{flex:0 0 150px;scroll-snap-align:start}
.card{cursor:pointer;text-align:left;transition:transform .15s}
.card:hover{transform:translateY(-3px)}
.cov{position:relative;border-radius:12px;overflow:hidden;aspect-ratio:1;background:var(--surf2);box-shadow:0 2px 10px rgba(0,0,0,.15)}
.cov img{width:100%;height:100%;object-fit:cover;display:block}
.cov .ph{position:absolute;inset:0;display:grid;place-items:center;padding:12px;text-align:center;font-weight:700;color:#fff;font-size:13px}
.cov .bar{position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(0,0,0,.35)}
.cov .bar i{display:block;height:100%;background:var(--acc2)}
.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)}
#msg button{margin-top:12px;background:var(--acc);color:#fff;padding:10px 18px;border-radius:10px;font-weight:600}
/* mini player */
#mini{position:fixed;left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom));max-width:760px;margin:auto;z-index:8;background:var(--surf);border:1px solid var(--bd);border-radius:18px;box-shadow:var(--sh);padding:10px 12px;display:flex;align-items:center;gap:12px;cursor:pointer;overflow:hidden}
#mini img{width:48px;height:48px;border-radius:10px;object-fit:cover;background:var(--surf2);flex:none}
#mini .meta{flex:1;min-width:0}
#mini b,#mini span{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#mini span{color:var(--mut);font-size:12.5px}
#mini .mbar{position:absolute;left:0;bottom:0;height:3px;background:var(--acc);width:0}
.ib{position:relative;width:44px;height:44px;border-radius:50%;display:grid;place-items:center;font-size:22px;flex:none}
.ib:hover{background:var(--surf2)}
.pp{background:var(--acc)!important;color:#fff;box-shadow:0 4px 14px color-mix(in srgb,var(--acc) 50%,transparent)}
/* full player */
#full{position:fixed;inset:0;z-index:20;background:var(--bg);transform:translateY(100%);transition:transform .35s cubic-bezier(.2,.8,.2,1);overflow:auto;visibility:hidden}
#full.open{transform:none;visibility:visible}
#fullbg{position:fixed;inset:0;z-index:-1;background-size:cover;background-position:center;filter:blur(70px) saturate(1.5);opacity:.35;transform:scale(1.3)}
.fw{max-width:520px;margin:auto;padding:calc(16px + env(safe-area-inset-top)) 24px calc(28px + env(safe-area-inset-bottom));min-height:100%;display:flex;flex-direction:column}
.top{display:flex;justify-content:space-between;align-items:center}
.top span{color:var(--mut);font-size:12px;letter-spacing:.08em;text-transform:uppercase;font-weight:600}
#bigcov{width:100%;max-width:340px;aspect-ratio:1;margin:18px auto;border-radius:20px;object-fit:cover;box-shadow:0 20px 50px rgba(0,0,0,.35);background:var(--surf2)}
#ftitle{font-size:22px;font-weight:800;letter-spacing:-.02em;text-align:center;margin:4px 0}
#fsub{color:var(--mut);text-align:center}
#fchap{color:var(--acc);text-align:center;font-weight:600;font-size:13px;margin-top:6px;min-height:18px}
.seekw{margin-top:20px}
input[type=range]{-webkit-appearance:none;appearance:none;width:100%;height:6px;border-radius:99px;background:linear-gradient(to right,var(--acc) var(--p,0%),var(--surf2) var(--p,0%));outline:0;margin:10px 0}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 6px rgba(0,0,0,.4);border:2px solid var(--acc)}
input[type=range]::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:#fff;border:2px solid var(--acc)}
.times{display:flex;justify-content:space-between;color:var(--mut);font-size:12px;font-variant-numeric:tabular-nums}
.ctl{display:flex;justify-content:center;align-items:center;gap:18px;margin:14px 0}
.ctl .ib{width:56px;height:56px;font-size:28px;position:relative}
.ib small{position:absolute;font-size:10px;font-weight:800;top:50%;left:50%;transform:translate(-50%,-38%)}
.ctl .pp{width:76px;height:76px;font-size:34px}
.opts{display:flex;justify-content:center;gap:10px;margin:6px 0 14px}
.pill{background:var(--surf);border:1px solid var(--bd);border-radius:99px;padding:8px 16px;font-weight:600;font-size:13.5px}
a.pill{text-decoration:none;color:var(--fg);display:inline-block}
.pill.on{border-color:var(--acc);color:var(--acc)}
.chaps{background:var(--surf);border:1px solid var(--bd);border-radius:14px;overflow:hidden}
.chaps h3{margin:0;padding:12px 16px;font-size:13px;color:var(--mut);text-transform:uppercase;letter-spacing:.06em}
.chlist{max-height:240px;overflow:auto}
.chlist button{display:flex;gap:12px;width:100%;padding:10px 16px;text-align:left;border-top:1px solid var(--bd)}
.chlist button:hover{background:var(--surf2)}
.chlist .tm{color:var(--mut);font-variant-numeric:tabular-nums;min-width:52px}
.chlist .on{color:var(--acc);font-weight:700}
#toast{position:fixed;left:50%;bottom:110px;transform:translateX(-50%);background:var(--fg);color:var(--bg);padding:10px 16px;border-radius:99px;font-weight:600;z-index:30;opacity:0;pointer-events:none;transition:opacity .2s}
#toast.on{opacity:1}
.brk{display:none}
@media(max-width:560px){.logo span{display:inline}.logo{margin-right:auto}.hd{flex-wrap:wrap;gap:10px;padding:calc(12px + env(safe-area-inset-top)) 14px 12px}
#adminlink,#who{order:1}.brk{display:block;order:2;flex:0 0 100%;height:0}.search{order:3;margin-left:0;min-width:0}#sort{order:3;max-width:48%}main{padding:4px 14px}.grid{grid-template-columns:repeat(auto-fill,minmax(130px,1fr))}}
</style>
</head>
<body>
<svg style="display:none">
<symbol id="i-play" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></symbol>
<symbol id="i-pause" viewBox="0 0 24 24"><path d="M6 5h4v14H6zm8 0h4v14h-4z"/></symbol>
<symbol id="i-down" viewBox="0 0 24 24"><path d="M7.4 8.6 12 13.2l4.6-4.6L18 10l-6 6-6-6z"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><path d="M15.5 14h-.8l-.3-.3A6.5 6.5 0 1 0 14 15.5l.3.3v.8l5 5 1.5-1.5zm-6 0a4.5 4.5 0 1 1 0-9 4.5 4.5 0 0 1 0 9"/></symbol>
<symbol id="i-back" viewBox="0 0 24 24"><path d="M12 5V1L7 6l5 5V7a6 6 0 1 1-6 6H4a8 8 0 1 0 8-8"/></symbol>
<symbol id="i-fwd" viewBox="0 0 24 24"><path d="M12 5V1l5 5-5 5V7a6 6 0 1 0 6 6h2a8 8 0 1 1-8-8"/></symbol>
<symbol id="i-book" viewBox="0 0 24 24"><path d="M18 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12V2zM9 4h2v5l-1-.75L9 9z"/></symbol>
</svg>
<header><div class="hd">
<div class="logo"><i><svg><use href="#i-book"/></svg></i><span>Audiobooks</span></div>
<div class="brk"></div>
<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><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>
</main>
<div id="mini" class="hide" role="button" aria-label="Open player">
<img id="mimg" alt=""><div class="meta"><b id="mtitle"></b><span id="msub"></span></div>
<button class="ib" id="mback" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg><small>30</small></button>
<button class="ib" id="mfwd" aria-label="Forward 30 seconds"><svg><use href="#i-fwd"/></svg><small>30</small></button>
<button class="ib pp" id="mplay" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button>
<div class="mbar" id="mbar"></div>
</div>
<div id="full" aria-hidden="true">
<div id="fullbg"></div>
<div class="fw">
<div class="top"><button class="ib" id="close" aria-label="Close"><svg><use href="#i-down"/></svg></button><span>Now playing</span><i style="width:44px"></i></div>
<img id="bigcov" alt="">
<div id="ftitle"></div><div id="fsub"></div><div id="fchap"></div>
<div class="seekw"><input id="seek" type="range" min="0" max="1" step="1" value="0" aria-label="Seek">
<div class="times"><span id="cur">0:00</span><span id="left">-0:00</span></div></div>
<div class="ctl">
<button class="ib" id="pch" aria-label="Previous chapter">⏮</button>
<button class="ib" id="back" aria-label="Back 30 seconds"><svg><use href="#i-back"/></svg><small>30</small></button>
<button class="ib pp" id="play" aria-label="Play or pause"><svg><use href="#i-play"/></svg></button>
<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="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>
<div id="toast"></div>
<audio id="audio" preload="metadata"></audio>
<script>
const $ = id => document.getElementById(id);
const audio = $('audio');
let books = [], current = null, chapters = [], chIdx = -1;
// The API schema is read defensively; field names vary between versions.
const pick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
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 => (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(); };
const store = {
get: k => { try { return JSON.parse(localStorage.getItem(k)); } catch { return null; } },
set: (k, v) => { try { localStorage.setItem(k, JSON.stringify(v)); } catch {} }
};
const ico = (id, name) => { $(id).innerHTML = `<svg><use href="#i-${name}"/></svg>`; };
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');
}
}
function card(b) {
const id = bookId(b), pr = store.get('prog:' + id);
const d = document.createElement('button');
d.className = 'card';
const pct = pr && pr.dur ? Math.min(100, pr.pos / pr.dur * 100) : 0;
d.innerHTML = `<div class="cov"><img loading="lazy" alt="" src="${cover(b)}">${pct > 1 ? `<div class="bar"><i style="width:${pct}%"></i></div>` : ''}${current && bookId(current) === id ? '<div class="eq">Playing</div>' : ''}</div><b></b><span></span>`;
const img = d.querySelector('img');
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 => (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' || 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'}` + (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) {
const a = author(b) || 'Unknown author', k = a.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
if (!groups.has(k)) { groups.set(k, []); labels.set(k, a); }
groups.get(k).push(b);
}
for (const [k, bs] of groups) groups.set(k, { label: labels.get(k), bs });
const sorted = [...groups.values()].sort((x, y) => x.label.localeCompare(y.label));
groups.clear(); sorted.forEach(g => groups.set(g.label, g.bs));
$('grid').className = '';
$('grid').replaceChildren(...[...groups].map(([a, bs]) => {
const s = document.createElement('section');
s.innerHTML = '<h3 class="ah"><span></span><small></small></h3><div class="grid"></div>';
s.querySelector('span').textContent = a;
s.querySelector('small').textContent = bs.length + (bs.length === 1 ? ' book' : ' books');
s.querySelector('.grid').replaceChildren(...bs.map(card));
return s;
}));
} else {
$('grid').className = 'grid';
$('grid').replaceChildren(...list.map(card));
}
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);
$('cont').replaceChildren(...cont.map(card));
}
$('q').oninput = render; $('sort').onchange = render;
// ---- playback ----
const PLAYABLE = /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i;
async function findFile(id) {
const data = await get(`/admin/api/books/${id}/files`);
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
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));
}
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));
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>`);
for (const i of ['mimg', 'bigcov']) { $(i).onerror = () => { $(i).onerror = null; $(i).src = fallback; }; $(i).src = cover(b); }
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
$('mtitle').textContent = $('ftitle').textContent = title(b);
$('msub').textContent = $('fsub').textContent = author(b);
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, off);
render();
if (open) openFull();
if ('mediaSession' in navigator) {
const ms = navigator.mediaSession;
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());
}
}
async function loadChapters(id, off) {
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
try {
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');
if (s == null) { const ms = pick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
return { title: pick(c, 'title', 'name') || 'Chapter', start: Number(s) };
}).filter(c => !isNaN(c.start));
} catch {}
if (!chapters.length) return;
$('chapBox').classList.remove('hide');
$('chlist').replaceChildren(...chapters.map((c, i) => {
const b = document.createElement('button');
b.innerHTML = `<span class="tm">${fmt(c.start)}</span><span></span>`; b.lastChild.textContent = c.title;
b.onclick = () => { audio.currentTime = c.start; }; return b;
}));
}
const skip = s => { audio.currentTime = Math.max(0, Math.min(audio.duration || 1e9, audio.currentTime + s)); };
function jump(d) {
if (!chapters.length) return skip(d * 30);
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();
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);
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.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';
console.error('audio error', c, audio.error && audio.error.message, audio.src);
toast(`Playback failed: ${why}${current ? ' · ' + (audio.src.split('.').pop().slice(0, 5)) : ''}`);
};
$('seek').oninput = e => { audio.currentTime = e.target.value; };
// ---- cross-device sync (progress is kept by server.py; newest timestamp wins) ----
let pending = {}, pushTimer;
function setProg(id, v) { store.set('prog:' + id, v); pending[id] = v; clearTimeout(pushTimer); pushTimer = setTimeout(push, 1500); }
async function push(keepalive) {
if (!Object.keys(pending).length) return;
const body = JSON.stringify(pending); pending = {};
try { const r = await fetch('/sync/progress', { method: 'POST', body, keepalive: !!keepalive }); if (!r.ok) throw 0; }
catch { pending = { ...JSON.parse(body), ...pending }; }
}
async function pull() {
try {
const remote = await get('/sync/progress');
for (const [id, v] of Object.entries(remote)) {
const mine = store.get('prog:' + id);
if (!mine || v.t > mine.t) store.set('prog:' + id, v);
else if (mine.t > v.t) pending[id] = mine; // local is newer: send it up
}
if (current) { const r = remote[bookId(current)]; if (r && r.t > lastSave + 1 && audio.paused && Math.abs(r.pos - audio.currentTime) > 5) audio.currentTime = r.pos; }
push(); render();
} catch {}
}
function saveProgress() { if (current && audio.duration) 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;
$('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);
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) {
chIdx = i;
$('fchap').textContent = i >= 0 ? chapters[i].title : '';
[...$('chlist').children].forEach((el, k) => { el.classList.toggle('on', k === i); if (k === i && $('full').classList.contains('open')) el.scrollIntoView({ block: 'nearest' }); });
}
};
// speed + sleep timer
const speeds = [1, 1.25, 1.5, 1.75, 2, 2.5, 3, 0.75]; let spIdx = Math.max(0, speeds.indexOf(store.get('speed') || 1));
const showSpeed = () => { $('speed').textContent = speeds[spIdx] + '×'; audio.playbackRate = speeds[spIdx]; store.set('speed', speeds[spIdx]); };
$('speed').onclick = () => { spIdx = (spIdx + 1) % speeds.length; showSpeed(); }; showSpeed();
const sleeps = [0, 15, 30, 45, 60]; let slIdx = 0, slTimer;
$('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);
};
// full-screen player
const openFull = () => { $('full').classList.add('open'); $('full').setAttribute('aria-hidden', 'false'); history.pushState({ full: 1 }, ''); };
const closeFull = () => { $('full').classList.remove('open'); $('full').setAttribute('aria-hidden', 'true'); };
$('mini').onclick = openFull;
$('close').onclick = () => { closeFull(); if (history.state && history.state.full) history.back(); };
window.onpopstate = closeFull;
document.addEventListener('keydown', e => {
if (['INPUT', 'SELECT'].includes(e.target.tagName) && e.target.type !== 'range') return;
if (e.code === 'Space' && current) { e.preventDefault(); toggle(); }
else if (e.code === 'ArrowLeft') skip(-30); else if (e.code === 'ArrowRight') skip(30);
else if (e.code === 'Escape') closeFull();
});
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 (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;
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, 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>
</html>