build-container / build (push) Successful in 4m6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
136 lines
14 KiB
JavaScript
136 lines
14 KiB
JavaScript
'use strict';
|
|
// ---- Audiobooks: your library from the audiobook player, through the hub (the hub signs in for you). Books play in the same player as Music,
|
|
// and where you got to is kept by the audiobook player, so it follows you between this app, the player's own page and your other devices.
|
|
const abApi = (p, opts) => jfetch('/api/ab' + p, opts);
|
|
const abPick = (o, ...ks) => { for (const k of ks) if (o && o[k] != null && o[k] !== '') return o[k]; };
|
|
const abId = b => String(abPick(b, 'id', 'asin', 'ASIN', 'book_id'));
|
|
const abTitle = b => abPick(b, 'title', 'title_short', 'name') || '(untitled)';
|
|
const abAuthor = b => { const a = abPick(b, 'author', 'author_name', 'authors', 'author_display') || ''; return Array.isArray(a) ? a.join(', ') : a; };
|
|
const abSeries = b => abPick(b, 'series_name', 'series') || '';
|
|
const abNarr = b => { const a = abPick(b, 'narrated_by', 'narrator', 'narrators', 'narrator_name') || ''; return Array.isArray(a) ? a.join(', ') : a; };
|
|
const abSecs = b => { const d = Number(abPick(b, 'seconds', 'duration', 'duration_sec', 'length_sec', 'runtime_seconds')); return d > 0 ? (d > 1e5 ? d / 1000 : d) : 0; }; // (some servers give milliseconds)
|
|
/** When the book was added to the library (ms), from whichever date the server gives; 0 when it gives none. */
|
|
const abAdded = b => { for (const k of ['added', 'added_at', 'date_added', 'created_at', 'created', 'purchase_date', 'mtime']) { const v = b && b[k]; if (v == null || v === '') continue; const t = typeof v === 'number' ? (v < 1e11 ? v * 1000 : v) : Date.parse(v); if (t > 0) return t; } return 0; };
|
|
const abLen = s => { const h = Math.floor(s / 3600), m = Math.round(s % 3600 / 60); return h ? `${h} h${m ? ' ' + m + ' m' : ''}` : `${m} m`; };
|
|
const abCover = b => `/api/ab/books/${encodeURIComponent(abId(b))}/cover`;
|
|
const abFmt = 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 abHue = t => { let h = 0; for (const c of t) h = (h * 31 + c.charCodeAt(0)) % 360; return h; };
|
|
let abBooks = null, abProg = {};
|
|
const abIsPlaying = () => { const t = cur(); return t && String(t.key).startsWith('ab:') ? t.key.slice(3) : ''; };
|
|
|
|
async function abFile(id) {
|
|
const data = await abApi(`/books/${encodeURIComponent(id)}/filelist`);
|
|
const arr = Array.isArray(data) ? data : (data.files || Object.values(data));
|
|
const names = arr.map(f => typeof f === 'string' ? f : abPick(f, 'name', 'filename', 'file', 'path')).filter(Boolean).map(n => n.split('/').pop());
|
|
return names.find(n => /\.mp3$/i.test(n)) || names.find(n => /\.(mp3|m4b|m4a|aac|ogg|opus|flac|wav)$/i.test(n));
|
|
}
|
|
async function abChapters(id) {
|
|
try {
|
|
const data = await abApi(`/books/${encodeURIComponent(id)}/chapters/flat`), arr = Array.isArray(data) ? data : (data.chapters || []);
|
|
return arr.map(c => { let s = abPick(c, 'start', 'start_sec', 'start_seconds', 'startOffsetSec', 'start_offset_sec');
|
|
if (s == null) { const ms = abPick(c, 'start_ms', 'start_offset_ms', 'startOffsetMs', 'offset_ms'); if (ms != null) s = ms / 1000; }
|
|
return { title: abPick(c, 'title', 'name') || 'Chapter', start: Number(s) }; }).filter(c => !isNaN(c.start));
|
|
} catch { return []; }
|
|
}
|
|
|
|
/** Start (or carry on with) a book: from where you got to, unless that was the very end. */
|
|
async function abPlay(b, at) {
|
|
const id = abId(b);
|
|
if (abIsPlaying() === id) { if (at != null) pl.a.currentTime = at; else if (pl.a.paused) pl.a.play().catch(() => {}); return; }
|
|
const file = await abFile(id);
|
|
if (!file) throw new Error('No playable MP3 or M4B for this book. Convert it in OpenAudible first.');
|
|
const pr = abProg[id], start = at != null ? at : pr && pr.pos > 10 && (!pr.dur || pr.pos < pr.dur - 30) ? pr.pos : 0;
|
|
playQueue([{ key: 'ab:' + id, title: abTitle(b), artist: abAuthor(b), album: abSeries(b), thumb: abCover(b), src: `/api/ab/books/${encodeURIComponent(id)}/files/${encodeURIComponent(file)}`, dur: 0, nocache: true }], 0);
|
|
if (start) pl.a.addEventListener('loadedmetadata', () => { if (abIsPlaying() === id) pl.a.currentTime = start; }, { once: true });
|
|
}
|
|
|
|
// Where you got to goes back to the audiobook player every few seconds while listening, and when you pause or leave.
|
|
let abLast = 0, abQueued = {};
|
|
const abPush = keepalive => {
|
|
if (!Object.keys(abQueued).length) return;
|
|
const body = JSON.stringify(abQueued); abQueued = {};
|
|
fetch('/api/ab/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive })
|
|
.then(r => { if (!r.ok) throw 0; }).catch(() => { abQueued = { ...JSON.parse(body), ...abQueued }; });
|
|
};
|
|
const abSave = keepalive => {
|
|
const id = abIsPlaying(), d = pl.a.duration; if (!id || !isFinite(d) || d <= 0) return;
|
|
abProg[id] = abQueued[id] = { pos: pl.a.currentTime, dur: d, t: Date.now() }; abPush(keepalive);
|
|
};
|
|
pl.a.addEventListener('timeupdate', () => { if (Date.now() - abLast > 8000) { abLast = Date.now(); abSave(); } });
|
|
pl.a.addEventListener('pause', () => abSave());
|
|
pl.a.addEventListener('ended', () => abSave());
|
|
addEventListener('pagehide', () => abSave(true));
|
|
|
|
/** Fetch where you got to in each book (from the audiobook player) and fold it into what this page already knows. */
|
|
const abLoadProg = async () => { try { for (const [id, p] of Object.entries(await abApi('/progress') || {})) if (!abProg[id] || p.t > abProg[id].t) abProg[id] = p; } catch {} };
|
|
|
|
async function viewBooks(args = []) {
|
|
const v = $('#view');
|
|
v.innerHTML = '<div class="wrap"><h1>Audiobooks</h1><p class="empty loading" role="status">Loading…</p></div>';
|
|
try {
|
|
const [data] = await Promise.all([abApi('/books'), abLoadProg(), lvPrime()]);
|
|
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
|
|
} catch (e) {
|
|
if (!location.hash.startsWith('#/audio')) return;
|
|
await lvPrime(); if (!location.hash.startsWith('#/audio')) return;
|
|
v.innerHTML = `<div class="wrap"><h1>Audiobooks</h1><p class="err">${esc(e.message)}</p><div class="bar"><button class="btn ghost" id="abretry">Try again</button><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div>${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}</div>`;
|
|
$('#abretry').onclick = () => route(); lvBind(v, () => route()); return;
|
|
}
|
|
if (!location.hash.startsWith('#/audio')) return;
|
|
const pct = b => { const p = abProg[abId(b)]; return p && p.dur ? Math.min(100, p.pos / p.dur * 100) : 0; };
|
|
const card = (b, wide) => `<button class="vcard${wide ? ' wide' : ''}" data-id="${esc(abId(b))}" style="text-align:left;background:none;border:0;padding:0;color:inherit;font:inherit;cursor:pointer;${wide ? 'width:140px;flex:none' : ''}">
|
|
<span class="vimg" style="aspect-ratio:1/1;background:linear-gradient(135deg,hsl(${abHue(abTitle(b))} 55% 40%),hsl(${abHue(abTitle(b)) + 40} 55% 28%))"><img loading="lazy" alt="" src="${abCover(b)}" onerror="this.remove()">${pct(b) > 1 ? `<i class="vprog"><b style="width:${pct(b)}%"></b></i>` : ''}${isNew(b) ? '<em class="abnew">New</em>' : ''}</span>
|
|
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small>${!wide && abSecs(b) ? `<small>${esc(abLen(abSecs(b)))}${abSeries(b) ? ' · ' + esc(abSeries(b)) : ''}</small>` : ''}</button>`;
|
|
const WEEKS = 3 * 7 * 864e5, isNew = b => { const t = abAdded(b); return t && Date.now() - t < WEEKS && pct(b) < 1; };
|
|
const dated = abBooks.some(abAdded), newest = [...abBooks].sort(dated ? (a, b) => abAdded(b) - abAdded(a) : () => 0).slice(0, 12); // (no dates: the server's own order, newest last)
|
|
if (!dated) newest.splice(0, newest.length, ...abBooks.slice(-12).reverse());
|
|
const stat = b => { const p = abProg[abId(b)]; return !p || !p.dur || p.pos <= 10 ? 'new' : p.pos >= p.dur - 30 ? 'done' : 'going'; };
|
|
const view = (() => { try { return JSON.parse(localStorage.getItem('ab_view')) || {}; } catch { return {}; } })();
|
|
let sort = view.sort || 'title', filt = view.filt || 'all';
|
|
const cont = abBooks.filter(b => { const p = abProg[abId(b)]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30; }).sort((a, b) => abProg[abId(b)].t - abProg[abId(a)].t).slice(0, 10);
|
|
const contFree = lvShelf.filter(b => lvLeft(b.id)).sort((a, b) => lvProg['lv:' + b.id].t - lvProg['lv:' + a.id].t).slice(0, 10);
|
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/free">Free audiobooks</a><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
|
|
${cont.length || contFree.length ? `<h2>Continue listening</h2><div class="vrow">${[...cont.map(b => ({ t: abProg[abId(b)].t, h: card(b, true) })), ...contFree.map(b => ({ t: lvProg['lv:' + b.id].t, h: lvCard(b, true) }))].sort((a, b) => b.t - a.t).map(x => x.h).join('')}</div>` : ''}
|
|
${newest.length > 1 ? `<h2>Newly added</h2><div class="vrow">${newest.map(b => card(b, true)).join('')}</div>` : ''}
|
|
${lvShelf.length ? `<h2>My free audiobooks</h2><div class="vgrid">${lvRecent().map(b => lvCard(b)).join('')}</div>` : ''}
|
|
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
|
|
<h2>Library <small id="abn" style="color:var(--muted);font-weight:400"></small></h2>
|
|
<div class="bar" style="flex-wrap:wrap;margin-top:6px">${[['all', 'All'], ['going', 'In progress'], ['new', 'Not started'], ['done', 'Finished']].map(([k, l]) => `<button class="btn ghost abf" data-f="${k}">${l}</button>`).join('')}
|
|
<span class="pspace grow"></span><label class="sub" style="display:flex;gap:6px;align-items:center">Sort <select id="abs0"><option value="title">Title</option><option value="author">Author</option><option value="added">Recently added</option><option value="series">Series</option><option value="length">Longest</option></select></label></div>
|
|
<div class="vgrid" id="abgrid"></div></div>`;
|
|
const draw = () => {
|
|
const q = $('#abq').value.trim().toLowerCase(), by = { title: (a, b) => abTitle(a).localeCompare(abTitle(b)), author: (a, b) => abAuthor(a).localeCompare(abAuthor(b)) || abTitle(a).localeCompare(abTitle(b)),
|
|
added: (a, b) => abAdded(b) - abAdded(a) || abTitle(a).localeCompare(abTitle(b)), length: (a, b) => abSecs(b) - abSecs(a),
|
|
series: (a, b) => (abSeries(a) || '\uffff').localeCompare(abSeries(b) || '\uffff') || (Number(abPick(a, 'series_sequence', 'series_index')) || 0) - (Number(abPick(b, 'series_sequence', 'series_index')) || 0) || abTitle(a).localeCompare(abTitle(b)) }[sort],
|
|
list = abBooks.filter(b => (filt === 'all' || stat(b) === filt) && (!q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b) + ' ' + abNarr(b)).toLowerCase().includes(q))).sort(by);
|
|
$('#abgrid').innerHTML = list.map(b => card(b)).join('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
|
|
$('#abn').textContent = list.length === abBooks.length ? `${abBooks.length} books` : `${list.length} of ${abBooks.length}`;
|
|
v.querySelectorAll('.abf').forEach(x => x.classList.toggle('on', x.dataset.f === filt));
|
|
$('#abs0').value = sort;
|
|
try { localStorage.setItem('ab_view', JSON.stringify({ sort, filt })); } catch {}
|
|
};
|
|
draw(); $('#abq').oninput = draw; $('#abs0').onchange = e => { sort = e.target.value; draw(); };
|
|
v.querySelectorAll('.abf').forEach(x => x.onclick = () => { filt = x.dataset.f; draw(); });
|
|
const byId = new Map(abBooks.map(b => [abId(b), b]));
|
|
v.onclick = e => { const c = e.target.closest('button[data-id]'); if (c) abSheet(byId.get(c.dataset.id)); };
|
|
lvBind(v, () => route());
|
|
if (args[0] === 'book' && args[1]) { const bid = args[1], b0 = /^lv:/.test(bid) ? null : byId.get(bid); if (/^lv:/.test(bid)) lvSheet(bid.slice(3)); else if (b0) abSheet(b0); else toast('That book is not in your library'); } // (a link from a Chat share card)
|
|
}
|
|
|
|
function abSheet(b) {
|
|
const id = abId(b), p = abProg[id], left = p && p.dur ? p.dur - p.pos : 0, resumable = p && p.pos > 10 && p.dur && p.pos < p.dur - 30;
|
|
const el = openSheet(abTitle(b), [abAuthor(b), abSeries(b)].filter(Boolean).join(' · '), `<div style="display:flex;gap:14px;align-items:flex-start"><span class="vimg" style="width:96px;flex:none;aspect-ratio:1/1"><img alt="" src="${abCover(b)}" onerror="this.remove()"></span>
|
|
<div style="min-width:0">${abNarr(b) ? `<p class="sub" style="margin:0 0 4px">Read by ${esc(abNarr(b))}</p>` : ''}${abSecs(b) ? `<p class="sub" style="margin:0 0 4px">${esc(abLen(abSecs(b)))}${abAdded(b) ? ' · added ' + new Date(abAdded(b)).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) : ''}</p>` : ''}<p class="sub" style="margin:0">${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}</p></div></div>
|
|
<div class="bar"><button class="btn" id="abp">${icon('play')}${resumable ? 'Resume' : 'Play'}</button>${resumable ? '<button class="btn ghost" id="abs">From the start</button>' : ''}<button class="btn ghost" id="abc">Chapters</button>${allowed('chat') ? `<button class="btn ghost" id="abh">${icon('send')}Share</button>` : ''}</div><div class="err" id="abe"></div><div id="abch" class="list" style="max-height:40vh;overflow:auto"></div>`);
|
|
const go = async at => { try { await abPlay(b, at); closeSheet(); } catch (e) { el.querySelector('#abe').textContent = e.message; } };
|
|
el.querySelector('#abp').onclick = () => go();
|
|
if (el.querySelector('#abh')) el.querySelector('#abh').onclick = () => { closeSheet(); shareMedia({ kind: 'audiobook', title: abTitle(b), sub: abAuthor(b), art: abCover(b), link: '#/audio/book/' + encodeURIComponent(id) }); };
|
|
if (el.querySelector('#abs')) el.querySelector('#abs').onclick = () => go(0);
|
|
el.querySelector('#abc').onclick = async () => {
|
|
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty loading" role="status">Loading…</p>';
|
|
const ch = await abChapters(id);
|
|
box.innerHTML = ch.length ? ch.map((c, i) => `<button class="row" data-i="${i}"><span class="grow"><b>${esc(c.title)}</b><small>${abFmt(c.start)}</small></span></button>`).join('') : '<p class="empty">This book has no chapter list.</p>';
|
|
box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start));
|
|
};
|
|
}
|