From 27df8031caa92e2bc14a087bf416e5810d022ac3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 01:28:49 +0000 Subject: [PATCH] Audiobooks: free audiobooks you saved or started now show in the main Audiobooks list (and under Continue listening), even when the audiobook library cannot be reached Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj --- public/audiobooks-view.js | 12 ++++++---- public/librivox-view.js | 50 ++++++++++++++++++++++----------------- public/sw.js | 2 +- 3 files changed, 37 insertions(+), 27 deletions(-) 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 = '

Audiobooks

Loading…

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

Free audiobooks

Loading…

'; - let shelf = []; - try { [shelf] = await Promise.all([lvApi('/shelf'), lvLoadProg()]); } catch (e) { v.innerHTML = `
${icon('back')}Audiobooks

${esc(e.message)}

`; return; } + 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 => { 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'];