399 lines
26 KiB
HTML
399 lines
26 KiB
HTML
<!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}
|
||
@media(max-width:560px){.logo span{display:none}.hd{padding:calc(12px + env(safe-area-inset-top)) 14px 12px}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="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();
|
||
for (const b of list) { const a = author(b) || 'Unknown author'; if (!groups.has(a)) groups.set(a, []); groups.get(a).push(b); }
|
||
$('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 = () => toast("Playback failed (this browser can't play that format)");
|
||
$('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>
|