Audiobooks: built-in library, playback and synced progress through the audiobook player (classic frame kept)
build-container / build (push) Canceled after 2m57s

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-22 14:48:05 -04:00
1 parent 66a376d22c
commit 62ff4d815d
14 files changed
+255 -15

No files matched your search

+1 -1
View File
@@ -195,7 +195,7 @@ function openMore() {
el.querySelectorAll('a.row').forEach(a => a.addEventListener('click', closeSheet));
}
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: viewAudio, verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
const routesOf = () => ({ video: viewVideo, planner: viewPlanner, home: viewHome, mail: viewMail, files: viewFiles, office: viewOffice, audio: args => args[0] === 'classic' ? viewAudio() : viewBooks(args), verse: () => location.replace('#/links'), podcast: args => viewPodcast(args), music: viewMusic, tasks: viewTasks, search: viewSearch, calendar: viewCalendar, signal: viewSignal, links: viewLinks, settings: viewSettings, edit: viewEdit });
let editor;
// Menu on a tablet or computer: collapse it to icons (wide screens), or hide it completely. Remembered on this device.
const navPref = () => { try { return JSON.parse(localStorage.getItem('nav_pref')) || {}; } catch { return {}; } };
+107
View File
@@ -0,0 +1,107 @@
'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 = '<div class="wrap"><h1>Audiobooks</h1><p class="empty">Loading…</p></div>';
try {
const [data] = await Promise.all([abApi('/books'), abLoadProg()]);
abBooks = Array.isArray(data) ? data : (data.books || Object.values(data));
} catch (e) {
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="#/settings">Settings</a><a class="btn ghost" href="#/audio/classic">Open the classic player</a></div></div>`;
$('#abretry').onclick = () => 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>` : ''}</span>
<b>${esc(abTitle(b))}</b><small>${esc(abAuthor(b))}</small></button>`;
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);
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Audiobooks</h1><span class="pspace grow"></span><a class="btn ghost" href="#/audio/classic" title="The audiobook player's own page">Classic player</a></div>
${cont.length ? `<h2>Continue listening</h2><div class="vrow">${cont.map(b => card(b, true)).join('')}</div>` : ''}
<input type="search" id="abq" placeholder="Search your books" autocomplete="off" autocapitalize="off" style="margin-top:14px">
<h2>Library</h2><div class="vgrid" id="abgrid"></div></div>`;
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('') || `<p class="empty">${abBooks.length ? 'No books match.' : 'No books in the library yet.'}</p>`;
};
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)); };
}
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"><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></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('#abs')) el.querySelector('#abs').onclick = () => go(0);
el.querySelector('#abc').onclick = async () => {
const box = el.querySelector('#abch'); box.innerHTML = '<p class="empty">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));
};
}
+1
View File
@@ -70,6 +70,7 @@
<script src="/video.js"></script>
<script src="/office.js"></script>
<script src="/player.js"></script>
<script src="/audiobooks-view.js"></script>
<script src="/lyrics-view.js"></script>
<script src="/settings.js"></script>
<script src="/files.js"></script>
+1 -1
View File
@@ -13,7 +13,7 @@ const px = {
if (!r.ok) throw new Error('Plex error ' + r.status);
return (await r.json()).MediaContainer || {};
},
art: (thumb, w = 300) => thumb && thumb.startsWith('/api/podcast/') ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
art: (thumb, w = 300) => thumb && /^\/api\/(podcast|ab)\//.test(thumb) ? thumb : thumb ? `/plex/photo/:/transcode?width=${w}&height=${w}&minSize=1&upscale=1&url=${encodeURIComponent(thumb)}` : 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==',
// All music libraries on the server; the chosen one is remembered per server.
async sections() {
if (px.secs) return px.secs;
+1 -1
View File
@@ -22,7 +22,7 @@ const altSrc = t => '/plex/music/:/transcode/universal/start.mp3?' + new URLSear
// so changing songs is instant and a slow or lost connection does not stop the music.
const sleep = ms => new Promise(ok => setTimeout(ok, ms));
const tc = { m: new Map(), ctl: null, busy: false };
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key)).map(t => t.src);
const tcWant = () => pl.q.slice(Math.max(pl.i, 0), pl.i + 3).filter(t => !pl.alt.has(t.key) && !t.nocache).map(t => t.src); // (long audio, such as audiobooks, is streamed, not saved whole)
function tcTrim() {
const keep = new Set(pl.q.slice(Math.max(pl.i - 1, 0), pl.i + 3).map(t => t.src));
for (const [src, v] of tc.m) if (!keep.has(src)) { URL.revokeObjectURL(v.url); tc.m.delete(src); }
+1 -1
View File
@@ -10,7 +10,7 @@ const pdDur = s => { s = Math.round(+s || 0); if (!s) return ''; const h = Math.
const pdDate = d => { const t = Date.parse(d); return t > 0 ? new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' }) : ''; };
const pdTime = d => Math.max(0, Date.parse(d) || 0);
const pdPlain = h => { const d = document.createElement('div'); d.innerHTML = String(h || ''); return (d.textContent || '').trim(); };
const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', dur: (+pdF(e, 'duration') || 0) * 1000 });
const pdTrack = (e, show) => ({ key: 'pod:' + pdId(e), title: pdF(e, 'title') || 'Episode', artist: pdF(show, 'title') || '', album: '', thumb: pdImg('podcasts', show), src: '/api/podcast/podcastitems/' + encodeURIComponent(pdId(e)) + '/file', nocache: true, dur: (+pdF(e, 'duration') || 0) * 1000 });
async function viewPodcast(args = []) {
const v = $('#view');
+15 -5
View File
@@ -38,8 +38,14 @@ async function searchPodcasts(term) {
return (Array.isArray(list) ? list : []).filter(s => ((pdF(s, 'title') || '') + ' ' + (pdF(s, 'author') || '')).toLowerCase().includes(lower)).slice(0, 12);
}
// The audiobook library, matched on title, author and series.
async function searchBooks(term) {
const [data] = await Promise.all([jfetch('/api/ab/books'), abLoadProg()]), all = Array.isArray(data) ? data : (data.books || Object.values(data)), lower = term.toLowerCase();
return all.filter(b => (abTitle(b) + ' ' + abAuthor(b) + ' ' + abSeries(b)).toLowerCase().includes(lower)).slice(0, 10);
}
function viewSearch() {
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music, movies & TV, podcasts…" autocomplete="off" autocapitalize="off">
$('#view').innerHTML = `<div class="wrap"><h1>Search</h1><input type="search" id="q" placeholder="Tasks, events, files, music, movies & TV, podcasts, audiobooks…" autocomplete="off" autocapitalize="off">
<p class="sub" id="src" style="margin:8px 0 0"></p>
<div class="bar" id="web"><a class="btn ghost" id="duckai" href="https://duck.ai/" target="_blank" rel="noopener">${icon('search')}Ask Duck.ai</a>
<a class="btn ghost" id="ddg" href="https://duckduckgo.com/" target="_blank" rel="noopener">${icon('search')}Search the web</a></div>
@@ -54,7 +60,7 @@ function viewSearch() {
const part = (st, off, label) => st === undefined ? off : st.err ? bad('error: ' + st.err) : `✓${st.n !== undefined ? ' ' + st.n : ''}`;
const filesOff = !sf.token() ? link('#/files', 'sign in to search files') : '✓';
const musicOff = !plexSt || !plexSt.signedIn ? link('#/music', 'sign in to search music') : !plexSt.server ? link('#/music', 'choose a Plex server') : '✓';
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')}`;
el.innerHTML = `Searching: Tasks ${part(r.tasks, '✓')} · Events ${part(r.events, '✓')} · Files ${part(r.files, filesOff)} · Music ${part(r.music, musicOff)} · Movies & TV ${part(r.video, musicOff)} · Podcasts ${part(r.pods, '✓')} · Audiobooks ${part(r.books, '✓')}`;
};
jfetch('/api/plex/status').then(st => { plexSt = st; showSources(); }).catch(() => showSources());
showSources();
@@ -68,12 +74,13 @@ function viewSearch() {
const enc = encodeURIComponent, lower = term.toLowerCase();
const hasSf = !!sf.token(), plexReady = !!(plexSt && plexSt.signedIn && plexSt.server);
const libsP = hasSf ? sf.req('/api2/repos/') : Promise.reject(new Error('signed out'));
const [libs, files, music, video, pods, ddg, hub] = await Promise.allSettled([
const [libs, files, music, video, pods, books, ddg, hub] = await Promise.allSettled([
libsP,
libsP.then(l => searchFiles(term, l)),
plexReady ? searchMusic(term) : Promise.reject(new Error('not connected')),
plexReady ? searchVideo(term) : Promise.reject(new Error('not connected')),
allowed('podcast') ? searchPodcasts(term) : Promise.reject(new Error('not allowed')),
allowed('audio') ? searchBooks(term) : Promise.reject(new Error('not allowed')),
jfetch('/api/ddg?q=' + enc(term)),
jfetch('/api/search?q=' + enc(term)),
]);
@@ -95,6 +102,7 @@ function viewSearch() {
}
const videoHtml = (video.status === 'fulfilled' ? video.value : []).map(m => vdCard(m)).join('');
const podHtml = (pods.status === 'fulfilled' ? pods.value : []).map(s => `<a class="row" href="#/podcast/show/${enc(pdId(s))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${pdImg('podcasts', s)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(pdF(s, 'title'))}</b><small>Podcast${pdF(s, 'author') ? ' · ' + esc(pdF(s, 'author')) : ''}</small></span></a>`).join('');
const bookHtml = (books.status === 'fulfilled' ? books.value : []).map(b => `<a class="row" href="#/audio" data-book="${esc(abId(b))}"><img class="art" style="width:40px;height:40px;border-radius:8px" alt="" src="${abCover(b)}" onerror="this.style.visibility='hidden'"><span class="grow"><b>${esc(abTitle(b))}</b><small>Audiobook${abAuthor(b) ? ' · ' + esc(abAuthor(b)) : ''}</small></span></a>`).join('');
const d = ddg.status === 'fulfilled' ? ddg.value : {};
const card = (d.text || d.answer) ? `<div class="card2" style="padding:14px;margin-top:14px;display:flex;gap:12px;align-items:flex-start">
${d.image ? `<img alt="" src="${esc(d.image)}" referrerpolicy="no-referrer" style="width:72px;height:72px;object-fit:cover;border-radius:10px;flex:none">` : ''}
@@ -114,13 +122,15 @@ function viewSearch() {
files: !hasSf ? undefined : files.status === 'fulfilled' ? { n: found.length + libHtml.split('class="row"').length - 1 } : { err: (files.reason && files.reason.message) || (libs.reason && libs.reason.message) || 'failed' },
music: !plexReady ? undefined : music.status === 'fulfilled' ? { n: music.value.length } : { err: music.reason && music.reason.message },
pods: !allowed('podcast') ? undefined : pods.status === 'fulfilled' ? { n: pods.value.length } : { err: pods.reason && pods.reason.message },
books: !allowed('audio') ? undefined : books.status === 'fulfilled' ? { n: books.value.length } : { err: books.reason && books.reason.message },
video: !plexReady ? undefined : video.status === 'fulfilled' ? { n: video.value.length } : { err: video.reason && video.reason.message },
});
const notes = [...(h.errors || [])].map(e => `<p class="sub">${esc(e)}</p>`).join('');
const linkHtml = allowed('links') ? allLinks().filter(l => (l.title + ' ' + l.url + ' ' + (l.group || '')).toLowerCase().includes(lower)).slice(0, 8).map(l => `<a class="row" href="${esc(safeUrl(l.url))}" target="_blank" rel="noopener noreferrer"><span class="ic" style="background:${linkColor(l)}">${icon('link')}</span><span class="grow"><b>${esc(l.title || l.url)}</b><small>${esc(new URL(safeUrl(l.url)).host)}${l.group ? ' · ' + esc(l.group) : ''}</small></span></a>`).join('') : '';
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.innerHTML = card + sec('Tasks', taskHtml) + sec('Links', linkHtml) + sec('Events', eventHtml) + sec('Libraries', libHtml) + sec('Files', fileHtml) + sec('Music', musicHtml) + (podHtml ? sec('Podcasts', podHtml) : '') + (bookHtml ? sec('Audiobooks', bookHtml) : '') + (videoHtml ? `<h2>Movies & TV</h2><div class="vgrid">${videoHtml}</div>` : '') + notes;
if (!taskHtml && !linkHtml && !eventHtml && !libHtml && !fileHtml && !musicHtml && !videoHtml && !podHtml && !bookHtml && !card) res.innerHTML = '<p class="empty">No results. The line above shows what was searched.</p>' + notes;
res.querySelectorAll('button[data-f]').forEach(b => b.onclick = () => { const [r, p, n, dd] = JSON.parse(b.dataset.f); openFile(r, p, n, dd); });
res.querySelectorAll('a[data-book]').forEach(a => a.onclick = e => { e.preventDefault(); const b = (books.value || []).find(x => abId(x) === a.dataset.book); if (b) abSheet(b); });
res.querySelectorAll('button[data-t]').forEach(b => b.onclick = () => playQueue([JSON.parse(b.dataset.t)], 0));
res.querySelectorAll('button[data-tl]').forEach(b => b.onclick = () => { store.set('task_list', b.dataset.tl); location.hash = '#/tasks'; });
}
+1 -1
View File
@@ -1,5 +1,5 @@
// App-shell cache only; Seafile/API traffic always goes to the network.
const CACHE = 'hub-v11';
const CACHE = 'hub-v12';
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'];