diora-web/static/podcasts/native-podcast.js
mrwnslz 3eb6a27ec3 Player-Sync: ein Player für alle Geräte (wie Spotify Connect)
Server: PlayerState und PlayerDevice (Migration 0012), /podcasts/player/
(Stand), report/ (das Gerät, das zu spielen anfängt, wird das aktive und
bleibt es auch pausiert, solange es online ist), command/ (Fernbedienung,
transfer auf ein anderes Gerät) und stream/ (Server-Sent Events, zugleich
die Anwesenheit der Geräte). seq steigt in einem UPDATE und nie zurück.

Browser: podcasts-web.js meldet Abspielen, Pause, Springen, Folgen- und
Senderwechsel (alle 10 s beim Spielen), hört den Stream, hört auf, wenn ein
anderes Gerät startet, führt Befehle aus, zeigt das andere Gerät als remote
und übernimmt, was ein verschwundenes Gerät zuletzt hatte. Eine pausierte
Folge folgt einem neueren Stand von anderswo; im Vordergrund wird nach
15 s neu geholt. Podcast-Seite neu kopiert, sw v58.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 15:18:38 +02:00

2488 lines
109 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copied from diora-android 38d03c6 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 (&amp;).
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) {
const r = remote();
if (r ? r.kind === 'podcast' && r.episodeId === e.id : 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 && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads},
DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, 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, dlIndicator(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)),
button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean));
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);
const offer = importOffer();
if (offer) root.append(offer);
if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox();
else if (view === 'episodes') renderEpisodes();
else if (view === 'feeds') renderFeeds();
else renderSearch();
}
scrollTop(scroll);
}
// AntennaPod had episodes on the phone: offered once to fetch them again (Wi-Fi only).
function importOffer() {
const o = state.import_downloads;
if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null;
const box = el('div', 'nui-pod-offer');
const s = parse(N.podcastDownloadSettings(), {});
const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? s.count || 0 : 0);
const raise = s.limit > 0 && needed > s.limit
? ` „Höchstens behalten“ geht dafür von ${s.limit} auf ${[5, 10, 25, 50, 100, 500].find(n => n >= needed) || 'unbegrenzt'}.` : '';
box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` +
(o.bytes ? ` (${size(o.bytes)})` : '') + '. Hier noch einmal laden? Nur im WLAN; sie kommen nicht in die Warteschlange.' + raise));
const row = el('div', 'nui-pod-offer-row');
row.append(button('Laden', 'Im WLAN herunterladen', () => { N.podcastImportDownloads(true); box.remove(); }, 'btn'),
button('Nein danke', 'Nicht laden', () => { N.podcastImportDownloads(false); box.remove(); }, 'btn'));
box.append(row);
return box;
}
window.__dioraImportDownloads = function (r) {
if (!r || !r.ok) toast('Die Liste aus AntennaPod kam nicht an — später noch einmal');
else if (r.count) toast(`${r.count} ${r.count === 1 ? 'Folge wird' : 'Folgen werden'} im WLAN geladen` +
(r.limit != null ? ` · Höchstens behalten: ${r.limit || 'unbegrenzt'}` : ''));
render();
};
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/<id>/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 = '<div class="nui-pod-mini-bar"><span></span></div>';
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', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true;
mini.append(art('', 'mini'), text,
button('↺', '15 s zurück', () => (miniKind === 'remote'
? remoteCmd('skip', -(devicePrefs().skip_back || 15)) : N.podcastSkip(false)), 'nui-pod-skip'),
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
devices, here,
button('▶', 'Abspielen', () => (miniKind === 'remote' ? remoteToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
dock.prepend(mini);
// One player for podcasts and radio: the radio's own bar steps aside.
document.documentElement.classList.add('nui-one-player');
// 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"]'));
}
}
// What the bar shows: 'podcast', 'radio' (a station is chosen), 'remote'
// (another device is the active one) or ''.
let miniKind = '';
function radioNow() {
return typeof window.__dioraRadioNow === 'function' ? window.__dioraRadioNow() : null;
}
function setArt(box, src) {
const img = box.querySelector('img');
src = (src || '').replace(/^http:/, 'https:');
if (!img && src) {
const i = el('img');
i.alt = '';
i.src = src;
i.addEventListener('error', () => i.remove());
box.append(i);
} else if (img && img.getAttribute('src') !== src) {
if (src) img.src = src; else img.remove();
}
}
function renderMini() {
const r = remote();
const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
if (!mini) return;
const radio = isPod || r ? null : radioNow();
miniKind = r ? 'remote' : isPod ? 'podcast' : radio ? 'radio' : '';
mini.hidden = !miniKind;
mini.classList.toggle('nui-mini-radio', miniKind === 'radio' || (!!r && r.kind === 'radio'));
mini.classList.toggle('nui-mini-remote', !!r);
document.documentElement.classList.toggle('nui-pod-mini-open', !!miniKind);
mini.querySelector('.nui-pod-skip').hidden = !(isPod || r) || (!!r && r.kind !== 'podcast');
mini.querySelector('.nui-mini-save').hidden = miniKind !== 'radio' || radio.saved;
mini.querySelector('.nui-mini-devices').hidden = !r || !N.playerTransfer;
mini.querySelector('.nui-mini-here').hidden = !r || !N.playerTakeOver;
if (r) {
const onRadio = r.kind === 'radio';
mini.querySelector('.nui-mini-station').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
mini.querySelector('.nui-mini-track').textContent = remoteLine(r);
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
t.title = r.playing ? (onRadio ? 'Stopp' : 'Pause') + ' auf ' + remoteName(r) : 'Abspielen auf ' + remoteName(r);
setArt(mini.querySelector('.nui-pod-art'), onRadio ? r.station && r.station.logo : r.artwork);
mini.querySelector('.nui-pod-mini-bar span').style.width = onRadio ? (r.playing ? '100%' : '0')
: r.dur ? Math.min(100, r.pos / r.dur * 100) + '%' : '0';
return;
}
if (miniKind === 'radio') {
mini.querySelector('.nui-mini-station').textContent = radio.name;
mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …'
: radio.playing ? (radio.track || '● Live') : 'Angehalten';
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = radio.playing ? '■' : '▶';
t.title = radio.playing ? 'Stopp' : 'Abspielen';
setArt(mini.querySelector('.nui-pod-art'), radio.logo);
mini.querySelector('.nui-pod-mini-bar span').style.width = radio.playing ? '100%' : '0';
return;
}
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';
setArt(mini.querySelector('.nui-pod-art'), player.artwork);
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/<id>/) ------------------------
// 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 || '<p>Keine Shownotes.</p>';
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;
}
// The full player: one for both. Which one it shows now.
let sheetKind = '';
function openPlayer() {
if (remote()) { openRemotePlayer(); return; }
if (player.kind !== 'podcast') return;
closePlayer();
sheetKind = 'podcast';
sheet = el('div');
sheet.id = 'nui-pod-player';
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
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'),
N.playerTransfer && button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'),
button('≡', 'Warteschlange', showQueue),
].filter(Boolean));
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;
}
// The radio in the same player: the station instead of the episode, "Live"
// instead of the seek bar, save / stop / sleep timer as the buttons.
function openRadioPlayer() {
const r = radioNow();
if (!r) return;
closePlayer();
sheetKind = 'radio';
sheet = el('div');
sheet.id = 'nui-pod-player';
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Radio"></div>';
const panel = sheet.firstChild;
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Player schließen', closePlayer));
const cover = art(r.logo, 'player');
cover.classList.add('nui-radio-art');
panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'),
el('div', 'nui-radio-live'));
const controls = el('div', 'nui-pod-controls');
controls.append(
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-pod-fav nui-radio-save'),
button('▶', 'Abspielen', () => window.__dioraRadioToggle && window.__dioraRadioToggle(), 'nui-pod-play'),
button('☾', 'Sleep-Timer', () => sleepMenu(true), 'nui-pod-sleep'),
);
const extra = el('div', 'nui-pod-extra');
const stations = el('button', 'btn nui-radio-stations', 'Sender wechseln');
stations.type = 'button';
stations.addEventListener('click', () => { closePlayer(); showTab('radio'); });
extra.append(stations);
if (N.playerTransfer) extra.append(devicesButton());
panel.append(controls, extra);
sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
document.body.append(sheet);
renderRadioPlayer();
}
function renderRadioPlayer() {
const r = radioNow();
if (!r) { closePlayer(); return; }
sheet.querySelector('.nui-pod-player-title').textContent = r.name;
sheet.querySelector('.nui-pod-player-feed').textContent = r.track || '';
const live = sheet.querySelector('.nui-radio-live');
live.textContent = r.buffering ? 'Verbinde …' : r.playing ? '● Live' : 'Angehalten';
live.classList.toggle('on', r.playing && !r.buffering);
const p = sheet.querySelector('.nui-pod-play');
p.textContent = r.playing ? '■' : '▶';
p.title = r.playing ? 'Stopp' : 'Abspielen';
const s = sheet.querySelector('.nui-radio-save');
s.textContent = r.saved ? '★' : '☆';
s.classList.toggle('on', r.saved);
s.disabled = r.saved;
s.title = r.saved ? 'Gespeichert' : 'Sender speichern';
const sl = sheet.querySelector('.nui-pod-sleep');
sl.textContent = sleepLabel() || '☾';
sl.classList.toggle('on', !!sleepLabel());
setArt(sheet.querySelector('.nui-pod-art-player'), r.logo);
}
// native-radio.js: the station, its track or its state changed.
window.__dioraRadioChanged = function () {
renderMini();
if (sheet && sheetKind === 'radio') renderRadioPlayer();
};
window.__dioraOpenPlayer = kind => (kind === 'radio' && !remote() ? openRadioPlayer() : openPlayer());
function renderPlayer() {
if (!sheet) return;
const r = remote();
if (sheetKind === 'remote') {
// Back here (taken over) or the other device went away: this device's player.
if (!r) {
closePlayer();
if (player.kind === 'podcast') openPlayer(); else if (radioNow()) openRadioPlayer();
} else if (sheet.dataset.remote !== remoteKey(r)) {
openRemotePlayer();
} else {
renderRemotePlayer();
}
return;
}
if (r) { openRemotePlayer(); return; }
if (sheetKind === 'radio') {
if (player.kind === 'podcast') closePlayer(); else renderRadioPlayer();
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(radio) {
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)});
}
if (radio !== true) {
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);
}
function showQueue() {
closePlayer();
view = 'queue';
feedView = null;
showTab('podcasts');
render();
}
// ---- another device plays (player sync, like Spotify Connect) ------------------------------
// The host (Bridge.kt / podcasts-web.js) shares one player among the user's
// devices. While another device is the active one (online, playing or
// paused), player.remote is what it plays: the bar and the player show it,
// their buttons control it (N.playerRemote), "Hier abspielen" plays it here
// from its place, and the device sheet moves it to any device that is online.
function remote() {
return player.remote && N.playerRemote ? player.remote : null;
}
function remoteName(r) {
return r.device || 'einem anderen Gerät';
}
function remoteKey(r) {
return r.kind + ':' + (r.kind === 'radio' ? (r.station && r.station.url) || '' : r.episodeId);
}
function remoteLine(r) {
return (r.playing ? '▸ läuft auf ' : '‖ pausiert auf ') + remoteName(r);
}
function remoteCmd(action, value) {
if (N.playerRemote) N.playerRemote(action, Math.round(value || 0));
}
function remoteToggle() {
const r = remote();
if (r) remoteCmd(r.playing ? 'pause' : 'play');
}
function takeOver() {
if (N.playerTakeOver) N.playerTakeOver();
}
// Which device plays: the ones online, this one first; choosing one moves the playback there.
function deviceMenu() {
if (!N.playerTransfer) return;
const all = (player.devices || []).filter(d => d.online || d.me);
const devices = all.filter(d => d.me).concat(all.filter(d => !d.me));
const r = remote();
const radio = r ? null : radioNow();
const activePlays = r ? r.playing : player.kind === 'podcast' ? !!player.playing : !!(radio && radio.playing);
const items = devices.map(d => ({
icon: d.active ? '◉' : '○',
label: d.me ? 'Dieses Gerät' : d.name || 'Unbekanntes Gerät',
hint: d.active ? (activePlays ? 'spielt gerade' : 'pausiert') : d.me ? null : 'hierhin verschieben',
active: !!d.active,
action: () => N.playerTransfer(d.id),
}));
if (devices.length < 2) {
items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote',
'Keine anderen Geräte online. Sobald diora auf einem anderen Gerät offen ist, erscheint es hier.')});
}
openSheet('Wiedergabe auf …', items);
}
function devicesButton() {
const b = el('button', 'btn nui-pod-devbtn', '⇄ Wiedergabe auf …');
b.type = 'button';
b.addEventListener('click', deviceMenu);
return b;
}
// The full player for what another device plays: the same layout, remote buttons.
function openRemotePlayer() {
const r = remote();
if (!r) return;
closePlayer();
sheetKind = 'remote';
const onRadio = r.kind === 'radio';
sheet = el('div');
sheet.id = 'nui-pod-player';
sheet.className = 'nui-pod-remote';
sheet.dataset.remote = remoteKey(r);
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
const panel = sheet.firstChild;
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Player schließen', closePlayer));
const cover = art(onRadio ? r.station && r.station.logo : r.artwork, 'player');
if (onRadio) cover.classList.add('nui-radio-art');
panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed nui-pod-remote-line'));
const controls = el('div', 'nui-pod-controls');
if (onRadio) {
controls.append(button('▶', 'Abspielen', remoteToggle, 'nui-pod-play'));
} else {
panel.append(el('div', 'nui-pod-player-feed nui-pod-remote-feed'));
const range = el('input', 'nui-pod-seek');
range.type = 'range';
range.min = 0;
range.step = 1;
range.addEventListener('input', () => {
seeking = true;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value);
});
range.addEventListener('change', () => {
remoteCmd('seek', 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;
controls.append(
button(String(back), `${back} Sekunden zurück`, () => remoteCmd('skip', -back), 'nui-pod-back'),
button('▶', 'Abspielen', remoteToggle, 'nui-pod-play'),
button(String(fwd), `${fwd} Sekunden vor`, () => remoteCmd('skip', fwd), 'nui-pod-fwd'),
);
}
panel.append(controls);
if (N.playerTakeOver) {
const here = el('button', 'btn nui-pod-here', '⇩ Hier abspielen');
here.type = 'button';
here.addEventListener('click', takeOver);
panel.append(here);
}
const extra = el('div', 'nui-pod-extra');
if (!onRadio) extra.append(button('⏭', 'Nächste Folge', () => remoteCmd('next'), 'nui-pod-next'));
if (N.playerTransfer) extra.append(onRadio ? devicesButton() : button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-pod-devices'));
if (!onRadio) extra.append(button('≡', 'Warteschlange', showQueue));
panel.append(extra);
if (!onRadio && r.episodeId) {
const notes = el('div', 'nui-pod-notesbox');
panel.append(notes);
showDetails(notes, r.episodeId, false);
}
sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
document.body.append(sheet);
renderRemotePlayer();
}
function renderRemotePlayer() {
const r = remote();
if (!r || !sheet) return;
const onRadio = r.kind === 'radio';
sheet.querySelector('.nui-pod-player-title').textContent = (onRadio ? r.station && r.station.name : r.title) || '';
sheet.querySelector('.nui-pod-remote-line').textContent = remoteLine(r);
const p = sheet.querySelector('.nui-pod-play');
p.textContent = r.playing ? (onRadio ? '■' : '❚❚') : '▶';
p.title = (r.playing ? (onRadio ? 'Stopp' : 'Pause') : 'Abspielen') + ' auf ' + remoteName(r);
if (onRadio) return;
sheet.querySelector('.nui-pod-remote-feed').textContent = r.feed || '';
const range = sheet.querySelector('.nui-pod-seek');
const dur = Math.round((r.dur || 0) / 1000);
const pos = Math.round((r.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) : '';
markChapter(sheet, pos);
}
// ---- 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(), {}) : {};
// All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round.
let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {};
const DL_COUNTS = [1, 2, 3, 5, 10];
const DL_PARALLEL = [1, 2, 3, 4];
// Why a download waits (DownloadPlan.waitReason).
const DL_REASONS = {
paused: 'pausiert', wifi: 'wartet auf WLAN', offline: 'wartet auf eine Verbindung', queue: 'in der Reihe',
retry: 'neuer Versuch',
};
// 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, title: e.title, reason: 'queue'}}));
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.reason === 'paused' ? '‖' : '…') : d.progress + '';
}
// "wartet auf WLAN", "neuer Versuch um 14:05" …
function dlReason(d) {
if (d.reason === 'retry' && d.retryAt) {
return 'neuer Versuch um ' + new Date(d.retryAt).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'});
}
return DL_REASONS[d.reason] || 'wartet';
}
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 [
d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst &&
{icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)},
{icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? dlReason(d) : 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})},
s.parallel != null && {icon: '⇉', label: 'Gleichzeitig laden', hint: s.parallel === 1 ? '1 Folge' : s.parallel + ' Folgen',
action: () => openSheet('Gleichzeitig laden', DL_PARALLEL.map(n => ({
icon: s.parallel === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.parallel === n,
hint: n === 3 ? 'Standard' : null, action: () => dlSet({parallel: n})})))},
{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, info) {
const before = downloads;
downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {};
if (info) dlInfo = typeof info === 'string' ? parse(info, dlInfo) : info;
const keys = Object.keys(downloads);
const same = keys.length === Object.keys(before).length &&
keys.every(id => before[id] && before[id].state === downloads[id].state && before[id].reason === downloads[id].reason);
// 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);
dlIndicatorText();
} else {
render();
}
renderDownloads();
};
function dlCounts() {
const c = {running: 0, waiting: 0, failed: 0, done: 0, bytes: 0};
for (const d of Object.values(downloads)) {
if (d.state === 'downloading') c.running++;
else if (d.state === 'queued') c.waiting++;
else if (d.state === 'failed') c.failed++;
else if (d.state === 'done') { c.done++; c.bytes += d.bytes || 0; }
}
return c;
}
function dlSummary() {
const c = dlCounts();
const parts = [];
if (c.running) parts.push(c.running + (c.running === 1 ? ' läuft' : ' laufen'));
if (c.waiting) parts.push(c.waiting + (c.waiting === 1 ? ' wartet' : ' warten') + (dlInfo.paused ? ' (pausiert)' : ''));
if (c.failed) parts.push(c.failed + ' fehlgeschlagen');
if (c.done) parts.push(c.done + ' fertig');
return parts.join(' · ') || 'keine';
}
// The top bar's sign while downloads run or wait: "↓ 3/68" (the one loading of all in this round,
// like the notification), "‖ 68" while paused. Opens the overview.
function dlIndicator() {
if (!DL || !N.podcastDownloadsInfo) return null;
const b = button('', 'Downloads', openDownloads, 'nui-pod-dlind');
b.id = 'nui-pod-dlind';
dlIndicatorText(b);
return b;
}
function dlIndicatorText(b) {
b = b || $id('nui-pod-dlind');
if (!b) return;
const c = dlCounts();
const busy = c.running + c.waiting;
b.hidden = !busy;
if (!busy) return;
const total = Math.max(dlInfo.total || 0, busy);
const done = Math.min(dlInfo.done || 0, total);
b.textContent = dlInfo.paused ? '‖ ' + busy : c.running ? `↓ ${Math.min(done + 1, total)}/${total}` : '↓ ' + busy;
b.title = 'Downloads: ' + dlSummary();
b.setAttribute('aria-label', b.title);
}
// ---- the downloads overview: what runs, waits, failed, is done ---------------------------
let dlSheet = null;
let dlBody = null;
function closeDownloads() {
if (!dlSheet) return false;
dlSheet.remove();
dlSheet = dlBody = null;
return true;
}
function openDownloads() {
if (!DL) return;
closeDownloads();
dlSheet = el('div');
dlSheet.id = 'nui-pod-player';
dlSheet.className = 'nui-pod-notes-sheet nui-pod-dls';
const panel = el('div', 'nui-pod-player-panel');
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Schließen', closeDownloads));
dlBody = el('div', 'nui-pod-dls-body');
panel.append(top, el('div', 'nui-pod-player-title', 'Downloads'), dlBody);
dlSheet.append(panel);
dlSheet.addEventListener('click', ev => { if (ev.target === dlSheet) closeDownloads(); });
document.body.append(dlSheet);
renderDownloads(true);
}
function dlTitle(d) {
return plain(d.title) || 'Folge ' + d.id;
}
// The line under the title: how far, why it waits, what went wrong.
function dlStatus(d) {
const of = d.total ? `${size(d.bytes)} von ${size(d.total)}` : size(d.bytes);
if (d.state === 'downloading') return (d.total ? d.progress + ' % · ' : '') + of;
if (d.state === 'failed') return d.error || 'hat nicht geklappt';
const parts = [dlReason(d)];
if (d.bytes) parts.push(of + ' geladen');
if (d.reason === 'retry' && d.error) parts.push(d.error);
return parts.join(' · ');
}
function dlActions(d) {
const items = [];
if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) {
items.push({icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(d.id)});
}
if ((d.state === 'failed' || (d.state === 'queued' && d.error)) && N.podcastRetryDownload) {
items.push({icon: '↻', label: 'Erneut versuchen', hint: d.error || null, action: () => N.podcastRetryDownload(d.id)});
}
items.push(d.state === 'failed'
? {icon: '✕', label: 'Löschen', danger: true, action: () => N.podcastDeleteDownload(d.id)}
: {icon: '✕', label: 'Abbrechen', hint: d.bytes ? 'was schon geladen ist, geht mit' : null, danger: true,
action: () => N.podcastDeleteDownload(d.id)});
return items;
}
function dlRow(d) {
const row = el('div', 'nui-pod-dlrow' + (d.state === 'failed' ? ' nui-pod-dlrow-failed' : ''));
row.dataset.dlrow = d.id;
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', dlTitle(d)));
const f = d.feedId && feeds().find(x => x.id === d.feedId);
const meta = el('div', 'nui-pod-meta nui-pod-dlstatus');
main.append(meta);
if (f) main.append(el('div', 'nui-pod-meta', plain(f.title)));
if (d.state === 'downloading' || (d.state === 'queued' && d.bytes)) {
const bar = el('div', 'nui-pod-progress nui-pod-stat-bar');
bar.append(el('span'));
main.append(bar);
}
row.append(main);
// The likely tap right there; all of them in the row's sheet.
if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) {
row.append(button('⇈', 'Zuerst laden', () => N.podcastDownloadFirst(d.id)));
}
if (d.state === 'failed' && N.podcastRetryDownload) {
row.append(button('↻', 'Erneut versuchen', () => N.podcastRetryDownload(d.id)));
}
row.append(button('✕', d.state === 'failed' ? 'Löschen' : 'Abbrechen', () => N.podcastDeleteDownload(d.id)));
row.addEventListener('click', () => openSheet(dlTitle(d), dlActions(d)));
dlRowNumbers(row, d);
return row;
}
function dlRowNumbers(row, d) {
row.querySelector('.nui-pod-dlstatus').textContent = dlStatus(d);
const fill = row.querySelector('.nui-pod-progress > span');
if (fill) fill.style.width = (d.total ? Math.round(d.bytes / d.total * 100) : 0) + '%';
}
function renderDownloads(force) {
if (!dlBody) return;
const all = Object.keys(downloads).map(id => Object.assign({id: +id}, downloads[id]));
const running = all.filter(d => d.state === 'downloading');
const waiting = all.filter(d => d.state === 'queued').sort((a, b) => (a.order || 0) - (b.order || 0));
const failed = all.filter(d => d.state === 'failed');
const done = all.filter(d => d.state === 'done');
// The same rows as before: only the numbers move (keeps a tap or the scroll going).
const sig = [running, waiting, failed].map(l => l.map(d => `${d.id}:${d.reason || ''}:${d.error || ''}`).join(','))
.join('|') + `|${done.length}|${!!dlInfo.paused}`;
if (!force && sig === dlBody.dataset.sig) {
for (const d of running.concat(waiting)) {
const row = dlBody.querySelector(`[data-dlrow="${d.id}"]`);
if (row) dlRowNumbers(row, d);
}
return;
}
dlBody.dataset.sig = sig;
const panel = dlBody.parentElement;
const scroll = panel.scrollTop;
dlBody.textContent = '';
const head = el('div', 'nui-pod-dls-head');
head.append(el('div', 'nui-pod-summary', dlSummary()));
const tools = el('div', 'nui-pod-offer-row');
if (N.podcastPauseDownloads && (running.length || waiting.length || dlInfo.paused)) {
const paused = !!dlInfo.paused;
tools.append(button(paused ? '▶ Fortsetzen' : '‖ Alle pausieren', paused ? 'Downloads fortsetzen' : 'Alle Downloads pausieren',
() => { dlInfo.paused = !paused; N.podcastPauseDownloads(!paused); renderDownloads(true); }, 'btn'));
}
if (failed.length > 1 && N.podcastRetryDownload) {
tools.append(button('↻ Alle erneut', 'Alle fehlgeschlagenen erneut versuchen', () => N.podcastRetryDownload(0), 'btn'));
}
if (tools.childNodes.length) head.append(tools);
dlBody.append(head);
const section = (title, list) => {
if (!list.length) return;
dlBody.append(el('div', 'nui-pod-dls-sec', `${title} (${list.length})`));
const box = el('div', 'nui-pod-list');
for (const d of list) box.append(dlRow(d));
dlBody.append(box);
};
section('Läuft', running);
section('Wartet', waiting);
section('Fehlgeschlagen', failed);
if (done.length) {
dlBody.append(el('div', 'nui-pod-dls-sec', 'Fertig'));
dlBody.append(el('div', 'nui-pod-meta nui-pod-dls-done',
`${done.length} ${done.length === 1 ? 'Folge' : 'Folgen'} auf dem Telefon · ${size(done.reduce((n, d) => n + (d.bytes || 0), 0))}`));
}
if (!all.length) dlBody.append(el('div', 'nui-pod-empty', 'Keine Downloads. Beim ↓ einer Folge lädst du sie aufs Telefon.'));
panel.scrollTop = scroll;
}
// ---- 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 <head>; 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 (closeDownloads()) 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);
});
})();