// Copied from diora-android f94c501 2026-10-02: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. // Podcasts for the phone, the AntennaPod way: the queue comes first. // // The app keeps the podcast state (Podcasts.kt: feeds, queue, inbox and // settings, fetched from the server in one request and kept for offline use) // and plays it (RadioService: the queue is the playlist, it keeps the place // and moves on by itself). This page only shows that state and hands what // the user does to DioraNative.podcast*. The web app's own podcast tab stays // hidden; its endpoints are the server side of all this. // // The browser runs this same file (diora-web copies it, tools/sync_podcast_ui.py): // there window.DioraPodcastHost (diora-web static/js/podcasts-web.js) plays the // part of the app, with the same calls. (function () { 'use strict'; const N = window.DioraPodcastHost || window.DioraNative; if (!N || !N.podcastState) return; // E-readers have no speaker; they stay a pure book app. if (N.isEinkDevice && N.isEinkDevice()) return; document.documentElement.classList.add('nui-podcasts'); const $id = id => document.getElementById(id); const VIEWS = [ {id: 'queue', label: 'Warteschlange'}, {id: 'inbox', label: 'Neu'}, {id: 'episodes', label: 'Folgen'}, {id: 'feeds', label: 'Abos'}, {id: 'search', label: 'Suche'}, ]; const POSITIONS = [ {value: 'back', label: 'Ans Ende'}, {value: 'front', label: 'An den Anfang'}, {value: 'after_current', label: 'Nach der laufenden Folge'}, ]; const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2]; function parse(s, fallback) { try { return JSON.parse(s); } catch (e) { return fallback; } } let state = parse(N.podcastState(), {}); let player = {kind: null}; let view = 'queue'; let feedView = null; // {feed, episodes} while one podcast is open let searchResults = null; let refreshing = false; let selected = null; // Set of episode ids while choosing several (queue or inbox) let locked = false; // queue locked: no dragging try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {} try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {} if (!VIEWS.some(v => v.id === view)) view = 'queue'; const queue = () => state.queue || []; const inbox = () => state.inbox || []; const feeds = () => state.feeds || []; const settings = () => state.settings || {}; const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0); // ---- small helpers --------------------------------------------------------------- function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function button(label, title, onClick, cls) { const b = el('button', 'btn-icon nui-icon' + (cls ? ' ' + cls : ''), label); b.type = 'button'; b.title = title; b.setAttribute('aria-label', title); b.addEventListener('click', e => { e.stopPropagation(); onClick(e); }); return b; } function openSheet(title, items) { if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items); } function clock(seconds) { seconds = Math.max(0, Math.round(seconds || 0)); const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function minutes(seconds) { const m = Math.round((seconds || 0) / 60); if (m < 60) return `${m} min`; return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`; } function day(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d)) return ''; const diff = Math.floor((Date.now() - d) / 86400000); if (diff <= 0) return 'heute'; if (diff === 1) return 'gestern'; if (diff < 7) return `vor ${diff} Tagen`; return d.toLocaleDateString('de-DE', {day: 'numeric', month: 'short', year: diff > 300 ? 'numeric' : undefined}); } let toastTimer = 0; function toast(text) { let t = $id('nui-radio-toast'); if (!t) { t = el('div'); t.id = 'nui-radio-toast'; document.body.appendChild(t); } t.textContent = text; t.classList.add('nui-show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600); } // Feed titles sometimes arrive with HTML entities still in them (&). const decoder = document.createElement('textarea'); function plain(s) { if (!s || s.indexOf('&') < 0) return s || ''; decoder.innerHTML = s; return decoder.value; } // Episodes come in two shapes: queue entries (episode__*) and inbox/feed // episodes. One view of both: function ep(o) { const e = shape(o); e.title = plain(e.title); e.feed = plain(e.feed); return e; } function shape(o) { if (o.episode__id != null) { return { id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id, art: o.episode__artwork_url || o.episode__feed__artwork_url, duration: o.episode__duration_seconds, pos: o.position_seconds, date: o.episode__pub_date, raw: o, }; } return { id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id, art: o.artwork_url || o.feed__artwork_url || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds, pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o, }; } function art(src, size) { const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : '')); if (src) { const img = el('img'); img.loading = 'lazy'; img.alt = ''; img.src = src.replace(/^http:/, 'https:'); img.addEventListener('error', () => img.remove()); box.append(img); } return box; } function inQueue(id) { return queue().some(q => q.episode__id === id); } // ---- actions --------------------------------------------------------------------------- function play(e) { if (currentId() === e.id) { openPlayer(); return; } N.podcastPlayEpisode(JSON.stringify(e.raw), currentId()); } function enqueue(e, where) { N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId()); const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position)); toast('In der Warteschlange' + (label ? ': ' + label.label.toLowerCase() : '')); } // Move within the queue: "next" is right after the episode that plays. function moveTo(e, where) { const ids = queue().map(q => q.episode__id).filter(id => id !== e.id); let index = where === 'top' ? 0 : ids.length; if (where === 'next') { const at = ids.indexOf(currentId()); index = at >= 0 ? at + 1 : 0; } N.podcastMove(e.id, index); } function markPlayed(e) { N.podcastMarkPlayed(e.id, true); toast('Als gehört markiert'); } function queueMenu(e) { const playing = currentId() === e.id; openSheet(e.title, [ {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)}, !playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, favoriteItem(e), !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, {icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)}, ...dlMenuItems(e), {icon: '✕', label: 'Aus der Warteschlange nehmen', danger: true, action: () => N.podcastDequeue(e.id)}, ]); } function episodeMenu(e) { const queued = inQueue(e.id); openSheet(e.title, [ {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)}, favoriteItem(e), !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')}, !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')}, !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')}, queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)}, !e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)}, e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)}, ...dlMenuItems(e), {icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen', action: () => N.podcastDismiss(JSON.stringify([e.id]))}, ]); } function settingsMenu() { const s = settings(); const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({ icon: s[name] === p.value ? '●' : '○', label: p.label, active: s[name] === p.value, action: () => N.podcastSettings(JSON.stringify({[name]: p.value})), }))); const label = v => (POSITIONS.find(p => p.value === v) || POSITIONS[0]).label; const prefs = devicePrefs(); // Grouped like AntennaPod's settings; each group opens its own list. const group = (icon, title, hint, items) => ({icon, label: title, hint, action: () => openSheet(title, items())}); openSheet('Podcasts', [ {icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, hier kommt nur das Ergebnis an', action: () => refresh(true)}, group('≡', 'Warteschlange', 'wohin neue Folgen kommen, gehörte entfernen', () => [ {icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position), action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')}, {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')}, removePlayedItem(), ]), group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“', 'headset_next' in prefs && 'Kopfhörer'].filter(Boolean).join(', '), () => [ skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]), skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]), silenceItem(), playedItem(), headsetItem(), ]), DL && group('↓', 'Downloads', 'automatisch, Limit, aufräumen', () => [ // Account-wide (server): new subscriptions start with auto-download on or off. 'auto_download_default' in s && {icon: s.auto_download_default ? '☑' : '☐', label: 'Neue Abos laden von selbst', hint: s.auto_download_default ? 'an: neue Folgen neuer Abos werden geladen' : 'aus: pro Podcast einschaltbar', action: () => N.podcastSettings(JSON.stringify({auto_download_default: !s.auto_download_default}))}, ...dlSettingsItems(), ]), group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml}, {icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml}, ]), {icon: '▤', label: 'Hörstatistik', action: openStats}, ]); } function skipItem(name, label, choices) { if (!N.podcastSetPrefValue) return null; const now = devicePrefs()[name]; return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`, action: () => openSheet(label, choices.map(c => ({ icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c), })))}; } function playedItem() { if (!N.podcastSetPrefValue) return null; const now = devicePrefs().smart_played; if (now == null) return null; const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende'); return {icon: '✓', label: 'Als gehört zählen', hint: label(now), action: () => openSheet('Eine Folge zählt als gehört …', [0, 15, 30, 60, 120, 300, 600].map(v => ({ icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v), })))}; } // A host without headset buttons or silence skipping (the browser) leaves them out of its prefs. function headsetItem() { if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null; const next = !!devicePrefs().headset_next; return {icon: '🎧', label: 'Kopfhörer: zweimal tippen', hint: next ? 'nächste Folge' : 'vorspulen (dreimal: zurück)', action: () => openSheet('Kopfhörer zweimal tippen …', [ {icon: !next ? '●' : '○', label: 'Vorspulen', hint: 'dreimal tippen springt zurück', action: () => N.podcastSetPref('headset_next', false)}, {icon: next ? '●' : '○', label: 'Nächste Folge', action: () => N.podcastSetPref('headset_next', true)}, ])}; } function silenceItem() { if (!N.podcastSetPref || !('skip_silence' in devicePrefs())) return null; const on = !!devicePrefs().skip_silence; return {icon: on ? '☑' : '☐', label: 'Stille überspringen', hint: on ? 'an: Pausen in Folgen werden übersprungen' : 'aus', action: () => N.podcastSetPref('skip_silence', !on)}; } function devicePrefs() { return parse(N.podcastPrefs ? N.podcastPrefs() : '{}', {}); } function removePlayedItem() { if (!N.podcastSetPref) return null; const on = devicePrefs().remove_played !== false; return {icon: on ? '☑' : '☐', label: 'Gehörte Folgen aus der Warteschlange nehmen', hint: on ? 'an: fertige Folgen verlassen die Warteschlange' : 'aus: fertige Folgen bleiben stehen', action: () => { N.podcastSetPref('remove_played', !on); toast(on ? 'Gehörte Folgen bleiben jetzt stehen' : 'Gehörte Folgen verlassen jetzt die Warteschlange'); }}; } // ---- sorting, locking, choosing several ---------------------------------------------- const SORTS = [ {id: 'new', label: 'Neueste zuerst', key: e => -(Date.parse(e.date) || 0)}, {id: 'old', label: 'Älteste zuerst', key: e => Date.parse(e.date) || 0}, {id: 'short', label: 'Kürzeste zuerst', key: e => Math.max(0, (e.duration || 0) - (e.pos || 0))}, {id: 'long', label: 'Längste zuerst', key: e => -Math.max(0, (e.duration || 0) - (e.pos || 0))}, {id: 'feed', label: 'Nach Podcast', key: e => (e.feed || '').toLowerCase() + ' ' + String(9e15 - (Date.parse(e.date) || 0))}, ]; // The episode that plays stays on top; everything else is sorted. function sortQueue(sort) { const items = queue().map(ep); const now = items.filter(e => e.id === currentId()); const rest = items.filter(e => e.id !== currentId()); if (sort === 'shuffle') { for (let i = rest.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [rest[i], rest[j]] = [rest[j], rest[i]]; } } else { const s = SORTS.find(x => x.id === sort); rest.sort((a, b) => { const ka = s.key(a), kb = s.key(b); return ka < kb ? -1 : ka > kb ? 1 : 0; }); } N.podcastReorder(JSON.stringify(now.concat(rest).map(e => e.id))); } const keepSorted = () => settings().keep_sorted || ''; function sortMenu() { const kept = keepSorted(); const keptLabel = kept ? (SORTS.find(x => x.id === kept) || {}).label : ''; openSheet('Warteschlange sortieren', SORTS.map(x => ({icon: '⇅', label: x.label, action: () => sortQueue(x.id)})) .concat([{icon: '⤨', label: 'Mischen', action: () => sortQueue('shuffle')}]) .concat('keep_sorted' in settings() ? [{icon: kept ? '☑' : '☐', label: 'Immer sortiert halten', hint: kept ? `an: ${keptLabel}` : 'aus: Reihenfolge bestimmst du', action: () => openSheet('Immer sortiert halten', [{id: '', label: 'Aus'}].concat(SORTS).map(x => ({ icon: kept === x.id ? '●' : '○', label: x.label, hint: x.id ? 'auch neu eingereihte Folgen' : null, action: () => N.podcastSettings(JSON.stringify({keep_sorted: x.id})), })))}] : [])); } function toggleLock() { locked = !locked; try { localStorage.setItem('nui_pod_locked', locked ? '1' : '0'); } catch (e) {} toast(locked ? 'Warteschlange gesperrt' : 'Warteschlange entsperrt'); render(); } function startSelecting(id) { selected = new Set(id ? [id] : []); render(); } function stopSelecting() { if (!selected) return false; selected = null; render(); return true; } function toggleSelected(id) { if (selected.has(id)) selected.delete(id); else selected.add(id); render(); } // The bar while choosing: count, all/none, and what to do with the chosen ones. function selectionBar(items, actions) { const bar = el('div', 'nui-pod-selbar'); const all = items.length > 0 && items.every(e => selected.has(e.id)); bar.append( button('✕', 'Auswahl beenden', stopSelecting), el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : 'Folgen antippen'), button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => { selected = new Set(all ? [] : items.map(e => e.id)); render(); }), ); const acts = el('div', 'nui-pod-selactions'); for (const a of actions) { const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label); b.type = 'button'; b.disabled = !selected.size; b.addEventListener('click', () => { const ids = items.filter(e => selected.has(e.id)).map(e => e.id); if (ids.length) a.run(ids); }); acts.append(b); } bar.append(acts); return bar; } function listTools(buttons) { const t = el('div', 'nui-pod-tools'); for (const b of buttons) if (b) t.append(b); return t; } function refresh(fetchFeeds) { refreshing = true; render(); N.podcastRefresh(!!fetchFeeds); } // ---- rendering --------------------------------------------------------------------------- let root = null; // The page scrolls inside .main-content (app.css), not the window. function scroller() { for (let e = root && root.parentElement; e && e !== document.body; e = e.parentElement) { const o = getComputedStyle(e).overflowY; if (o === 'auto' || o === 'scroll') return e; } return document.scrollingElement || document.documentElement; } function scrollTop(y) { const s = scroller(); if (y == null) return s.scrollTop; s.scrollTop = y; return y; } function build() { const tab = $id('tab-podcasts'); if (!tab || $id('nui-pod')) return false; root = el('div'); root.id = 'nui-pod'; tab.prepend(root); return true; } function render() { if (!root) return; const scroll = scrollTop(); root.textContent = ''; const bar = el('div', 'nui-tab-bar'); bar.id = 'nui-pod-bar'; const text = el('div', 'nui-bar-text'); text.append(el('span', 'native-brand', 'Podcasts')); if (refreshing) text.append(el('span', 'muted nui-pod-status', 'Wird abgerufen …')); else if (state.server_time) text.append(el('span', 'muted nui-pod-status', 'Stand ' + day(state.server_time) + ', ' + new Date(state.server_time).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}))); bar.append(text, button('⟳', 'Alle Feeds abrufen', () => refresh(true)), button('⋮', 'Einstellungen', settingsMenu)); root.append(bar); if (feedView) { renderFeed(); } else { const tabs = el('div', 'nui-pod-tabs'); for (const v of VIEWS) { const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : '')); b.type = 'button'; b.append(el('span', null, v.label)); const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inbox().length : 0; if (count) b.append(el('span', 'nui-pod-count', String(count))); b.addEventListener('click', () => { selected = null; view = v.id; try { localStorage.setItem('nui_pod_view', view); } catch (e) {} render(); scrollTop(0); }); tabs.append(b); } root.append(tabs); if (view === 'queue') renderQueue(); else if (view === 'inbox') renderInbox(); else if (view === 'episodes') renderEpisodes(); else if (view === 'feeds') renderFeeds(); else renderSearch(); } scrollTop(scroll); } function empty(text) { root.append(el('div', 'nui-pod-empty', text)); } function progressBar(pos, duration) { if (!pos || !duration) return null; const b = el('div', 'nui-pod-progress'); const f = el('span'); f.style.width = Math.min(100, Math.round(pos / duration * 100)) + '%'; b.append(f); return b; } function episodeRow(e, opts) { const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : '')); row.dataset.id = e.id; row.append(art(e.art)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', e.title)); // Length first: podcast names are long and get cut off. const meta = []; // A played episode, or a place past the feed's (often wrong) length: just the length. const started = e.pos > 30 && !e.played && e.pos < e.duration - 30; if (e.duration) meta.push(started ? minutes(e.duration - e.pos) + ' übrig' : minutes(e.duration)); if (opts.showDate && e.date) meta.push(day(e.date)); if (opts.showFeed && e.feed) meta.push(e.feed); main.append(el('div', 'nui-pod-meta', meta.join(' · '))); const p = e.played ? null : progressBar(e.pos, e.duration); if (p) main.append(p); row.append(main); if (selected) { row.classList.add('nui-pod-selecting'); const box = el('span', 'nui-pod-check' + (selected.has(e.id) ? ' on' : ''), selected.has(e.id) ? '☑' : '☐'); row.prepend(box); row.addEventListener('click', () => toggleSelected(e.id)); } else { dlAppend(row, e); row.addEventListener('click', () => play(e)); } if (!selected && !opts.noSelect) { // Long press starts choosing several, like in AntennaPod. let timer = 0; row.addEventListener('pointerdown', ev => { if (ev.target.closest('button, .nui-pod-handle')) return; timer = setTimeout(() => { timer = 0; startSelecting(e.id); }, 550); }); const cancel = () => { if (timer) clearTimeout(timer); timer = 0; }; row.addEventListener('pointerup', cancel); row.addEventListener('pointerleave', cancel); row.addEventListener('pointercancel', cancel); row.addEventListener('contextmenu', ev => ev.preventDefault()); } return row; } function renderQueue() { const items = queue().map(ep); if (!items.length) { empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.'); return; } const total = items.reduce((n, e) => n + Math.max(0, (e.duration || 0) - (e.pos || 0)), 0); if (selected) { root.append(selectionBar(items, [ {label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))}, {label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Entfernen', danger: true, run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, ])); } else { const head = el('div', 'nui-pod-head'); const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : ''; head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}`), listTools([ button('⇅', 'Sortieren oder mischen', sortMenu), button('☐', 'Mehrere auswählen', () => startSelecting()), button(locked ? '🔒' : '🔓', locked ? 'Warteschlange entsperren' : 'Warteschlange sperren', toggleLock, locked ? 'nui-pod-lock on' : 'nui-pod-lock'), ])); root.append(head); } const list = el('div', 'nui-pod-list nui-pod-queue'); for (const e of items) { const row = episodeRow(e, {showFeed: true}); if (!selected) { row.append(button('⋯', 'Mehr', () => queueMenu(e))); if (!locked && !keepSorted()) { const handle = el('div', 'nui-pod-handle', '≡'); handle.title = 'Ziehen zum Umsortieren'; handle.addEventListener('click', ev => ev.stopPropagation()); dragSort(handle, row, list); row.append(handle); } } list.append(row); } root.append(list); } function renderInbox() { const items = inbox().map(ep); if (!items.length) { empty(feeds().length ? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.' : 'Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } if (selected) { const chosen = ids => items.filter(e => ids.includes(e.id)); const add = (ids, where) => { N.podcastEnqueue(JSON.stringify(chosen(ids).map(e => e.raw)), where, currentId()); toast(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} eingereiht`); stopSelecting(); }; root.append(selectionBar(items, [ {label: 'Einreihen', run: ids => openSheet(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} einreihen`, [ {icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label, action: () => add(ids, '')}, {icon: '⤴', label: 'Als Nächstes', action: () => add(ids, 'after_current')}, {icon: '⇈', label: 'An den Anfang', action: () => add(ids, 'front')}, {icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')}, ])}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }}, ])); } else { const head = el('div', 'nui-pod-head'); head.append(el('div', 'nui-pod-summary', `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'}`), listTools([button('☐', 'Mehrere auswählen', () => startSelecting())])); root.append(head); } const list = el('div', 'nui-pod-list'); for (const e of items) { const row = episodeRow(e, {showFeed: true, showDate: true}); if (!selected) { row.append( e.inQueue || inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') : button('+', 'Einreihen', () => enqueue(e)), button('⋯', 'Mehr', () => episodeMenu(e))); } list.append(row); } root.append(list); } function renderFeeds() { const items = feeds().slice().sort((a, b) => String(b.latest_episode_at || '').localeCompare(String(a.latest_episode_at || ''))); if (!items.length) { empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } const list = el('div', 'nui-pod-list'); for (const f of items) { const row = el('div', 'nui-pod-row'); row.append(art(f.artwork_url)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(f.title))); const meta = []; if (f.author) meta.push(f.author); if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at)); main.append(el('div', 'nui-pod-meta', meta.join(' · '))); if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error)); row.append(main); if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still')); else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto')); row.addEventListener('click', () => openFeed(f)); list.append(row); } root.append(list); } async function openFeed(f) { feedView = {feed: f, episodes: null}; render(); scrollTop(0); try { const r = await fetch(`/podcasts/feeds/${f.id}/episodes/`); if (!r.ok) throw new Error(r.status); const data = await r.json(); if (feedView && feedView.feed.id === f.id) { feedView = {feed: Object.assign({}, f, data.feed), episodes: data.episodes}; render(); } } catch (e) { if (feedView && feedView.feed.id === f.id) { feedView.episodes = []; feedView.error = true; render(); } } } function renderFeed() { const f = feedView.feed; const head = el('div', 'nui-pod-feedhead'); head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; render(); }), art(f.artwork_url, 'big')); const info = el('div', 'nui-pod-main'); info.append(el('div', 'nui-pod-title', f.title)); if (f.author) info.append(el('div', 'nui-pod-meta', f.author)); head.append(info); root.append(head); const live = feeds().find(x => x.id === f.id) || f; if (live.last_error) { root.append(el('div', 'nui-pod-feederr nui-pod-feederr-box', `⚠ Der Feed ließ sich zuletzt nicht laden (${live.last_error}${live.last_error_at ? ', ' + day(live.last_error_at) : ''}). ` + 'Der Server versucht es weiter, bei wiederholten Fehlern seltener.')); } if ('new_episodes' in live && N.podcastFeedSettings) { root.append(newEpisodesRow(live)); if (N.podcastFeedPrefs) root.append(feedSettings(f)); root.append(unsubscribeBox(f)); return renderFeedEpisodes(f); } const auto = live.auto_queue; const toggle = el('label', 'nui-pod-switch'); const box = el('input'); box.type = 'checkbox'; box.checked = !!auto; box.addEventListener('change', () => N.podcastAutoQueue(f.id, box.checked)); const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase(); toggle.append(box, el('span', null, 'Neue Folgen automatisch einreihen'), el('span', 'nui-pod-meta', pos)); root.append(toggle); if (N.podcastFeedPrefs) root.append(feedSettings(f)); root.append(unsubscribeBox(f)); renderFeedEpisodes(f); } // Where this podcast's new episodes go, and which ones count at all. function newEpisodesRow(live) { const row = el('div', 'nui-pod-feedprefs'); const chip = (label, value, on, onClick) => { const b = el('button', 'nui-pod-chip' + (on ? ' on' : '')); b.type = 'button'; b.append(el('span', null, label), el('strong', null, value)); b.addEventListener('click', onClick); return b; }; const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); }; const where = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'}; const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase(); row.append(chip('Neue Folgen', where[live.new_episodes] || where.inbox, live.new_episodes !== 'inbox', () => openSheet(`Neue Folgen von „${plain(live.title)}“ …`, [ {icon: live.new_episodes === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', action: () => save({new_episodes: 'inbox'})}, {icon: live.new_episodes === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, action: () => save({new_episodes: 'queue'})}, {icon: live.new_episodes === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', action: () => save({new_episodes: 'ignore'})}, ]))); const words = [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`, live.min_duration && `ab ${live.min_duration} min`].filter(Boolean).join(', '); const ask = async (key, title, now) => { const v = typeof customPrompt === 'function' ? await customPrompt(title, now || '') : null; if (v != null) save({[key]: v.trim()}); }; // AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves. if ('auto_download' in live && N.podcastDownloads) { row.append(chip('Auto-Download', live.auto_download ? 'an' : 'aus', !!live.auto_download, () => openSheet(`Neue Folgen von „${plain(live.title)}“ herunterladen?`, [ {icon: live.auto_download ? '●' : '○', label: 'Ja, von selbst', hint: 'im WLAN, innerhalb des Download-Limits', action: () => save({auto_download: true})}, {icon: !live.auto_download ? '●' : '○', label: 'Nein', hint: 'nur die Warteschlange wird geladen', action: () => save({auto_download: false})}, ]))); } row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [ {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle', action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)}, {icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine', action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)}, {icon: '⏱', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine', action: () => openSheet('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({ icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge', action: () => save({min_duration: m}), })))}, ]))); return row; } function renderFeedEpisodes(f) { if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; } if (feedView.error) { empty('Die Folgen lassen sich gerade nicht laden. Das braucht eine Verbindung.'); return; } const list = el('div', 'nui-pod-list'); for (const o of feedView.episodes) { // Feed episodes don't name their podcast; queue entries need it. o.feed__id = f.id; o.feed__title = f.title; o.feed__artwork_url = f.artwork_url; const e = ep(o); const row = episodeRow(e, {showDate: true, noSelect: true}); row.append( inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') : button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }), button('⋯', 'Mehr', () => episodeMenu(e))); list.append(row); } root.append(list); } function volumeLabel(v) { if (!v || v === 1) return ''; return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || ''; } // This podcast's own playback: speed, intro and outro to skip. function feedSettings(f) { const live = feeds().find(x => x.id === f.id) || f; const own = 'skip_intro' in live ? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1} : (devicePrefs().feeds || {})[String(f.id)] || {}; const row = el('div', 'nui-pod-feedprefs'); const chip = (label, value, onClick) => { const b = el('button', 'nui-pod-chip' + (value ? ' on' : '')); b.type = 'button'; b.append(el('span', null, label), el('strong', null, value || 'aus')); b.addEventListener('click', onClick); return b; }; const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); }; const seconds = (key, title) => openSheet(title, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({ icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', action: () => set(key, v), }))); row.append( chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`, SPEEDS.map(v => ({icon: own.speed === v ? '●' : '○', label: speedLabel(v), action: () => set('speed', v)})) .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))), chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')), chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')), chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`, [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']] .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))), ); return row; } // Unsubscribing (server: POST /podcasts/feeds//remove/). Asked once more // right here; then the server forgets the podcast with its episodes, and the // app deletes their downloads (not those of favourites). function unsubscribeBox(f) { const box = el('div', 'nui-pod-unsub'); if (!feedView.confirmRemove) { const b = el('button', 'nui-pod-unsub-btn', 'Abo beenden'); b.type = 'button'; b.addEventListener('click', () => { if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; } feedView.confirmRemove = true; render(); }); box.append(b); return box; } box.classList.add('nui-pod-unsub-ask'); box.append(el('div', 'nui-pod-title', `„${plain(f.title)}“ nicht mehr abonnieren?`), el('div', 'nui-pod-meta', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt und Favoriten, ' + 'auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : ''))); const actions = el('div', 'nui-pod-unsub-actions'); const yes = el('button', 'btn nui-pod-unsub-yes', feedView.removing ? 'Wird beendet …' : 'Abo beenden'); yes.type = 'button'; yes.disabled = !!feedView.removing; yes.addEventListener('click', () => unsubscribe(f)); const no = el('button', 'btn', 'Abbrechen'); no.type = 'button'; no.disabled = !!feedView.removing; no.addEventListener('click', () => { feedView.confirmRemove = false; render(); }); actions.append(yes, no); box.append(actions); return box; } async function unsubscribe(f) { if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; } feedView.removing = true; render(); try { const r = await fetch(`/podcasts/feeds/${f.id}/remove/`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}', }); // 404: gone already (removed elsewhere) — the same result. if (!r.ok && r.status !== 404) throw new Error(r.status); } catch (e) { if (feedView && feedView.feed.id === f.id) { feedView.removing = false; render(); } toast(navigator.onLine ? 'Abo beenden hat nicht geklappt.' : 'Abo beenden braucht eine Verbindung.'); return; } // The episodes the page knows of it: downloads from before the app kept their podcast match by id. const ids = ((feedView && feedView.feed.id === f.id && feedView.episodes) || []).map(o => o.id) .concat(queue().filter(q => q.episode__feed__id === f.id).map(q => q.episode__id)) .concat(inbox().filter(o => o.feed__id === f.id).map(o => o.id)); state.feeds = feeds().filter(x => x.id !== f.id); if (feedView && feedView.feed.id === f.id) feedView = null; refreshing = true; render(); toast(`„${plain(f.title)}“ abbestellt`); // The app deletes the downloads and fetches the state again. if (N.podcastFeedRemoved) N.podcastFeedRemoved(f.id, JSON.stringify(ids)); else N.podcastRefresh(false); } // ---- all episodes, filtered (server: GET /podcasts/episodes/) ---------------------- const FILTERS = [ {id: 'unplayed', label: 'Ungehört'}, {id: 'in_progress', label: 'Angefangen'}, {id: 'favorites', label: 'Favoriten'}, {id: 'history', label: 'Verlauf'}, {id: 'played', label: 'Gehört'}, {id: 'all', label: 'Alle'}, ]; let epFilter = 'unplayed'; try { epFilter = localStorage.getItem('nui_pod_filter') || 'unplayed'; } catch (e) {} let epList = null; // {filter, items, total, loading, error} async function loadEpisodes(filter, append) { const offset = append && epList && epList.filter === filter ? epList.items.length : 0; if (!append) epList = {filter, items: [], total: 0, loading: true}; else epList.loading = true; render(); try { const r = await fetch(`/podcasts/episodes/?filter=${filter}&limit=100&offset=${offset}`); if (!r.ok) throw new Error(r.status); const d = await r.json(); if (!epList || epList.filter !== filter) return; epList = {filter, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0}; } catch (e) { if (!epList || epList.filter !== filter) return; epList.loading = false; epList.error = 'Die Folgenliste braucht eine Verbindung.'; } render(); } function renderEpisodes() { const chips = el('div', 'nui-pod-filters'); for (const f of FILTERS) { const b = el('button', 'nui-pod-chip' + (f.id === epFilter ? ' on' : ''), f.label); b.type = 'button'; b.addEventListener('click', () => { epFilter = f.id; try { localStorage.setItem('nui_pod_filter', epFilter); } catch (e) {} loadEpisodes(epFilter, false); }); chips.append(b); } root.append(chips); if (!epList || epList.filter !== epFilter) { loadEpisodes(epFilter, false); empty('Wird geladen …'); return; } if (epList.error && !epList.items.length) { empty(epList.error); return; } if (!epList.items.length) { empty(epList.loading ? 'Wird geladen …' : { unplayed: 'Keine ungehörten Folgen.', in_progress: 'Keine angefangenen Folgen.', favorites: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.', history: 'Noch nichts gehört.', played: 'Noch keine gehörten Folgen.', all: 'Keine Folgen.', }[epFilter]); return; } root.append(el('div', 'nui-pod-summary', `${epList.total} ${epList.total === 1 ? 'Folge' : 'Folgen'}`)); const list = el('div', 'nui-pod-list'); for (const o of epList.items) { const e = ep(o); const row = episodeRow(e, {showFeed: true, showDate: epFilter !== 'history', noSelect: true}); if (epFilter === 'history' && o.last_played_at) { row.querySelector('.nui-pod-meta').textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || ''); } if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ ')); row.append( inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') : button('+', 'Einreihen', () => enqueue(e)), button('⋯', 'Mehr', () => episodeMenu(e))); list.append(row); } root.append(list); if (epList.items.length < epList.total) { const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); more.type = 'button'; more.disabled = !!epList.loading; more.addEventListener('click', () => loadEpisodes(epFilter, true)); root.append(more); } } // ---- favourites ------------------------------------------------------------------------- function favoriteItem(e) { const o = e.raw || {}; const known = typeof o.favorite === 'boolean'; return {icon: o.favorite ? '★' : '☆', label: known ? (o.favorite ? 'Kein Favorit mehr' : 'Als Favorit markieren') : 'Favorit an/aus', action: () => toggleFavorite(e, star => { if (e.raw) e.raw.favorite = star; })}; } async function toggleFavorite(e, done) { const o = e.raw || {}; const body = typeof o.favorite === 'boolean' ? {favorite: !o.favorite} : {}; try { const r = await fetch(`/podcasts/episodes/${e.id}/favorite/`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body), }); if (!r.ok) throw new Error(r.status); const d = await r.json(); toast(d.favorite ? '★ Als Favorit markiert' : 'Kein Favorit mehr'); // Favourites keep their download (Downloads.kt). if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, !!d.favorite); const cached = storedDetails(e.id); if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite})); if (done) done(d.favorite); if (view === 'episodes' && epList) { const item = epList.items.find(x => x.id === e.id); if (item) item.favorite = d.favorite; render(); } } catch (err) { toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung'); } } // ---- listening statistics (server: GET /podcasts/stats/) ----------------------------- let statsSheet = null; function closeStats() { if (!statsSheet) return false; statsSheet.remove(); statsSheet = null; return true; } function hours(seconds) { const h = Math.floor(seconds / 3600); const m = Math.round((seconds % 3600) / 60); return h ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`; } function openStats() { closeStats(); statsSheet = el('div'); statsSheet.id = 'nui-pod-player'; statsSheet.className = 'nui-pod-notes-sheet'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeStats)); const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-stats'); panel.append(top, el('div', 'nui-pod-player-title', 'Hörstatistik'), tabs, body); statsSheet.append(panel); statsSheet.addEventListener('click', ev => { if (ev.target === statsSheet) closeStats(); }); document.body.append(statsSheet); const ranges = [{days: 30, label: '30 Tage'}, {days: 365, label: 'Jahr'}, {days: 0, label: 'Gesamt'}]; const show = async range => { tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.days === String(range.days))); body.textContent = ''; body.append(el('div', 'nui-pod-meta', 'Wird geladen …')); let d = null; try { const r = await fetch('/podcasts/stats/' + (range.days ? `?days=${range.days}` : '')); if (!r.ok) throw new Error(r.status); d = await r.json(); } catch (e) { body.textContent = ''; body.append(el('div', 'nui-pod-meta', 'Die Statistik braucht eine Verbindung.')); return; } body.textContent = ''; const big = el('div', 'nui-pod-stat-total'); big.append(el('strong', null, hours(d.total_seconds || 0)), el('span', null, `${d.episodes_played || 0} Folgen gehört · ${d.episodes_started || 0} angefangen`)); body.append(big); const feedsList = d.feeds || []; if (!feedsList.length) { body.append(el('div', 'nui-pod-meta', 'In diesem Zeitraum nichts gehört.')); return; } const max = Math.max(...feedsList.map(f => f.seconds || 0), 1); for (const f of feedsList) { const row = el('div', 'nui-pod-stat-row'); row.append(art(f.artwork_url)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(f.title)), el('div', 'nui-pod-meta', `${hours(f.seconds || 0)} · ${f.episodes_played || 0} Folgen`)); const bar = el('div', 'nui-pod-progress nui-pod-stat-bar'); const fill = el('span'); fill.style.width = Math.max(2, Math.round((f.seconds || 0) / max * 100)) + '%'; bar.append(fill); main.append(bar); row.append(main); body.append(row); } }; for (const r of ranges) { const b = el('button', 'nui-pod-subtab', r.label); b.type = 'button'; b.dataset.days = String(r.days); b.addEventListener('click', () => show(r)); tabs.append(b); } show(ranges[0]); } // ---- OPML: take the subscriptions to another app, or bring them in ------------------- async function exportOpml() { try { const r = await fetch('/podcasts/feeds/export.opml'); if (!r.ok) throw new Error(r.status); const blob = new Blob([await r.text()], {type: 'text/x-opml'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'diora-podcasts.opml'; a.click(); // the app offers to share or save it (native.js) setTimeout(() => URL.revokeObjectURL(a.href), 60000); } catch (e) { toast(navigator.onLine ? 'Export hat nicht geklappt' : 'Der Export braucht eine Verbindung'); } } function importOpml() { const input = document.createElement('input'); input.type = 'file'; input.accept = '.opml,.xml,text/x-opml,text/xml,application/xml'; input.addEventListener('change', async () => { const file = input.files && input.files[0]; if (!file) return; const form = new FormData(); form.append('file', file); toast('Abos werden importiert …'); try { const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form}); const d = await r.json(); if (!r.ok || !d.ok) throw new Error(d.error || r.status); toast(`${d.added} neue Abos${d.skipped ? `, ${d.skipped} schon vorhanden` : ''}`); refresh(true); } catch (e) { toast('Diese Datei ließ sich nicht importieren'); } }); input.click(); } function renderSearch() { const form = el('form', 'nui-pod-search'); const input = el('input', 'search-input'); input.type = 'search'; input.id = 'nui-pod-q'; input.placeholder = 'Podcast suchen oder Feed-Adresse einfügen …'; input.setAttribute('enterkeyhint', 'search'); input.value = searchResults ? searchResults.q : ''; const go = el('button', 'btn', 'Suchen'); go.type = 'submit'; form.append(input, go); form.addEventListener('submit', ev => { ev.preventDefault(); search(input.value.trim()); }); root.append(form); if (!searchResults) { empty('Sucht im Podcast-Verzeichnis von Apple. Eine Feed-Adresse (https://…) abonnierst du direkt.'); return; } if (searchResults.loading) { empty('Suche läuft …'); return; } if (searchResults.error) { empty(searchResults.error); return; } if (!searchResults.items.length) { empty('Nichts gefunden.'); return; } const subscribed = new Set(feeds().map(f => f.rss_url)); const list = el('div', 'nui-pod-list'); for (const r of searchResults.items) { const row = el('div', 'nui-pod-row'); row.append(art(r.artwork_url)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', r.title), el('div', 'nui-pod-meta', [r.author, r.genre].filter(Boolean).join(' · '))); row.append(main); if (!r.rss_url) row.append(el('span', 'nui-pod-meta', 'kein Feed')); else if (subscribed.has(r.rss_url)) row.append(el('span', 'nui-pod-badge', 'abonniert')); else row.append(button('+', 'Abonnieren', () => subscribe(r.rss_url, r.title))); list.append(row); } root.append(list); } async function search(q) { if (!q) return; if (/^https?:\/\//i.test(q)) { subscribe(q, q); return; } searchResults = {q, loading: true}; render(); try { const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(q)); const data = await r.json(); searchResults = {q, items: data.results || [], error: data.error ? 'Die Suche ist gerade nicht erreichbar.' : null}; } catch (e) { searchResults = {q, items: [], error: 'Die Suche braucht eine Verbindung.'}; } render(); } async function subscribe(rssUrl, title) { toast('Wird abonniert …'); try { const r = await fetch('/podcasts/feeds/add/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({rss_url: rssUrl, title}), }); const data = await r.json(); if (!r.ok || !data.ok) throw new Error(data.error || r.status); toast(`„${data.title || title}“ abonniert`); refresh(false); } catch (e) { toast(navigator.onLine ? 'Dieser Feed lässt sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.'); } } // ---- drag to reorder (touch) ----------------------------------------------------------- function dragSort(handle, row, list) { handle.addEventListener('pointerdown', ev => { ev.preventDefault(); ev.stopPropagation(); handle.setPointerCapture(ev.pointerId); const rows = [...list.children]; const from = rows.indexOf(row); const rects = rows.map(r => r.getBoundingClientRect()); const startY = ev.clientY; let to = from; row.classList.add('nui-pod-dragging'); const move = e => { const dy = e.clientY - startY; row.style.transform = `translateY(${dy}px)`; const mid = rects[from].top + rects[from].height / 2 + dy; to = rects.findIndex(r => mid < r.top + r.height / 2); if (to < 0) to = rects.length - 1; rows.forEach((r, i) => { if (r === row) return; let shift = 0; if (from < to && i > from && i <= to) shift = -rects[from].height; if (from > to && i >= to && i < from) shift = rects[from].height; r.style.transform = shift ? `translateY(${shift}px)` : ''; }); }; const end = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', end); handle.removeEventListener('pointercancel', end); rows.forEach(r => { r.style.transform = ''; }); row.classList.remove('nui-pod-dragging'); if (to !== from) { // Show the new order at once; the app's state follows right after. list.insertBefore(row, to > from ? rows[to].nextSibling : rows[to]); N.podcastMove(parseInt(row.dataset.id, 10), to); } }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', end); handle.addEventListener('pointercancel', end); }); } // ---- mini player and full player ----------------------------------------------------------- let mini = null; function buildMini() { const dock = $id('nui-dock'); if (!dock || $id('nui-pod-mini')) return; mini = el('div'); mini.id = 'nui-pod-mini'; mini.hidden = true; mini.innerHTML = '
'; const text = el('button', 'nui-mini-text'); text.type = 'button'; text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track')); text.addEventListener('click', openPlayer); mini.append(art('', 'mini'), text, button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'), button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-mini-toggle')); dock.prepend(mini); // A "Podcasts" button in the bottom bar, between books and radio. const tabbar = $id('nui-tabbar'); if (tabbar && !tabbar.querySelector('[data-tab="podcasts"]')) { const b = el('button'); b.type = 'button'; b.dataset.tab = 'podcasts'; b.append(el('span', 'nui-tab-icon', '◎'), document.createTextNode('Podcasts')); b.addEventListener('click', () => { showTab('podcasts'); scrollTop(0); }); tabbar.insertBefore(b, tabbar.querySelector('[data-tab="radio"]')); } } function renderMini() { const isPod = player.kind === 'podcast'; document.documentElement.classList.toggle('nui-pod-playing', isPod); if (!mini) return; mini.hidden = !isPod; document.documentElement.classList.toggle('nui-pod-mini-open', isPod); if (!isPod) return; mini.querySelector('.nui-mini-station').textContent = player.title || ''; // The back button names the set skip length (checked now and then, it rarely changes). if (!mini.skipChecked || Date.now() - mini.skipChecked > 5000) { mini.skipChecked = Date.now(); const sk = mini.querySelector('.nui-pod-skip'); sk.title = `${devicePrefs().skip_back || 15} s zurück`; sk.setAttribute('aria-label', sk.title); } mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …' : (player.feed || '') + (player.dur ? ' · ' + clock((player.dur - player.pos) / 1000) + ' übrig' : ''); const t = mini.querySelector('.nui-mini-toggle'); t.textContent = player.playing ? '❚❚' : '▶'; t.title = player.playing ? 'Pause' : 'Abspielen'; const a = mini.querySelector('.nui-pod-art img'); const box = mini.querySelector('.nui-pod-art'); const src = (player.artwork || '').replace(/^http:/, 'https:'); if (!a && src) { const img = el('img'); img.alt = ''; img.src = src; box.append(img); } else if (a && a.getAttribute('src') !== src) { if (src) a.src = src; else a.remove(); } mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0'; } let sheet = null; let sheetEpisode = 0; let seeking = false; // ---- chapters and shownotes (server: /podcasts/episodes//) ------------------------ // Fetched per episode when wanted and kept for offline use (the last 40). const details = new Map(); function storedDetails(id) { if (details.has(id)) return details.get(id); try { const d = JSON.parse(localStorage.getItem('nui_pod_ep_' + id) || 'null'); if (d) details.set(id, d); return d; } catch (e) { return null; } } function keepDetails(id, d) { details.set(id, d); try { const ids = JSON.parse(localStorage.getItem('nui_pod_ep_ids') || '[]').filter(x => x !== id); ids.push(id); while (ids.length > 40) localStorage.removeItem('nui_pod_ep_' + ids.shift()); localStorage.setItem('nui_pod_ep_' + id, JSON.stringify(d)); localStorage.setItem('nui_pod_ep_ids', JSON.stringify(ids)); } catch (e) {} } async function loadDetails(id) { try { const r = await fetch(`/podcasts/episodes/${id}/`); if (!r.ok) throw new Error(r.status); const d = await r.json(); keepDetails(id, d); return d; } catch (e) { return storedDetails(id); } } // Timestamps in shownotes ("12:34", "1:02:03") become links that jump there. const STAMP = /\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b/g; function linkStamps(root) { const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodes = []; while (walker.nextNode()) { const n = walker.currentNode; if (!n.parentElement.closest('a') && STAMP.test(n.nodeValue)) nodes.push(n); STAMP.lastIndex = 0; } for (const n of nodes) { const frag = document.createDocumentFragment(); let last = 0; n.nodeValue.replace(STAMP, (m, h, mi, se, at) => { const secs = (parseInt(h || '0', 10) * 3600) + parseInt(mi, 10) * 60 + parseInt(se, 10); if (parseInt(se, 10) > 59) return m; frag.append(document.createTextNode(n.nodeValue.slice(last, at))); const a = el('a', 'nui-pod-stamp', m); a.href = '#'; a.dataset.t = String(secs); frag.append(a); last = at + m.length; return m; }); frag.append(document.createTextNode(n.nodeValue.slice(last))); n.replaceWith(frag); } } // Chapters and shownotes into a box; with `live`, taps jump in the playing episode. async function showDetails(box, id, live) { box.textContent = ''; box.dataset.episode = String(id); let d = storedDetails(id); if (!d) box.append(el('div', 'nui-pod-meta', 'Shownotes werden geladen …')); const fresh = await loadDetails(id); if (box.dataset.episode !== String(id)) return; d = fresh || d; box.textContent = ''; if (!d) { box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.')); return; } if (live && sheet) { const fav = sheet.querySelector('.nui-pod-fav'); if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); } } const chapters = d.chapters || []; if (live && chapters.length > 1) { // Previous / next chapter, like AntennaPod's chapter buttons. const nav = el('div', 'nui-pod-chapnav'); const jump = dir => { const t = (player.pos || 0) / 1000; let i = chapters.length - 1; while (i > 0 && chapters[i].start > t + 0.5) i--; // "Back" within the first seconds of a chapter goes to the one before. const target = dir > 0 ? i + 1 : (t - chapters[i].start < 3 ? i - 1 : i); if (target < 0 || target >= chapters.length) return; N.podcastSeek(Math.round(chapters[target].start * 1000)); }; nav.append(button('⏮', 'Voriges Kapitel', () => jump(-1)), el('span', 'nui-pod-chapnow'), button('⏭', 'Nächstes Kapitel', () => jump(1))); box.append(nav); } const tabs = el('div', 'nui-pod-subtabs'); const body = el('div', 'nui-pod-notesbody'); const show = which => { tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which)); body.textContent = ''; if (which === 'chapters') { const ol = el('ol', 'nui-pod-chapters'); chapters.forEach((c, i) => { const li = el('li', 'nui-pod-chapter'); li.dataset.start = String(c.start || 0); li.dataset.index = String(i); li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`))); if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000))); ol.append(li); }); body.append(ol); markChapter(box, (player.pos || 0) / 1000); } else if (which === 'transcript') { showTranscript(body, id, live); } else { const notes = el('div', 'nui-pod-notes'); notes.innerHTML = d.description || '

Keine Shownotes.

'; notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank')); if (live) { linkStamps(notes); notes.addEventListener('click', ev => { const a = ev.target.closest('a.nui-pod-stamp'); if (!a) return; ev.preventDefault(); N.podcastSeek(parseInt(a.dataset.t, 10) * 1000); }); } body.append(notes); } }; const tab = (which, label) => { const b = el('button', 'nui-pod-subtab', label); b.type = 'button'; b.dataset.which = which; b.addEventListener('click', () => show(which)); tabs.append(b); }; if (chapters.length) tab('chapters', `Kapitel (${chapters.length})`); tab('notes', 'Shownotes'); if (d.has_transcript) tab('transcript', 'Transkript'); box.append(tabs, body); show(chapters.length ? 'chapters' : 'notes'); } function markChapter(scope, seconds) { const items = scope.querySelectorAll('.nui-pod-chapter'); let current = -1; items.forEach((li, i) => { if (parseFloat(li.dataset.start) <= seconds + 0.5) current = i; }); items.forEach((li, i) => li.classList.toggle('current', i === current)); const now = scope.querySelector('.nui-pod-chapnow'); if (now) { const d = storedDetails(parseInt((scope.querySelector('.nui-pod-notesbox') || scope).dataset.episode, 10)); const ch = d && d.chapters ? d.chapters.filter(c => c.start <= seconds + 0.5).pop() : null; now.textContent = ch ? plain(ch.title || '') : ''; } markTranscript(scope, seconds); } // ---- transcript: follows the playback, a tap jumps there -------------------------------- const transcripts = new Map(); let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while async function showTranscript(body, id, live) { body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …')); let t = transcripts.get(id); if (!t) { try { const r = await fetch(`/podcasts/episodes/${id}/transcript/`); if (!r.ok) throw new Error(r.status); t = await r.json(); transcripts.set(id, t); } catch (e) { body.textContent = ''; body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Für diese Folge gibt es kein lesbares Transkript.' : 'Das Transkript braucht eine Verbindung.')); return; } } body.textContent = ''; const list = el('div', 'nui-pod-transcript'); for (const seg of t.segments || []) { const p = el('p', 'nui-pod-seg'); p.dataset.start = String(seg.start || 0); if (seg.speaker) p.append(el('strong', 'nui-pod-speaker', seg.speaker + ': ')); p.append(document.createTextNode(seg.text || '')); if (live) p.addEventListener('click', () => N.podcastSeek(Math.round((seg.start || 0) * 1000))); list.append(p); } list.addEventListener('touchstart', () => { transcriptTouched = Date.now(); }, {passive: true}); body.append(list); markTranscript(body, (player.pos || 0) / 1000); } function markTranscript(scope, seconds) { const segs = scope.querySelectorAll('.nui-pod-seg'); if (!segs.length) return; let current = null; segs.forEach(p => { if (parseFloat(p.dataset.start) <= seconds + 0.3) current = p; }); segs.forEach(p => p.classList.toggle('current', p === current)); if (current && current.dataset.shown !== '1' && Date.now() - transcriptTouched > 8000) { segs.forEach(p => { p.dataset.shown = ''; }); current.dataset.shown = '1'; current.scrollIntoView({block: 'center', behavior: 'smooth'}); } } // Shownotes of an episode that isn't playing: its own sheet. let notesSheet = null; function openNotes(e) { closeNotes(); notesSheet = el('div'); notesSheet.id = 'nui-pod-player'; notesSheet.className = 'nui-pod-notes-sheet'; const panel = el('div', 'nui-pod-player-panel'); const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Schließen', closeNotes)); panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || '')); const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen'); play1.type = 'button'; play1.addEventListener('click', () => { closeNotes(); play(e); }); panel.append(play1); const box = el('div', 'nui-pod-notesbox'); panel.append(box); notesSheet.append(panel); notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); }); document.body.append(notesSheet); showDetails(box, e.id, false); } function closeNotes() { if (!notesSheet) return false; notesSheet.remove(); notesSheet = null; return true; } function openPlayer() { if (player.kind !== 'podcast') return; closePlayer(); sheet = el('div'); sheet.id = 'nui-pod-player'; sheet.innerHTML = ''; const panel = sheet.firstChild; const top = el('div', 'nui-pod-player-top'); top.append(button('⌄', 'Player schließen', closePlayer)); panel.append(top, art(player.artwork, 'player')); panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed')); const range = el('input', 'nui-pod-seek'); range.type = 'range'; range.min = 0; range.step = 1; range.id = 'nui-pod-seek'; range.addEventListener('input', () => { seeking = true; sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value); }); range.addEventListener('change', () => { N.podcastSeek(range.value * 1000); seeking = false; }); const times = el('div', 'nui-pod-times'); times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left')); panel.append(range, times); const prefs = devicePrefs(); const back = prefs.skip_back || 15; const fwd = prefs.skip_forward || 30; const controls = el('div', 'nui-pod-controls'); controls.append( button(String(back), `${back} Sekunden zurück`, () => N.podcastSkip(false), 'nui-pod-back'), button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-pod-play'), button(String(fwd), `${fwd} Sekunden vor`, () => N.podcastSkip(true), 'nui-pod-fwd'), ); panel.append(controls); const extra = el('div', 'nui-pod-extra'); extra.append( button('1×', 'Geschwindigkeit', speedMenu, 'nui-pod-speed'), button('☾', 'Sleep-Timer', sleepMenu, 'nui-pod-sleep'), button('☆', 'Favorit', () => toggleFavorite({id: player.episodeId}, star => { const b = sheet && sheet.querySelector('.nui-pod-fav'); if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); } }), 'nui-pod-fav'), button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'), button('≡', 'Warteschlange', () => { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); }), ); panel.append(extra); const notes = el('div', 'nui-pod-notesbox'); panel.append(notes); sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); document.body.append(sheet); sheetEpisode = player.episodeId; showDetails(notes, player.episodeId, true); renderPlayer(); } function closePlayer() { if (!sheet) return false; sheet.remove(); sheet = null; return true; } function renderPlayer() { if (!sheet) return; if (player.kind !== 'podcast') { closePlayer(); return; } if (sheetEpisode !== player.episodeId) { // The next episode started while the player was open. sheetEpisode = player.episodeId; showDetails(sheet.querySelector('.nui-pod-notesbox'), player.episodeId, true); } markChapter(sheet, (player.pos || 0) / 1000); sheet.querySelector('.nui-pod-player-title').textContent = player.title || ''; sheet.querySelector('.nui-pod-player-feed').textContent = player.feed || ''; const range = sheet.querySelector('.nui-pod-seek'); const dur = Math.round((player.dur || 0) / 1000); const pos = Math.round((player.pos || 0) / 1000); range.max = dur || 1; if (!seeking) { range.value = pos; sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos); } sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : ''; const p = sheet.querySelector('.nui-pod-play'); p.textContent = player.playing ? '❚❚' : '▶'; p.title = player.playing ? 'Pause' : 'Abspielen'; const sp = sheet.querySelector('.nui-pod-speed'); sp.textContent = speedLabel(player.speed || 1) + (player.podcastSpeed ? '•' : ''); sp.title = player.podcastSpeed ? 'Geschwindigkeit (eigene für diesen Podcast)' : 'Geschwindigkeit'; const sl = sheet.querySelector('.nui-pod-sleep'); sl.textContent = sleepLabel() || '☾'; sl.classList.toggle('on', !!sleepLabel()); const img = sheet.querySelector('.nui-pod-art-player img'); const src = (player.artwork || '').replace(/^http:/, 'https:'); if (img && src && img.getAttribute('src') !== src) img.src = src; } function speedLabel(s) { return String(Math.round(s * 100) / 100).replace('.', ',') + '×'; } // Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast. function speedMenu() { const own = !!player.podcastSpeed; const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts', SPEEDS.map(s => ({ icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○', label: speedLabel(s), action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)), })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : [])); if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; } openSheet('Geschwindigkeit', [ {icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)}, {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene', action: () => pick(true)}, ]); } // ---- sleep timer (runs in the app, also with the screen off) ---- function sleepLabel() { const t = player.sleep || {}; if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`; if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`; return ''; } // Minutes count only while playing; shaking the phone starts them over. function sleepMenu() { if (!N.podcastSleep) return; const t = player.sleep || {}; const on = !!sleepLabel(); const items = []; if (on) { items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)}); if (t.leftMs > 0 && N.podcastSleepExtend) { items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)}); items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)}); } } for (const m of [5, 10, 15, 30, 45, 60, 90]) { items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)}); } items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); if (N.podcastSleepAuto) { items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)', hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus', action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }}); } openSheet('Sleep-Timer', items); } function finishAndNext() { const id = currentId(); if (!id) return; N.podcastNext(); N.podcastMarkPlayed(id, true); } // ---- downloads (Downloads.kt) ------------------------------------------------------------ // Episodes kept on the phone to play without a connection. Each episode row // gets a mark (↓ fetch it, a ring while it loads, ✓ when it's here), the ⋯ // menus and the settings sheet get their entries. The app downloads the // first episodes of the queue by itself and tells us via __dioraDownloads. const DL = !!N.podcastDownloads; let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; const DL_COUNTS = [1, 2, 3, 5, 10]; // The limit and the clean-up the AntennaPod way (Downloads.kt, DownloadPlan.toClean). const DL_LIMITS = [5, 10, 25, 50, 100, 500, 0]; const DL_CLEANUPS = [ {value: 'queue', label: 'Wenn Platz gebraucht wird', hint: 'beim Limit gehen Folgen, die nicht in der Warteschlange stehen: gehörte zuerst'}, {value: 'never', label: 'Nie', hint: 'beim Limit lädt die App nichts mehr von selbst'}, ]; const dlLimitLabel = n => (n ? n + ' Folgen' : 'Unbegrenzt'); function size(bytes) { if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB'; return Math.max(1, Math.round((bytes || 0) / 1048576)) + ' MB'; } function dlStart(e) { N.podcastDownload(JSON.stringify(e.raw)); window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0}})); toast('Wird heruntergeladen'); } function dlDelete(e, running) { N.podcastDeleteDownload(e.id); toast(running ? 'Download abgebrochen' : 'Download gelöscht'); } function dlAppend(row, e) { if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return; const d = downloads[e.id]; const state = d ? d.state : null; let b; if (state === 'done') { b = button('✓', 'Heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-done'); } else if (state === 'queued' || state === 'downloading') { b = button('', state === 'queued' ? 'Wartet auf den Download' : 'Wird heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-ring'); b.append(el('span')); } else { b = button('↓', state === 'failed' ? 'Download hat nicht geklappt — nochmal' : 'Herunterladen', () => dlStart(e), 'nui-pod-dl' + (state === 'failed' ? ' nui-pod-dl-failed' : '')); } b.dataset.dl = e.id; dlRing(b); row.append(b); } function dlRing(b) { const d = downloads[b.dataset.dl]; if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return; b.style.setProperty('--p', (d.progress || 0) + '%'); b.firstChild.textContent = d.state === 'queued' ? '…' : d.progress + ''; } function dlMenuItems(e) { if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return []; const d = downloads[e.id]; if (!d || d.state === 'failed') { return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung', action: () => dlStart(e)}]; } if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}]; return [{icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? 'wartet noch' : d.progress + ' %', action: () => dlDelete(e, true)}]; } function dlSet(changes) { Object.assign(dlSettings, changes); N.podcastSetDownloadSettings(JSON.stringify(changes)); settingsMenu(); } function dlSettingsItems() { if (!DL) return []; const s = dlSettings = parse(N.podcastDownloadSettings(), dlSettings); const done = Object.values(downloads).filter(d => d.state === 'done'); const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0); return [ {icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen', hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})}, s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen', DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n, action: () => dlSet({count: n})})))}, s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN', action: () => dlSet({mobile: !s.mobile})}, {icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen', hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten', action: () => dlSet({deletePlayed: !s.deletePlayed})}, s.limit != null && {icon: '▦', label: 'Höchstens behalten', hint: dlLimitLabel(s.limit) + ` · jetzt ${Object.values(downloads).filter(d => d.state !== 'failed').length}`, action: () => openSheet('Höchstens so viele Folgen auf dem Telefon', DL_LIMITS.map(n => ({ icon: s.limit === n ? '●' : '○', label: dlLimitLabel(n), active: s.limit === n, hint: n === 25 ? 'Standard' : null, action: () => dlSet({limit: n})})))}, 'enqueue' in s && {icon: s.enqueue ? '☑' : '☐', label: 'Heruntergeladene Folgen einreihen', hint: s.enqueue ? 'an: was geladen wird, kommt in die Warteschlange' : 'aus', action: () => dlSet({enqueue: !s.enqueue})}, s.cleanup && {icon: '♻', label: 'Aufräumen', hint: (DL_CLEANUPS.find(c => c.value === s.cleanup) || DL_CLEANUPS[0]).label, action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({ icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value, action: () => dlSet({cleanup: c.value})})))}, done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, danger: true, hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen', action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [ {icon: '✕', label: 'Löschen', hint: size(bytes), danger: true, action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }}, {icon: '‹', label: 'Abbrechen', action: () => {}}, ])}, ]; } window.__dioraDownloads = function (map) { const before = downloads; downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {}; const keys = Object.keys(downloads); const same = keys.length === Object.keys(before).length && keys.every(id => before[id] && before[id].state === downloads[id].state); // Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going). if (same) document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing); else render(); }; // ---- hooks from the app --------------------------------------------------------------- window.__dioraPodcasts = function (json) { state = typeof json === 'string' ? parse(json, state) : json; render(); }; window.__dioraPodcastsRefreshed = function (ok) { refreshing = false; if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand'); render(); }; window.__dioraPlayer = function (p) { const before = player; player = p || {kind: null}; renderMini(); renderPlayer(); // The current episode changed: the queue view marks it. if (before.episodeId !== player.episodeId || before.kind !== player.kind) render(); }; // The app loads this in ; the browser may load it after the page is parsed. function start(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); } start(() => { if (typeof showTab !== 'function') return; // The web's podcast tab loads its own lists; the app has them already. window.loadPodcastTab = function () {}; window.refreshAllFeeds = async function () {}; build(); buildMini(); player = parse(N.playerState(), {kind: null}); render(); renderMini(); const back = window.__dioraBack; window.__dioraBack = function () { if (closeStats()) return true; if (closeNotes()) return true; if (closePlayer()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true; if (back && back()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts') { if (feedView) { feedView = null; render(); return true; } showTab('books'); return true; } return false; }; // Pull the latest state once per start; the server has done the fetching. N.podcastRefresh(false); }); })();