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

Wird geladen …

'; 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 @@ '' + '' + '' + + (dl && dl.held ? '' : '') + '' + '
' + 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 ''; + if (n.busy) return ''; + return ''; + } + + // 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 ''; + } + + // 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) + '' + '
'; + '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',