diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js
index 18f82e3..871292d 100644
--- a/static/js/audiobooks.js
+++ b/static/js/audiobooks.js
@@ -439,6 +439,8 @@
if (!b) { view.innerHTML = '
' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
'
' + esc(b.title) + '
' +
@@ -459,7 +462,8 @@
'
' +
'' +
'' +
- '
' +
+ '
' +
+ (dl ? downloadButton(dl) : '') + '
' +
(marks.length ? '
Lesezeichen
' + marks.map(m =>
'
').join('') + '
' : '') +
'
Kapitel
' +
- (d ? '
' + d.chapters.map(c => chapterHtml(c, now)).join('') + '
'
+ (d ? '
' + d.chapters.map(c => chapterHtml(c, now, dlMap)).join('') + '
'
: '
Wird geladen …
');
}
@@ -477,7 +481,105 @@
return '▶ ' + (b.done || b.heard_seconds > 0 ? 'Weiterhören' : 'Anfangen');
}
- function chapterHtml(c, now) {
+ // ---- downloads (the Android app: chapters on the device, for listening without a connection) ----
+
+ function dlHost() {
+ const h = host();
+ return h && typeof h.podcastDownload === 'function' && typeof h.podcastDownloads === 'function' ? h : null;
+ }
+
+ // The host's downloads: {episodeId: {state: queued|downloading|done|failed, progress …}}, or null.
+ function downloads() {
+ const h = dlHost();
+ if (!h) return null;
+ try { return JSON.parse(h.podcastDownloads()) || {}; } catch (e) { return {}; }
+ }
+
+ function bookDownloads(d, map) {
+ const n = {total: d.chapters.length, done: 0, busy: 0, failed: 0, held: 0};
+ d.chapters.forEach(c => {
+ const e = map[c.episode__id];
+ if (!e) return;
+ n.held++;
+ if (e.state === 'done') n.done++;
+ else if (e.state === 'failed') n.failed++;
+ else n.busy++;
+ });
+ return n;
+ }
+
+ function downloadButton(n) {
+ if (n.done === n.total) return '
' + DL_DONE + ' Auf dem Gerät';
+ if (n.busy) return '
↓ Lädt … ' + n.done + '/' + n.total + '';
+ return '
↓ Herunterladen' + (n.done ? ' (noch ' + (n.total - n.done) + ')' : '') + '';
+ }
+
+ // Arrow into a tray; on the device: the same, white in a full circle (not a ✓: that's "heard").
+ const DL_ICON = '
';
+ const DL_DONE = '
';
+
+ function chapterDownload(c, map) {
+ const e = map[c.episode__id];
+ let sign = DL_ICON;
+ let label = 'Kapitel herunterladen';
+ let cls = '';
+ if (e && e.state === 'done') { sign = DL_DONE; label = 'Auf dem Gerät – tippen: löschen'; cls = ' ab-dl-on'; }
+ else if (e && e.state === 'failed') { sign = '!'; label = 'Fehlgeschlagen – tippen: erneut versuchen'; }
+ else if (e && e.state === 'downloading') { sign = Math.round((e.progress || 0) * 100) + '%'; label = 'Lädt'; cls = ' ab-dl-busy'; }
+ else if (e) { sign = '…'; label = 'Wartet auf den Download'; cls = ' ab-dl-busy'; }
+ return '
' + sign + '';
+ }
+
+ // All chapters not yet on the device, from the one that comes next on (then the ones before).
+ function downloadAll() {
+ const h = dlHost();
+ const d = st.detail;
+ if (!h || !d) return;
+ const map = downloads() || {};
+ const from = d.book.next ? d.book.next.index : 0;
+ const order = d.chapters.slice(from).concat(d.chapters.slice(0, from));
+ let n = 0;
+ order.forEach(c => {
+ const e = map[c.episode__id];
+ if (e && e.state !== 'failed') return;
+ if (e && typeof h.podcastRetryDownload === 'function') h.podcastRetryDownload(c.episode__id);
+ else h.podcastDownload(JSON.stringify(c));
+ n++;
+ });
+ toast(n ? n + (n === 1 ? ' Kapitel wird' : ' Kapitel werden') + ' heruntergeladen' : 'Schon alles auf dem Gerät');
+ setTimeout(renderView, 300);
+ }
+
+ async function removeDownloads() {
+ const h = dlHost();
+ const d = st.detail;
+ if (!h || !d || typeof h.podcastDeleteDownload !== 'function') return;
+ const ok = typeof customConfirm === 'function'
+ ? await customConfirm('Die Kapitel von „' + d.book.title + '“ vom Gerät löschen? Stellen und Lesezeichen bleiben; abspielen geht dann nur mit Verbindung.', {danger: true})
+ : true;
+ if (!ok) return;
+ const map = downloads() || {};
+ d.chapters.forEach(c => { if (map[c.episode__id]) h.podcastDeleteDownload(c.episode__id); });
+ toast('Vom Gerät gelöscht');
+ setTimeout(renderView, 300);
+ }
+
+ async function chapterDownloadTap(index) {
+ const h = dlHost();
+ const d = st.detail;
+ const c = d && d.chapters[index];
+ if (!h || !c) return;
+ const e = (downloads() || {})[c.episode__id];
+ if (!e) h.podcastDownload(JSON.stringify(c));
+ else if (e.state === 'failed' && typeof h.podcastRetryDownload === 'function') h.podcastRetryDownload(c.episode__id);
+ else if (e.state === 'done' && typeof h.podcastDeleteDownload === 'function') {
+ const ok = typeof customConfirm === 'function' ? await customConfirm('Kapitel ' + (index + 1) + ' vom Gerät löschen?') : true;
+ if (ok) h.podcastDeleteDownload(c.episode__id);
+ }
+ setTimeout(renderView, 300);
+ }
+
+ function chapterHtml(c, now, dlMap) {
const playing = now && now.index === c.index;
const dur = c.episode__duration_seconds || 0;
let meta;
@@ -491,7 +593,8 @@
'
' + esc(meta) + '' +
'
' + (c.played ? '✓' : '○') + ' ';
+ 'title="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '">' + (c.played ? '✓' : '○') + '' +
+ (dlMap ? chapterDownload(c, dlMap) : '') + '';
}
// Play the open book's chapter `index` (from its own place, or from `ms`).
@@ -631,6 +734,12 @@
else if (act === 'unplayed') markAll(false);
else if (act === 'played') markAll(true);
else if (act === 'folder') assignFolder(st.open);
+ else if (act === 'dl-all') downloadAll();
+ else if (act === 'dl-remove') removeDownloads();
+ else if (act === 'dl-info') {
+ const n = bookDownloads(st.detail, downloads() || {});
+ toast(n.done === n.total ? 'Alle ' + n.total + ' Kapitel sind auf dem Gerät (löschen: ⋮ oben)' : n.done + ' von ' + n.total + ' Kapiteln sind da, der Rest lädt');
+ }
else if (act === 'podcast') {
const ok = typeof customConfirm === 'function'
? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.')
@@ -640,6 +749,7 @@
closeBook();
}
else if (t.dataset.abPlayed != null) markChapter(Number(t.dataset.abPlayed));
+ else if (t.dataset.abDl != null) chapterDownloadTap(Number(t.dataset.abDl));
else if (t.dataset.abCh != null) play(Number(t.dataset.abCh));
else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm));
else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit));
@@ -818,6 +928,11 @@
'.ab-chrow .ab-row { border-bottom: none; }',
'.ab-tick { color: var(--muted, #9a9a9a); font-size: 17px; }',
'.ab-tick.ab-ticked { color: var(--accent, #e63946); }',
+ '.ab-dl { color: var(--muted, #9a9a9a); font-size: 15px; min-width: 44px; }',
+ '.ab-dl-ic { width: 20px; height: 20px; vertical-align: -4px; }',
+ '.ab-dl.ab-dl-on { color: var(--fg, inherit); }',
+ '.ab-dl.ab-dl-busy { font-size: 12px; }',
+ '.ab-btn.ab-dl-on { border-color: var(--fg, currentColor); }',
'.ab-mark .ab-row { flex: 1; min-width: 0; }',
'#ab-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#ab-toast.ab-show { opacity: 1; }',
diff --git a/static/js/sw.js b/static/js/sw.js
index 8947bb6..990e69a 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v125';
+const CACHE = 'diora-v126';
const SHELL = [
'/static/css/app.css',
'/static/js/app/01-core.js',