'use strict'; // ---- Free audiobooks (LibriVox): search public-domain books read by volunteers, keep a shelf, and listen in the same player as Music. Where you got to in each // book is kept on the server (per person), so it follows you between devices. const lvApi = (p, opts) => jfetch('/api/ab/free' + p, opts); const lvOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); const lvDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.floor(s / 3600), m = Math.floor(s % 3600 / 60); return h ? h + ' h ' + m + ' min' : Math.max(1, m) + ' min'; }; const lvImg = (b, sq) => ``; let lvProg = {}, lvQueued = {}, lvLast = 0, lvBook = null, lvLoading = ''; const lvPlaying = () => { const t = cur(), m = t && /^lv:(\d+):(\d+)$/.exec(String(t.key)); return m ? { id: m[1], n: +m[2] } : null; }; const lvOffsets = b => { let t = 0; return b.chapters.map(c => { const o = t; t += c.secs; return o; }); }; const lvTotal = b => b.chapters.reduce((a, c) => a + c.secs, 0) || b.secs; const lvLoadProg = async () => { try { for (const [id, p] of Object.entries(await lvApi('/progress') || {})) if (!lvProg[id] || p.t > lvProg[id].t) lvProg[id] = p; } catch {} }; const lvPush = keepalive => { if (!Object.keys(lvQueued).length) return; const body = JSON.stringify(lvQueued); lvQueued = {}; fetch('/api/ab/free/progress', { method: 'POST', headers: { 'content-type': 'application/json' }, body, keepalive: !!keepalive }).then(r => { if (!r.ok) throw 0; }).catch(() => { lvQueued = { ...JSON.parse(body), ...lvQueued }; }); }; const lvSave = keepalive => { const now = lvPlaying(), d = pl.a.duration; if (!now || !isFinite(d) || d <= 0) return; if (!lvBook || lvBook.id !== now.id) { // the page was reloaded while a book was playing: fetch its chapter list once, then carry on saving if (lvLoading !== now.id) { lvLoading = now.id; lvApi('/book/' + now.id).then(b => { lvBook = { id: b.id, offsets: lvOffsets(b), total: lvTotal(b) }; }).catch(() => {}).finally(() => { lvLoading = ''; }); } return; } const key = 'lv:' + now.id; lvProg[key] = lvQueued[key] = { pos: (lvBook.offsets[now.n] || 0) + pl.a.currentTime, dur: lvBook.total, t: Date.now() }; lvPush(keepalive); }; pl.a.addEventListener('timeupdate', () => { if (Date.now() - lvLast > 8000) { lvLast = Date.now(); lvSave(); } }); pl.a.addEventListener('pause', () => lvSave()); pl.a.addEventListener('ended', () => lvSave()); addEventListener('pagehide', () => lvSave(true)); let lvShelf = []; /** Load the saved free books and where you got to in each (for this page and for the main Audiobooks list). Never throws. */ const lvPrime = async () => { try { [lvShelf] = await Promise.all([lvApi('/shelf'), lvLoadProg()]); } catch { lvShelf = []; } return lvShelf; }; const lvCard = (b, wide) => { const p = lvLeft(b.id), pct = p ? Math.min(100, p.pos / p.dur * 100) : 0; return `${lvImg(b).replace('', pct > 1 ? `` : '')}${esc(b.title)}${esc(b.author || '')}${b.secs ? (b.author ? ' · ' : '') + esc(lvDur(b.secs)) : ''}`; }; const lvBind = (root, after, known) => root.querySelectorAll('[data-lv]').forEach(a => a.onclick = e => { e.preventDefault(); lvSheet(a.dataset.lv, after, known); }); const lvRecent = () => lvShelf.slice().sort((a, b) => ((lvProg['lv:' + b.id] || {}).t || b.added) - ((lvProg['lv:' + a.id] || {}).t || a.added)); const lvLeft = id => { const p = lvProg['lv:' + id]; return p && p.pos > 10 && p.dur && p.pos < p.dur - 30 ? p : null; }; /** Play a book from `at` seconds into it (default: where you left off), as one queue of chapters so the next chapter follows by itself. */ function lvPlay(b, at) { const offs = lvOffsets(b), p = lvLeft(b.id), pos = at != null ? at : p ? p.pos : 0; let n = 0; for (let i = 0; i < offs.length; i++) if (offs[i] <= pos) n = i; const now = lvPlaying(); lvBook = { id: b.id, offsets: offs, total: lvTotal(b) }; if (now && now.id === b.id && now.n === n && at == null) { if (pl.a.paused) pl.a.play().catch(() => {}); return; } playQueue(b.chapters.map(c => ({ key: `lv:${b.id}:${c.n}`, title: c.title, artist: b.author, album: b.title, thumb: b.cover, src: `/api/ab/free/audio/${b.id}/${c.n}`, dur: c.secs * 1000, nocache: true })), n); const within = Math.max(0, pos - offs[n]); if (within > 1) pl.a.addEventListener('loadedmetadata', () => { const t = lvPlaying(); if (t && t.id === b.id && t.n === n) pl.a.currentTime = within; }, { once: true }); lvApi('/shelf', lvOpt('POST', { id: b.id })).catch(() => {}); // a book you start is kept on your shelf } async function viewFreeBooks() { const v = $('#view'); v.innerHTML = '

Free audiobooks

Loading…

'; try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = `
${icon('back')}Audiobooks

${esc(e.message)}

`; return; } if (!location.hash.startsWith('#/audio/free')) return; const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 }; const card = b => lvCard(b); v.innerHTML = `
${icon('back')}Audiobooks

Free audiobooks

Public-domain books read aloud by volunteers (LibriVox). Free, with no sign-in. A book you start is kept on your shelf.

`; const known = new Map(lvShelf.map(b => [b.id, b])); const draw = () => { const mine = lvRecent(); $('#lvbody').innerHTML = (!S.q && mine.length ? `

My free audiobooks

${mine.map(card).join('')}
` : '') + (S.q ? (S.items.length ? `

Results

${S.items.map(card).join('')}
` : S.busy ? '' : '

Nothing found.

') : `

${S.items.length ? 'Browse' : ''}

${S.items.length ? `
${S.items.map(card).join('')}
` : S.busy ? '' : ''}`); lvBind($('#lvbody'), draw, known); $('#lvmore').innerHTML = S.busy ? 'Searching…' : S.more ? '' : ''; if ($('#lvmb')) $('#lvmb').onclick = () => run(false); }; const run = async reset => { const my = ++S.seq; if (reset) { S.page = 1; S.items = []; } else S.page++; S.busy = true; $('#lvnote').innerHTML = ''; draw(); try { const r = await lvApi(`/search?q=${encodeURIComponent(S.q)}&page=${S.page}`); if (my !== S.seq) return; S.items = S.items.concat(r.items); S.more = r.more; r.items.forEach(b => known.set(b.id, known.get(b.id) || b)); } catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `

${esc(e.message)}

`; } if (my === S.seq) { S.busy = false; draw(); } }; let t; $('#lvq').oninput = e => { clearTimeout(t); t = setTimeout(() => { S.q = e.target.value.trim(); run(true); }, 450); }; $('#lvq').onkeydown = e => { if (e.key === 'Enter') { clearTimeout(t); S.q = e.target.value.trim(); run(true); } }; draw(); run(true); } async function lvSheet(id, after, known = new Map()) { const sum = known.get(id) || lvShelf.find(x => x.id === id) || { id, title: 'Audiobook', author: '' }; const el = openSheet(sum.title, sum.author || '', '

Loading…

'); let b; try { b = await lvApi('/book/' + id); } catch (e) { el.querySelector('.empty').outerHTML = `

${esc(e.message)}

`; return; } if (!document.body.contains(el)) return; const p = lvLeft(id), total = lvTotal(b), onShelf = () => lvShelf.some(x => x.id === id); el.querySelector('.empty').outerHTML = `

${esc([b.language, lvDur(total), b.chapters.length + ' chapters'].filter(Boolean).join(' · '))}

${p ? esc(abFmt(p.pos) + ' of ' + abFmt(p.dur) + ' · ' + abFmt(p.dur - p.pos) + ' left') : 'Not started'}

${b.description ? `

${esc(b.description)}

` : ''}
${p ? '' : ''}
`; const go = at => { if (!b.chapters.length) return toast('This book has no audio'); lvPlay(b, at); closeSheet(); if (!onShelf()) lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); if (after) after(); }; el.querySelector('#lvp').onclick = () => go(); if (el.querySelector('#lvs')) el.querySelector('#lvs').onclick = () => go(0); el.querySelector('#lvc').onclick = () => { const offs = lvOffsets(b); el.querySelector('#lvch').innerHTML = b.chapters.map((c, i) => ``).join(''); el.querySelectorAll('#lvch [data-i]').forEach(x => x.onclick = () => go(offs[x.dataset.i])); }; el.querySelector('#lvk').onclick = async e => { const btn = e.currentTarget; try { if (onShelf()) { await lvApi('/shelf/' + id, lvOpt('DELETE')); lvShelf = lvShelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); lvShelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); } btn.textContent = onShelf() ? 'Remove from shelf' : 'Save to shelf'; if (after) after(); } catch (x) { toast(x.message); } }; }