'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 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 = '

Audiobooks

Loading…

'; 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 = `

Audiobooks

${esc(e.message)}

Free audiobooksSettingsOpen the classic player
${lvShelf.length ? `

My free audiobooks

${lvRecent().map(b => lvCard(b)).join('')}
` : ''}
`; $('#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) => ``; 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 = `

Audiobooks

Free audiobooksClassic player
${cont.length || contFree.length ? `

Continue listening

${[...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('')}
` : ''} ${lvShelf.length ? `

My free audiobooks

${lvRecent().map(b => lvCard(b)).join('')}
` : ''}

Library

`; const draw = () => { const q = $('#abq').value.trim().toLowerCase(), list = abBooks.filter(b => !q || (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(q)) .sort((a, b) => abTitle(a).localeCompare(abTitle(b))); $('#abgrid').innerHTML = list.map(b => card(b)).join('') || `

${abBooks.length ? 'No books match.' : 'No books in the library yet.'}

`; }; draw(); $('#abq').oninput = 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()); } 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(' · '), `

${resumable ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(left) + ' left') : p && p.dur && p.pos >= p.dur - 30 ? 'Finished' : 'Not started'}

${resumable ? '' : ''}
`); 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('#abs')) el.querySelector('#abs').onclick = () => go(0); el.querySelector('#abc').onclick = async () => { const box = el.querySelector('#abch'); box.innerHTML = '

Loading…

'; const ch = await abChapters(id); box.innerHTML = ch.length ? ch.map((c, i) => ``).join('') : '

This book has no chapter list.

'; box.querySelectorAll('button[data-i]').forEach(x => x.onclick = () => go(ch[x.dataset.i].start)); }; }