Hörbücher herunterladen (Android-App)
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:
parent
c52da00405
commit
fa9dc073cb
2 changed files with 120 additions and 5 deletions
|
|
@ -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; }',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue