Files
audiobook-player/index.html
T
nmemmertandClaude Sonnet 5.5 1df566d85d
build / check (push) Successful in 10s
build / image (push) Successful in 27s
Fix mobile header, merge author name variants, clearer playback errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 16:02:39 -04:00

414 lines
27 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">
<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}
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}
.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{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}
.ctl .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></select>
</div></header>
<main>
<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></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="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 => `/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); };
// ---- 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;
}
$('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));
render();
pull();
} catch (e) {
$('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);
d.onclick = () => play(b, true);
return d;
}
function render() {
const q = $('q').value.trim().toLowerCase(), sort = $('sort').value;
let list = books.filter(b => !q || [title(b), author(b), series(b)].join(' ').toLowerCase().includes(q));
const last = b => (store.get('prog:' + bookId(b)) || {}).t || 0;
list.sort(sort === 'author' ? (a, b) => author(a).localeCompare(author(b)) || title(a).localeCompare(title(b))
: sort === 'recent' ? (a, b) => last(b) - last(a)
: (a, b) => title(a).localeCompare(title(b)));
$('count').textContent = books.length ? `${list.length} book${list.length === 1 ? '' : 's'}` : '';
if (sort === 'author') {
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 = '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));
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.');
saveProgress();
current = b;
$('mini').classList.remove('hide');
for (const [i, t] of [['mimg', 'src'], ['bigcov', 'src']]) $(i).src = cover(b);
$('fullbg').style.backgroundImage = `url("${cover(b)}")`;
$('mtitle').textContent = $('ftitle').textContent = title(b);
$('msub').textContent = $('fsub').textContent = author(b);
audio.src = `/api/books/${id}/files/${encodeURIComponent(file)}`;
await 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);
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('previoustrack', () => jump(-1)); ms.setActionHandler('nexttrack', () => jump(1));
ms.setActionHandler('play', () => audio.play()); ms.setActionHandler('pause', () => audio.pause());
}
}
async function loadChapters(id) {
chapters = []; chIdx = -1; $('chlist').replaceChildren(); $('chapBox').classList.add('hide'); $('fchap').textContent = '';
try {
const data = 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); };
$('fwd').onclick = () => 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;
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';
$('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 so later play() calls
// (which happen after async fetches) are still allowed.
let unlocked = false;
const unlock = () => {
if (unlocked) return; unlocked = true;
const keep = audio.src;
if (!keep) {
audio.src = 'data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAIlYAAESsAAACABAAZGF0YQAAAAA=';
audio.play().then(() => audio.pause()).catch(() => {});
}
};
['touchend', 'click'].forEach(ev => document.addEventListener(ev, unlock, { once: true, passive: true }));
fetch('/me').then(r => r.json()).then(m => { if (m.admin) $('adminlink').classList.remove('hide'); $('who').textContent = m.user + ' ⎋'; $('who').onclick = () => { if (confirm('Sign out ' + m.user + '?')) location.href = '/logout'; }; }).catch(() => {});
load();
</script>
</body>
</html>