Hörbücher herunterladen (Android-App)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 9m8s
Test / e2e (push) Successful in 2m2s

In der Ansicht eines Hörbuchs „↓ Herunterladen“: alle Kapitel, die noch
fehlen, ab dem nächsten (mit Fortschritt „Lädt … 12/56“, danach „Auf dem
Gerät“); je Kapitel ein Download-Zeichen (laden, Fortschritt, erneut
versuchen, löschen) und im ⋮-Menü „Vom Gerät löschen“. Nur wo der Host
Downloads kann (DioraNative.podcastDownload); sw.js: diora-v126.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-17 10:16:39 +02:00
parent c52da00405
commit fa9dc073cb
2 changed files with 120 additions and 5 deletions

View file

@ -439,6 +439,8 @@
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
const now = nowInBook();
const marks = d ? d.bookmarks : [];
const dlMap = d ? downloads() : null;
const dl = dlMap ? bookDownloads(d, dlMap) : null;
const titleOf = id => {
const ch = d ? d.chapters.filter(c => c.episode__id === id)[0] : null;
return ch ? 'Kapitel ' + (ch.index + 1) : '';
@ -450,6 +452,7 @@
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
'<button type="button" data-ab="folder">In Ordner …</button>' +
(dl && dl.held ? '<button type="button" data-ab="dl-remove">Vom Gerät löschen (' + dl.held + ' Kapitel)</button>' : '') +
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
@ -459,7 +462,8 @@
'<div class="ab-actions">' +
'<button type="button" class="ab-btn ab-primary" data-ab="go">' + goLabel(b, now) + '</button>' +
'<button type="button" class="ab-btn" data-ab="sleep"' + (now ? '' : ' disabled') + '>☾ Kapitelende</button>' +
'<button type="button" class="ab-btn" data-ab="mark"' + (now ? '' : ' disabled') + '>+ Lesezeichen</button></div>' +
'<button type="button" class="ab-btn" data-ab="mark"' + (now ? '' : ' disabled') + '>+ Lesezeichen</button>' +
(dl ? downloadButton(dl) : '') + '</div>' +
(marks.length ? '<h3 class="ab-h">Lesezeichen</h3><div class="ab-marks">' + marks.map(m =>
'<div class="ab-mark"><button type="button" class="ab-row" data-ab-bm="' + m.id + '">' +
'<span class="ab-card-text"><span class="ab-meta">' + esc(titleOf(m.episode_id)) + ' · ' + clock(m.position_ms) + '</span>' +
@ -467,7 +471,7 @@
'<button type="button" class="ab-icon" data-ab-bm-edit="' + m.id + '" aria-label="Notiz ändern">✎</button>' +
'<button type="button" class="ab-icon" data-ab-bm-del="' + m.id + '" aria-label="Löschen">✕</button></div>').join('') + '</div>' : '') +
'<h3 class="ab-h">Kapitel</h3>' +
(d ? '<div class="ab-chapters">' + d.chapters.map(c => chapterHtml(c, now)).join('') + '</div>'
(d ? '<div class="ab-chapters">' + d.chapters.map(c => chapterHtml(c, now, dlMap)).join('') + '</div>'
: '<p class="ab-empty">Wird geladen …</p>');
}
@ -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 '<button type="button" class="ab-btn ab-dl-on" data-ab="dl-info">' + DL_DONE + ' Auf dem Gerät</button>';
if (n.busy) return '<button type="button" class="ab-btn" data-ab="dl-info">↓ Lädt … ' + n.done + '/' + n.total + '</button>';
return '<button type="button" class="ab-btn" data-ab="dl-all">↓ Herunterladen' + (n.done ? ' (noch ' + (n.total - n.done) + ')' : '') + '</button>';
}
// Arrow into a tray; on the device: the same, white in a full circle (not a ✓: that's "heard").
const DL_ICON = '<svg class="ab-dl-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10.5l5 5 5-5M5 19.5h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="square"/></svg>';
const DL_DONE = '<svg class="ab-dl-ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor"/><path d="M12 6v8M8.5 10.5l3.5 3.5 3.5-3.5M7.5 17.5h9" fill="none" stroke="var(--bg, #fff)" stroke-width="2" stroke-linecap="square"/></svg>';
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 '<button type="button" class="ab-icon ab-dl' + cls + '" data-ab-dl="' + c.index + '" aria-label="' + label + '" title="' + label + '">' + sign + '</button>';
}
// 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 @@
'<span class="ab-meta">' + esc(meta) + '</span></span></button>' +
'<button type="button" class="ab-icon ab-tick' + (c.played ? ' ab-ticked' : '') + '" data-ab-played="' + c.index + '" ' +
'aria-label="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '" ' +
'title="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '">' + (c.played ? '✓' : '○') + '</button></div>';
'title="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '">' + (c.played ? '✓' : '○') + '</button>' +
(dlMap ? chapterDownload(c, dlMap) : '') + '</div>';
}
// 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; }',

View file

@ -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',