diff --git a/public/audiobooks-view.js b/public/audiobooks-view.js
index 9cb46aa..d8d1c96 100644
--- a/public/audiobooks-view.js
+++ b/public/audiobooks-view.js
@@ -63,12 +63,13 @@ async function viewBooks(args = []) {
const v = $('#view');
v.innerHTML = '
';
try {
- const [data] = await Promise.all([abApi('/books'), abLoadProg()]);
+ 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;
- v.innerHTML = `Audiobooks
${esc(e.message)}
`;
- $('#abretry').onclick = () => route(); return;
+ await lvPrime(); if (!location.hash.startsWith('#/audio')) return;
+ v.innerHTML = `Audiobooks
${esc(e.message)}
${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; };
@@ -76,8 +77,10 @@ async function viewBooks(args = []) {
${pct(b) > 1 ? `` : ''}
${esc(abTitle(b))}${esc(abAuthor(b))}`;
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 = `
- ${cont.length ? `
Continue listening
${cont.map(b => card(b, true)).join('')}
` : ''}
+ ${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 = () => {
@@ -88,6 +91,7 @@ async function viewBooks(args = []) {
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) {
diff --git a/public/librivox-view.js b/public/librivox-view.js
index d06ca9c..e0ad782 100644
--- a/public/librivox-view.js
+++ b/public/librivox-view.js
@@ -27,6 +27,12 @@ pl.a.addEventListener('timeupdate', () => { if (Date.now() - lvLast > 8000) { lv
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. */
@@ -42,20 +48,19 @@ function lvPlay(b, at) {
async function viewFreeBooks() {
const v = $('#view'); v.innerHTML = '';
- let shelf = [];
- try { [shelf] = await Promise.all([lvApi('/shelf'), lvLoadProg()]); } catch (e) { v.innerHTML = ``; return; }
+ try { lvShelf = await lvApi('/shelf'); await lvLoadProg(); } catch (e) { v.innerHTML = ``; return; }
if (!location.hash.startsWith('#/audio/free')) return;
const S = { q: '', page: 1, items: [], more: false, busy: false, seq: 0 };
- const card = b => { 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 card = b => lvCard(b);
v.innerHTML = `
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(shelf.map(b => [b.id, b]));
+ const known = new Map(lvShelf.map(b => [b.id, b]));
const draw = () => {
- const mine = shelf.slice().sort((a, b) => ((lvProg['lv:' + b.id] || {}).t || b.added) - ((lvProg['lv:' + a.id] || {}).t || a.added));
+ 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 ? '' : ''}`);
- $('#lvbody').querySelectorAll('.vcard').forEach(a => a.onclick = e => { e.preventDefault(); open(a.dataset.id); });
+ lvBind($('#lvbody'), draw, known);
$('#lvmore').innerHTML = S.busy ? 'Searching…' : S.more ? '' : '';
if ($('#lvmb')) $('#lvmb').onclick = () => run(false);
};
@@ -65,23 +70,24 @@ async function viewFreeBooks() {
catch (e) { if (my === S.seq) $('#lvnote').innerHTML = `${esc(e.message)}
`; }
if (my === S.seq) { S.busy = false; draw(); }
};
- const open = async id => {
- const sum = known.get(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 = () => shelf.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()) shelf.push({ id, title: b.title, author: b.author, secs: b.secs, cover: b.cover, added: Date.now() }); draw(); };
- 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')); shelf = shelf.filter(x => x.id !== id); } else { await lvApi('/shelf', lvOpt('POST', { id })); shelf.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'; draw(); } catch (x) { toast(x.message); } };
- };
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); } };
+ }
diff --git a/public/sw.js b/public/sw.js
index 9b7a47a..9222209 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
-const CACHE = 'hub-v38';
+const CACHE = 'hub-v39';
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];