diora-web/static/podcasts/native-podcast.js
mrwnslz b7a81f3e26
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 6m55s
Neuer Tab „Ambient“: Drift, ein generativer Ambient-Synthesizer; sw v73
- static/ambient/index.html: Drift (aus der Session "Ambient-Synthesizer-Webapp",
  eine Datei, Web Audio, offline), eingebettet per iframe (?embed=1&bg=#000)
- app.js: Tab 'ambient', iframe lädt beim ersten Öffnen und füllt den Platz bis
  zur Leiste; Radio/Podcast und Ambient wechseln sich ab (postMessage
  {type:'ambient', cmd|state})
- sw.js: Ambient im Offline-Cache (ignoreSearch für die Shell-Dateien)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 23:08:57 +02:00

4362 lines
198 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 d323614 2026-10-03: 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);
// The tabs; the search is a view of its own, opened from the top bar.
const VIEWS = [
{id: 'queue', label: 'Warteschlange'},
{id: 'inbox', label: 'Neu'},
{id: 'episodes', label: 'Folgen'},
{id: 'feeds', label: 'Abos'},
];
const VIEW_IDS = VIEWS.map(v => v.id).concat('search');
// The app's start page (this device; native.js opens it, the browser host too).
const IN_APP = !window.DioraPodcastHost;
const START_PAGES = [
!IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'},
{id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null},
{id: 'queue', label: 'Podcasts: Warteschlange'},
{id: 'inbox', label: 'Podcasts: Neu'},
{id: 'radio', label: 'Radio'},
].filter(Boolean);
function startPage() {
let v = null;
try { v = localStorage.getItem('nui_start_page'); } catch (e) {}
return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id;
}
const POSITIONS = [
{value: 'back', label: 'Ans Ende'},
{value: 'front', label: 'An den Anfang'},
{value: 'after_current', label: 'Nach der laufenden Folge'},
];
// The speed menu's choices while the account has no presets of its own (settings().speed_presets).
const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2];
const MIN_SPEED = 0.5;
const MAX_SPEED = 3.5;
const MAX_PRESETS = 12;
// Skip lengths back and forward (seconds), as AntennaPod offers them.
const SKIPS = [5, 10, 15, 20, 30, 45, 60];
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 preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing
let previewSeq = 0;
let refreshing = false;
let swipingSince = 0; // a row is being swiped (since then): render waits (rowGestures)
let renderAfterSwipe = 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) {}
// Opening into the podcasts (start page): that list.
if (startPage() === 'queue' || startPage() === 'inbox') view = startPage();
if (!VIEW_IDS.includes(view)) view = 'queue';
const queue = () => state.queue || [];
const inbox = () => state.inbox || [];
// All new episodes: the server sends the first ones (inbox_limit) and how many there are
// (inbox_total); what left „Neu“ here since then counts off.
function inboxTotal() {
const n = inbox().length;
const total = state.inbox_total;
if (typeof total !== 'number') return n;
const sent = typeof state.inbox_limit === 'number' ? Math.min(total, state.inbox_limit) : n;
return Math.max(n, total - Math.max(0, sent - n));
}
const feeds = () => state.feeds || [];
const settings = () => state.settings || {};
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
// The account's speed presets (null there: the default list).
const presets = () => {
const p = settings().speed_presets;
return Array.isArray(p) && p.length ? p.slice().sort((a, b) => a - b) : SPEEDS;
};
// Skipping to the next episode keeps the skipped one (AntennaPod's "keep skipped episodes").
const keepSkipped = () => settings().keep_skipped !== false;
// "Zeiten an das Tempo anpassen": lengths and remaining times as long as they take at the speed.
const bySpeed = () => !!settings().times_by_speed;
let prefsSeen = null;
let prefsAt = 0;
function speedOf(feedId) {
if (!prefsSeen || Date.now() - prefsAt > 2000) { prefsSeen = devicePrefs(); prefsAt = Date.now(); }
const f = feedId ? feeds().find(x => x.id === feedId) : null;
return f && f.playback_speed > 0 ? f.playback_speed : prefsSeen.speed > 0 ? prefsSeen.speed : 1;
}
// Seconds as shown: at the podcast's speed (or `speed`), when so set.
function shown(seconds, feedId, speed) {
if (!bySpeed()) return seconds;
const s = speed > 0 ? speed : speedOf(feedId);
return s > 0 ? seconds / s : seconds;
}
// ---- 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;
}
// Line icons in the text's colour (black on e-ink): deleting is a bin, taking out of a
// list a list with an x — the same in menus, swipes, the selection bar and the undo bar.
const SVG_PATHS = {
trash: '<path d="M4 7h16M9.5 7V4.5h5V7M6.5 7l1 12.5h9l1-12.5M10 11v5.5M14 11v5.5"/>',
listx: '<path d="M4 6h11M4 11h7M4 16h6M14.5 13.5l5.5 5.5M20 13.5l-5.5 5.5"/>',
};
function svgIcon(name, size) {
const span = el('span', 'nui-svg');
const px = size || 20;
span.innerHTML = `<svg viewBox="0 0 24 24" width="${px}" height="${px}" aria-hidden="true" fill="none" ` +
`stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${SVG_PATHS[name]}</svg>`;
return span;
}
// The sheets (native-ui.js, or the browser host's copy) show text icons; an item's `svg`
// replaces its icon afterwards (the rows come in the items' order).
function openSheet(title, items) {
if (!(window.DioraUI && DioraUI.openSheet)) return;
DioraUI.openSheet(title, items);
const shown = items.filter(it => it && !it.custom);
if (!shown.some(it => it.svg)) return;
const rows = document.querySelectorAll('#nui-sheet .nui-panel > .nui-row');
if (rows.length !== shown.length) return;
shown.forEach((it, i) => {
const icon = it.svg && rows[i].querySelector('.nui-row-icon');
if (icon) { icon.textContent = ''; icon.append(svgIcon(it.svg)); }
});
}
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`;
}
// Short dates: heute, gestern, vor 3 Tagen (up to 6), then 8.7. this year, 8.7.22 before.
function day(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
const now = new Date();
const midnight = x => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
const diff = Math.round((midnight(now) - midnight(d)) / 86400000);
if (diff <= 0) return 'heute';
if (diff === 1) return 'gestern';
if (diff < 7) return `vor ${diff} Tagen`;
const dm = `${d.getDate()}.${d.getMonth() + 1}.`;
return d.getFullYear() === now.getFullYear() ? dm : dm + String(d.getFullYear() % 100).padStart(2, '0');
}
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, feedArt: o.episode__feed__artwork_url || feedArtOf(o.episode__feed__id),
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, feedArt: o.feed__artwork_url || feedArtOf(o.feed__id) || (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 feedArtOf(id) {
const f = id ? feeds().find(x => x.id === id) : null;
return f ? f.artwork_url : '';
}
// A cover: the episode's own, else its podcast's, else a tile with the podcast's initial.
function art(src, size, fallback, name) {
const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : ''));
const srcs = [src, fallback].filter(Boolean).map(s => s.replace(/^http:/, 'https:'))
.filter((s, i, all) => all.indexOf(s) === i);
const initial = () => {
if (box.querySelector('img') || box.firstChild) return;
const letter = (plain(name || '').replace(/^(the|der|die|das)\s+/i, '').match(/[0-9A-Za-zÀ-ÿ]/) || [''])[0];
if (letter) box.append(el('span', 'nui-pod-initial', letter.toUpperCase()));
};
const next = () => {
const s = srcs.shift();
if (!s) { initial(); return; }
const img = el('img');
img.loading = 'lazy';
img.alt = '';
img.src = s;
img.addEventListener('error', () => { img.remove(); next(); });
box.append(img);
};
next();
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');
}
// Choosing several, from a row's menu (long press or ⋯).
const selectItem = (e, opts) => opts && opts.select &&
{icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)};
// „Folge löschen“: out of the queue and „Neu“, its download goes here and on every other
// device (the server tells them), but not played: it keeps its place and stays in „Folgen“.
const DISCARD_HINT = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört';
const discardItem = (e, opts) => N.podcastDiscard &&
{icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: DISCARD_HINT, action: () => discardOne(e, (opts && opts.list) || '', (opts && opts.filters) || [])};
// One episode: no question, 4 s „Rückgängig“ like a swipe; sent only then. False when its row leaves the list.
function discardOne(e, list, filters) {
const hide = list === 'queue' || list === 'inbox' || list === 'downloads' ||
filters.includes('downloaded') || filters.includes('queued');
offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => {
discardNow([e.id]);
if (!hide) render();
}});
return !hide;
}
function discardNow(ids) {
N.podcastDiscard(JSON.stringify(ids));
// The lists fetched from the server (Folgen, a podcast) show it at once.
const lists = [epList && epList.items, feedView && feedView.episodes];
for (const l of lists) for (const o of l || []) if (ids.includes(o.id)) o.in_queue = false;
}
// Several: asked first.
function discardMany(ids) {
openSheet(`${nEpisodes(ids.length)} löschen? Nicht als gehört markiert.`, [
{icon: '⊠', svg: 'trash', label: 'Löschen', hint: DISCARD_HINT, danger: true, action: () => {
discardNow(ids);
toast(`${nEpisodes(ids.length)} gelöscht`);
stopSelecting();
}},
{icon: '‹', label: 'Abbrechen', action: () => {}},
]);
}
const discardAction = () => N.podcastDiscard && {label: 'Löschen', svg: 'trash', danger: true, run: discardMany};
function queueMenu(e, opts) {
const playing = currentId() === e.id;
openSheet(e.title, [
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
!playing && {icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
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: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
action: () => N.podcastDequeue(e.id)},
discardItem(e, opts),
selectItem(e, opts),
]);
}
function episodeMenu(e, opts) {
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: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
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: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null,
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: '⊘', svg: 'listx', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
action: () => N.podcastDismiss(JSON.stringify([e.id]))},
discardItem(e, opts),
selectItem(e, opts),
]);
}
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(),
keepSkippedItem(),
deleteDequeueItem(),
]),
group('▶', 'Abspielen', ['Sprungweiten', 'speed_presets' in s && 'Tempo', 'skip_silence' in prefs && 'Stille', '„gehört“',
'times_by_speed' in s && 'Zeiten', 'headset_next' in prefs && 'Kopfhörer',
'notify_buttons' in prefs && 'Benachrichtigung'].filter(Boolean).join(', '), () => [
skipItem('skip_back', 'Zurückspringen', SKIPS),
skipItem('skip_forward', 'Vorspringen', SKIPS),
'speed_presets' in s && {icon: '»', label: 'Tempo-Voreinstellungen', hint: presets().map(speedLabel).join(' · '),
action: () => presetsEditor()},
silenceItem(),
playedItem(),
timesItem(),
headsetItem(),
notifyItem(),
]),
mobileGroup(),
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(),
]),
backgroundSyncItem(),
{icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
startPageItem(),
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},
]);
}
// The app's background job (BackgroundSync.kt): new episodes, their notifications and downloads with the app closed.
const BG_SYNC = [
{value: 0, label: 'Aus', hint: 'nur wenn die App offen ist'},
{value: 15, label: 'Alle 15 Minuten'},
{value: 60, label: 'Jede Stunde', hint: 'Standard'},
{value: 240, label: 'Alle 4 Stunden'},
];
function backgroundSyncItem() {
if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null;
const now = N.podcastBackgroundSync();
const label = (BG_SYNC.find(c => c.value === now) || BG_SYNC[2]).label;
return {icon: '⟲', label: 'Im Hintergrund abgleichen', hint: label + ' · neue Folgen, Mitteilungen, Downloads',
action: () => openSheet('Im Hintergrund abgleichen', BG_SYNC.map(c => ({
icon: c.value === now ? '●' : '○', label: c.label, hint: c.hint, active: c.value === now,
action: () => { N.podcastSetBackgroundSync(c.value); settingsMenu(); },
})))};
}
// Skip lengths and "counts as played" belong to the account where the server knows them
// (the host sends them there, PlaybackPrefs.set); else to this device.
const where = name => (name in settings() ? 'für alle Geräte' : 'auf diesem Gerät');
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} (${where(name)})`, choices.map(c => ({
icon: c === now ? '●' : '○', label: `${c} Sekunden`, active: c === now,
action: () => { N.podcastSetPrefValue(name, c); toast(`${label}: ${c} Sekunden`); },
})))};
}
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 … (${where('smart_played')})`,
[0, 15, 30, 45, 60, 120, 300, 600].map(v => ({
icon: v === now ? '●' : '○', label: label(v), active: v === now,
action: () => N.podcastSetPrefValue('smart_played', v),
})))};
}
// An account setting (server: POST /podcasts/settings/), through the host.
function setAccount(name, value, text) {
if (!N.podcastSettings) return;
N.podcastSettings(JSON.stringify({[name]: value}));
if (text) toast(text);
}
function accountSwitch(name, label, onHint, offHint, def) {
const s = settings();
if (!(name in s)) return null;
const on = s[name] == null ? def : !!s[name];
return {icon: on ? '☑' : '☐', label, hint: on ? onHint : offHint,
action: () => setAccount(name, !on, `${label}: ${on ? 'aus' : 'an'}`)};
}
// AntennaPod's "keep skipped episodes": ⏭, "Folge überspringen", headset and car go on without marking.
function keepSkippedItem() {
return accountSwitch('keep_skipped', 'Übersprungene Folgen behalten',
'an: wer zur nächsten springt, lässt die Folge ungehört in der Warteschlange',
'aus: übersprungene Folgen zählen als gehört', true);
}
// AntennaPod's "delete removes from queue".
function deleteDequeueItem() {
if (!DL) return null;
return accountSwitch('delete_dequeue', '„Datei löschen“ nimmt aus der Warteschlange',
'an: wer die Datei einer Folge löscht, nimmt sie auch aus der Warteschlange', 'aus: die Folge bleibt eingereiht', true);
}
// AntennaPod's "adjust media info to playback speed".
function timesItem() {
return accountSwitch('times_by_speed', 'Zeiten an das Tempo anpassen',
'an: Längen und Restzeiten so, wie lange sie beim eingestellten Tempo dauern', 'aus: Längen wie bei 1×', false);
}
// What may use mobile data: downloads by hand (account), automatic ones (this device), streaming (account).
const STREAMING = [
{value: 'allow', label: 'Erlauben'},
{value: 'ask', label: 'Nachfragen', hint: 'einmal pro Folge'},
{value: 'never', label: 'Nie', hint: 'nur WLAN oder heruntergeladene Folgen'},
];
function mobileGroup() {
const s = settings();
const items = () => {
const now = settings();
const dls = DL ? parse(N.podcastDownloadSettings(), dlSettings) : {};
const streaming = STREAMING.find(c => c.value === now.mobile_streaming) || STREAMING[0];
return [
DL && accountSwitch('mobile_downloads', 'Downloads per Hand über mobile Daten',
'an: was du von Hand lädst, lädt auch unterwegs', 'aus: wartet aufs WLAN, wie automatische Downloads', true),
DL && {icon: dls.mobile ? '☑' : '☐', label: 'Automatische Downloads über mobile Daten',
hint: (dls.mobile ? 'an' : 'aus: nur im WLAN') + ' · dieses Gerät', action: () => dlSet({mobile: !dls.mobile})},
N.podcastAllowStream && 'mobile_streaming' in now && {icon: '⇵', label: 'Streamen über mobile Daten',
hint: streaming.label + ' · Radio ist davon nicht betroffen',
action: () => openSheet('Folgen über mobile Daten streamen', STREAMING.map(c => ({
icon: c.value === streaming.value ? '●' : '○', label: c.label, hint: c.hint || null, active: c.value === streaming.value,
action: () => setAccount('mobile_streaming', c.value, `Streamen über mobile Daten: ${c.label}`),
})))},
].filter(Boolean);
};
if (!('mobile_downloads' in s || 'mobile_streaming' in s) || !items().length) return null;
return {icon: '◢', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())};
}
// Which page the app opens with (this device).
function startPageItem() {
const now = startPage();
const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label;
return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'),
action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP ||
document.documentElement.classList.contains('nui-radio')).map(p => ({
icon: p.id === now ? '●' : '○', label: p.label, hint: p.hint || null, active: p.id === now,
action: () => {
try { localStorage.setItem('nui_start_page', p.id); } catch (e) {}
toast('Startseite: ' + p.label);
},
})))};
}
// ---- speed presets (the account's; AntennaPod's speed presets, up to 3.5×) ----
const roundSpeed = v => Math.round(v * 100) / 100;
function savePresets(list) {
setAccount('speed_presets', list);
setTimeout(() => presetsEditor(list || SPEEDS), 60);
}
function presetsEditor(list) {
const now = (list || presets()).slice().sort((a, b) => a - b);
const own = Array.isArray(settings().speed_presets) || !!list;
openSheet('Tempo-Voreinstellungen', now.map(v => ({
icon: '✕', label: speedLabel(v), hint: now.length > 1 ? 'antippen zum Entfernen' : 'eine bleibt immer',
action: () => { if (now.length > 1) savePresets(now.filter(x => x !== v)); else toast('Eine Voreinstellung bleibt immer'); },
})).concat([
{icon: '+', label: 'Tempo hinzufügen', hint: `${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`,
action: () => addPreset(now)},
own && {icon: '↺', label: 'Standardliste', hint: SPEEDS.map(speedLabel).join(' · '), action: () => savePresets(null)},
].filter(Boolean)));
}
function addPreset(now) {
if (now.length >= MAX_PRESETS) { toast(`Höchstens ${MAX_PRESETS} Voreinstellungen`); return; }
const add = v => savePresets(now.concat(roundSpeed(v)).sort((a, b) => a - b));
const choices = [];
for (let v = MIN_SPEED; v <= MAX_SPEED + 0.001; v += 0.25) {
if (!now.some(x => Math.abs(x - v) < 0.001)) choices.push(roundSpeed(v));
}
openSheet('Tempo hinzufügen', choices.map(v => ({icon: '+', label: speedLabel(v), action: () => add(v)}))
.concat(typeof customPrompt === 'function' ? [{icon: '✎', label: 'Eigener Wert …', hint: 'z. B. 1,65', action: async () => {
const t = await customPrompt(`Tempo (${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}):`, '');
if (t == null) return;
const v = roundSpeed(parseFloat(String(t).replace(',', '.').replace(/[×x]/g, '')));
if (!(v >= MIN_SPEED && v <= MAX_SPEED)) { toast(`Bitte eine Zahl von ${speedLabel(MIN_SPEED)} bis ${speedLabel(MAX_SPEED)}`); return; }
if (now.some(x => Math.abs(x - v) < 0.001)) { toast(`${speedLabel(v)} ist schon dabei`); return; }
add(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)},
])};
}
// The notification's two extra buttons (app only: NotifyButtons.kt), like AntennaPod's.
const NOTIFY = [
{key: 'next', label: 'Folge überspringen',
hint: () => (keepSkipped() ? 'die nächste spielt, diese bleibt ungehört eingereiht' : 'zählt als gehört, die nächste spielt')},
{key: 'chapter', label: 'Nächstes Kapitel', hint: () => 'wenn die Folge Kapitel hat'},
// The account's presets; without any, the app's own list.
{key: 'speed', label: 'Tempo', hint: () => (Array.isArray(settings().speed_presets) && settings().speed_presets.length
? presets() : [1, 1.2, 1.5, 1.75, 2]).map(speedLabel).join(' · ')},
{key: 'sleep', label: 'Sleep-Timer', hint: () => `${sleepMinutesLabel((player.sleep || {}).last || 15)} an / aus, auch beim Radio`},
];
function notifyItem() {
if (!N.podcastSetNotifyButtons || !Array.isArray(devicePrefs().notify_buttons)) return null;
const now = devicePrefs().notify_buttons;
const names = keys => keys.map(k => (NOTIFY.find(n => n.key === k) || {}).label).filter(Boolean).join(', ') || 'keine';
const sheet = chosen => openSheet('Zwei Knöpfe in der Benachrichtigung', NOTIFY.map(n => ({
icon: chosen.includes(n.key) ? '☑' : '☐', label: n.label, hint: n.hint(),
// A third one replaces the one chosen first.
action: () => {
const next = chosen.includes(n.key) ? chosen.filter(k => k !== n.key) : chosen.concat(n.key).slice(-2);
N.podcastSetNotifyButtons(next.join(','));
toast('Benachrichtigung: ' + names(next));
},
})));
return {icon: '⊞', label: 'Knöpfe in der Benachrichtigung', hint: names(now), action: () => sheet(now)};
}
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) {
commitSwipe();
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.filter(Boolean)) {
const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
if (a.svg) b.prepend(svgIcon(a.svg, 16));
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;
}
const nEpisodes = n => `${n} ${n === 1 ? 'Folge' : 'Folgen'}`;
// Choosing several in a list of episodes (Folgen, one podcast): what to do with them.
function episodeSelection(items) {
const pick = ids => items.filter(e => ids.includes(e.id));
const fresh = new Set(inbox().map(o => o.id));
return selectionBar(items, [
{label: 'Einreihen', run: ids => enqueueMany(pick(ids))},
{label: 'Gehört', run: ids => markMany(pick(ids), true)},
{label: 'Ungehört', run: ids => markMany(pick(ids), false)},
DL && {label: 'Laden', run: ids => downloadMany(pick(ids))},
{label: 'Favorit', run: ids => openSheet(nEpisodes(ids.length), [
{icon: '★', label: 'Als Favorit markieren', action: () => favoriteMany(pick(ids), true)},
{icon: '☆', label: 'Kein Favorit mehr', action: () => favoriteMany(pick(ids), false)},
])},
// Only where some of them are new.
items.some(e => fresh.has(e.id)) && {label: 'Aus „Neu“', svg: 'listx', run: ids => {
const gone = ids.filter(id => fresh.has(id));
if (gone.length) N.podcastDismiss(JSON.stringify(gone));
toast(gone.length ? `${nEpisodes(gone.length)} aus „Neu“ entfernt` : 'Keine davon ist in „Neu“');
stopSelecting();
}},
discardAction(),
].filter(Boolean));
}
function enqueueMany(list) {
const todo = list.filter(e => !inQueue(e.id));
if (!todo.length) { toast('Schon alle in der Warteschlange'); stopSelecting(); return; }
const add = where => {
N.podcastEnqueue(JSON.stringify(todo.map(e => e.raw)), where, currentId());
for (const e of todo) e.raw.in_queue = true;
toast(`${nEpisodes(todo.length)} eingereiht`);
stopSelecting();
};
openSheet(`${nEpisodes(todo.length)} einreihen`, [
{icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label,
action: () => add('')},
{icon: '⤴', label: 'Als Nächstes', action: () => add('after_current')},
{icon: '⇈', label: 'An den Anfang', action: () => add('front')},
{icon: '⇊', label: 'Ans Ende', action: () => add('back')},
]);
}
function markMany(list, played) {
const ids = list.map(e => e.id);
if (played) {
N.podcastMarkPlayedAll(JSON.stringify(ids), true);
} else {
// "Ungehört" one by one fetches the state again each time: all but the
// last in one go; downloaded ones singly, the app has to know of them.
const single = ids.filter(id => DL && downloads[id]);
const rest = ids.filter(id => !(DL && downloads[id]));
if (!single.length) single.push(rest.pop());
if (rest.length) N.podcastMarkPlayedAll(JSON.stringify(rest), false);
for (const id of single) N.podcastMarkPlayed(id, false);
}
for (const e of list) {
e.raw.played = played;
if (played) e.raw.position_seconds = 0;
}
toast(`${nEpisodes(ids.length)} als ${played ? 'gehört' : 'ungehört'} markiert`);
stopSelecting();
}
function downloadMany(list) {
const todo = list.filter(e => (e.raw.audio_url || e.raw.episode__audio_url) &&
!(downloads[e.id] && downloads[e.id].state !== 'failed'));
const waiting = {};
for (const e of todo) {
N.podcastDownload(JSON.stringify(e.raw));
waiting[e.id] = {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'};
}
toast(todo.length ? `${nEpisodes(todo.length)} ${todo.length === 1 ? 'wird' : 'werden'} geladen` : 'Schon alle geladen');
selected = null;
window.__dioraDownloads(Object.assign({}, downloads, waiting));
render();
}
async function favoriteMany(list, star) {
stopSelecting();
try {
const r = await fetch('/podcasts/episodes/favorite/', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({episode_ids: list.map(e => e.id), favorite: star}),
});
if (!r.ok) throw new Error(r.status);
for (const e of list) {
e.raw.favorite = star;
if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, star);
const cached = storedDetails(e.id);
if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: star}));
}
toast(star ? `★ ${nEpisodes(list.length)} als Favorit markiert` : `${nEpisodes(list.length)}: kein Favorit mehr`);
} catch (err) {
toast(navigator.onLine ? 'Favoriten ließen sich nicht setzen' : 'Favoriten brauchen eine Verbindung');
}
render();
}
// ---- filters and order of episode lists (Folgen, one podcast) -------------------------
const FILTERS = [
{id: 'unplayed', label: 'Ungehört', test: o => !o.played, none: 'Keine ungehörten Folgen.'},
{id: 'in_progress', label: 'Angefangen', test: o => !o.played && (o.position_seconds || 0) > 0,
none: 'Keine angefangenen Folgen.'},
{id: 'favorites', label: 'Favoriten', test: o => !!o.favorite,
none: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.'},
{id: 'queued', label: 'Warteschlange', test: o => inQueue(o.id), none: 'Keine davon steht in der Warteschlange.'},
{id: 'downloaded', label: 'Geladen', app: true, test: o => dlDone(o.id), none: 'Keine Folgen auf dem Gerät.'},
{id: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0,
none: 'Noch nichts gehört.'},
{id: 'played', label: 'Gehört', test: o => !!o.played, none: 'Noch keine gehörten Folgen.'},
];
// Filters that can't both be on: choosing one takes the others off.
const EXCLUDES = {unplayed: ['played'], in_progress: ['played'], played: ['unplayed', 'in_progress']};
const EP_SORTS = [
{id: 'new', label: 'Neueste zuerst', key: o => -(Date.parse(o.pub_date) || -Infinity)},
{id: 'old', label: 'Älteste zuerst', key: o => Date.parse(o.pub_date) || Infinity},
{id: 'long', label: 'Längste zuerst', key: o => (o.duration_seconds ? -o.duration_seconds : Infinity)},
{id: 'short', label: 'Kürzeste zuerst', key: o => o.duration_seconds || Infinity},
{id: 'title', label: 'Nach Titel', key: o => plain(o.title).toLocaleLowerCase('de')},
{id: 'played', label: 'Zuletzt gehört', all: true},
];
const sortLabel = id => (EP_SORTS.find(s => s.id === id) || EP_SORTS[0]).label;
function dlDone(id) {
return DL && !!downloads[id] && downloads[id].state === 'done';
}
function matchesAll(o, filters) {
return filters.every(id => { const f = FILTERS.find(x => x.id === id); return !f || f.test(o); });
}
function sortEpisodes(list, sort) {
const s = EP_SORTS.find(x => x.id === sort);
if (!s || !s.key) return list;
const keyed = list.map((o, i) => [s.key(o), i, o]);
keyed.sort((a, b) => (typeof a[0] === 'string' ? a[0].localeCompare(b[0], 'de') : a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0) ||
a[1] - b[1]);
return keyed.map(k => k[2]);
}
// One row of chips: "Alle" and the filters, combinable.
function filterRow(active, onChange, allEpisodes) {
const chips = el('div', 'nui-pod-filters');
const chip = (label, on, onClick) => {
const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''), label);
b.type = 'button';
b.setAttribute('aria-pressed', on ? 'true' : 'false');
b.addEventListener('click', onClick);
chips.append(b);
};
chip('Alle', !active.length, () => onChange([]));
for (const f of FILTERS) {
if ((f.app && !DL) || (f.all && !allEpisodes)) continue;
const on = active.includes(f.id);
chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id)
: active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id)));
}
// An active filter further right shows (the row scrolls sideways).
requestAnimationFrame(() => {
const on = chips.querySelector('.nui-pod-chip.on');
if (on && on.offsetLeft + on.offsetWidth > chips.scrollLeft + chips.clientWidth) chips.scrollLeft = on.offsetLeft - 8;
});
return chips;
}
function sortMenuFor(current, allEpisodes, onPick) {
openSheet('Folgen sortieren', EP_SORTS.filter(s => allEpisodes || !s.all).map(s => ({
icon: s.id === current ? '●' : '○', label: s.label, active: s.id === current, action: () => onPick(s.id),
})));
}
function noneText(filters) {
const f = filters.length === 1 && FILTERS.find(x => x.id === filters[0]);
return f ? f.none : filters.length ? 'Keine Folgen mit diesen Filtern.' : 'Keine Folgen.';
}
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() {
// A finger is swiping a row: redrawing now would pull the row away under it (and end
// the swipe). It redraws when the swipe ends — unless that never came (a lost pointer).
if (swipingSince && Date.now() - swipingSince < 8000) { renderAfterSwipe = true; return; }
episodeScreenParts();
if (!root) return;
const scroll = scrollTop();
// Search results arriving while typing: the field keeps its text and the cursor.
const q = $id('nui-pod-q');
const typing = q && document.activeElement === q ? [q.selectionStart, q.selectionEnd] : null;
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(), searchButton(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)),
button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean));
root.append(bar);
if (preview) {
renderPreview();
} else 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' ? inboxTotal() : 0;
if (count) b.append(el('span', 'nui-pod-count' + (v.id === 'queue' ? ' nui-pod-count-queue' : ''), count > 999 ? '999+' : 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);
const q2 = typing && $id('nui-pod-q');
if (q2) {
q2.focus();
try { q2.setSelectionRange(typing[0], typing[1]); } catch (e) {}
}
}
// The search: a magnifier in the top bar (an SVG: the text glyphs for it differ from font to font).
function searchButton() {
const b = button('', 'Suchen', () => {
selected = null;
feedView = null;
preview = null;
view = 'search';
render();
scrollTop(0);
const q = $id('nui-pod-q');
if (q && !(searchResults && searchResults.q)) q.focus();
}, 'nui-pod-searchbtn' + (view === 'search' && !feedView && !preview ? ' on' : ''));
b.innerHTML = '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true" fill="none" stroke="currentColor" ' +
'stroke-width="2" stroke-linecap="round"><circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5 21 21"/></svg>';
return b;
}
// 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 ? queue().length : 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;
}
// One episode in a list. opts.list names the list (queue, inbox, episodes, feed, downloads):
// it decides the one action at hand and the swipes; everything else is in the row's menu
// (⋯ or a long press). opts.filters: the list's filters (what a swipe takes out of it).
function episodeRow(e, opts) {
const list = opts.list || '';
const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : ''));
row.dataset.id = e.id;
if (swipeGone(e.id)) row.classList.add('nui-pod-gone');
row.append(art(e.art, null, e.feedArt, e.feed));
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(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId)));
if (opts.showDate && e.date) meta.push(day(e.date));
if (opts.showFeed && e.feed) meta.push(e.feed);
const metaEl = el('div', 'nui-pod-meta', meta.join(' · '));
// Where the row's button isn't the download: its state as a small mark.
const mark = selected || dlPrimary(e, list) ? null : dlMark(e);
if (mark) metaEl.prepend(mark);
main.append(metaEl);
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));
return row;
}
const how = {select: !opts.noSelect, list, filters: opts.filters || []};
const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how));
row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu'));
row.addEventListener('click', () => openEpisode(e, how));
// Swipes: touch only, not while the queue is locked.
const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : '';
rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes);
return row;
}
// The one action at hand: the queue and the downloads play, „Neu“ queues,
// the episode lists download (or queue, where nothing downloads).
function dlPrimary(e, list) {
return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url);
}
function primaryButton(e, list) {
if (list === 'queue' || list === 'downloads') {
const now = currentId() === e.id;
const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen',
() => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn');
b.dataset.play = e.id;
return b;
}
if (dlPrimary(e, list)) return dlButton(e);
const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue);
return queued ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
: button('+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; render(); });
}
// ▶ / ❚❚ in the rows follow the player without drawing the lists again.
function playButtons() {
document.querySelectorAll('#nui-pod [data-play]').forEach(b => {
const now = currentId() === Number(b.dataset.play) && player.playing;
b.textContent = now ? '❚❚' : '▶';
b.title = now ? 'Pause' : 'Abspielen';
b.setAttribute('aria-label', b.title);
});
}
// ---- long press and swipes on a row ---------------------------------------------------
// Long press opens the row's menu. Swiping (a finger, not a mouse) left or right does what
// the list's swipe settings say, with 4 s to undo. Vertical moves stay the page's scrolling;
// the queue's drag handle and the sideways chip rows aren't rows, so nothing competes.
function rowGestures(row, onLong, onSwipe, list) {
let start = null;
let holding = false; // this row counts in swipingSince
let timer = 0;
let mode = '';
let dx = 0;
let vx = 0;
let last = null;
let bg = null;
let swallow = false;
const stopTimer = () => { if (timer) clearTimeout(timer); timer = 0; };
const actionFor = d => swipeAction(list, d);
const limit = () => Math.min(140, Math.max(72, row.offsetWidth * 0.3));
const armed = () => {
const act = actionFor(dx < 0 ? 'left' : 'right');
if (act === 'none') return false;
const fling = Math.abs(dx) > 40 && Math.abs(vx) > 0.6 && Math.sign(vx) === Math.sign(dx);
return Math.abs(dx) >= limit() || fling;
};
const paint = () => {
const d = dx < 0 ? 'left' : 'right';
const act = actionFor(d);
const shownDx = act === 'none' ? dx * 0.25 : dx;
row.style.transform = `translateX(${shownDx}px)`;
const a = SWIPE_ACTIONS.find(x => x.id === act) || SWIPE_ACTIONS[0];
bg.dataset.dir = d;
bg.classList.toggle('armed', armed());
const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r');
const other = bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l');
if (label.dataset.act !== act) {
label.dataset.act = act;
label.textContent = '';
if (act !== 'none') {
const t = swipeText(a, row);
const text = el('span', 'nui-pod-swipe-text');
text.append(el('b', null, t.title));
if (t.hint) text.append(el('small', null, t.hint));
const icon = a.svg ? svgIcon(a.svg, 22) : el('span', null, a.icon);
icon.classList.add('nui-pod-swipe-icon');
label.append(icon, text);
}
}
other.textContent = '';
delete other.dataset.act;
};
const finish = done => {
if (holding) {
holding = false;
swipingSince = 0;
if (renderAfterSwipe) { renderAfterSwipe = false; setTimeout(render, done ? 200 : 220); }
}
row.classList.remove('nui-pod-swiping');
const gone = bg;
bg = null;
if (!done) {
row.style.transition = 'transform 0.18s ease';
row.style.transform = '';
setTimeout(() => { row.style.transition = ''; if (gone) gone.remove(); }, 200);
} else if (gone) {
setTimeout(() => gone.remove(), 220);
}
};
row.addEventListener('pointerdown', ev => {
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
// On the queue's drag handle: a vertical move sorts (dragSort), a sideways one is the swipe.
const onHandle = !!ev.target.closest('.nui-pod-handle');
if (onHandle && !onSwipe) return;
start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'};
mode = '';
dx = vx = 0;
last = {x: ev.clientX, t: ev.timeStamp};
swallow = false;
stopTimer();
if (!onHandle && !ev.target.closest('button')) {
timer = setTimeout(() => {
timer = 0;
if (mode || !start) return;
mode = 'long';
swallow = true;
onLong();
}, 550);
}
});
row.addEventListener('pointermove', ev => {
if (!start || ev.pointerId !== start.id) return;
const mx = ev.clientX - start.x;
const my = ev.clientY - start.y;
if (!mode) {
if (Math.abs(mx) > 8 || Math.abs(my) > 8) { stopTimer(); start.moved = true; }
if (onSwipe && start.touch && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.5) {
mode = 'swipe';
// The swipe before stays undoable (its bar too) until this one is done (offerUndo).
holding = true;
swipingSince = Date.now();
try { row.setPointerCapture(ev.pointerId); } catch (e) {}
bg = el('div', 'nui-pod-swipebg');
bg.append(el('span', 'nui-pod-swipe-l'), el('span', 'nui-pod-swipe-r'));
bg.style.top = row.offsetTop + 'px';
bg.style.height = row.offsetHeight + 'px';
row.parentElement.insertBefore(bg, row);
row.classList.add('nui-pod-swiping');
} else if (Math.abs(my) > 12) {
start = null;
return;
}
}
if (mode !== 'swipe') return;
ev.preventDefault();
const dt = ev.timeStamp - last.t;
if (dt > 0) vx = 0.7 * vx + 0.3 * ((ev.clientX - last.x) / dt);
last = {x: ev.clientX, t: ev.timeStamp};
dx = mx;
paint();
});
const end = ev => {
if (!start || (ev && ev.pointerId !== start.id)) return;
stopTimer();
const moved = start.moved;
start = null;
// A drag (a mouse's too) isn't a tap: no playing by accident.
if (mode !== 'swipe') { if (moved && mode !== 'long') swallow = true; return; }
swallow = true;
const d = dx < 0 ? 'left' : 'right';
const act = actionFor(d);
if (ev.type === 'pointerup' && armed() && act !== 'none') {
row.style.transition = 'transform 0.16s ease-out';
row.style.transform = `translateX(${d === 'left' ? -row.offsetWidth : row.offsetWidth}px)`;
finish(true);
setTimeout(() => {
const kept = onSwipe(act, d);
// The row stays in the list: back in its place.
if (kept !== false && row.isConnected) {
row.style.transition = 'transform 0.18s ease';
row.style.transform = '';
setTimeout(() => { row.style.transition = ''; }, 200);
}
}, 160);
} else {
finish(false);
}
};
row.addEventListener('pointerup', end);
row.addEventListener('pointercancel', end);
row.addEventListener('pointerleave', ev => { if (mode !== 'swipe') { stopTimer(); } else if (ev.pointerType !== 'touch') end(ev); });
// The tap that ends a long press or a swipe isn't a tap on the row.
row.addEventListener('click', ev => {
if (!swallow) return;
swallow = false;
ev.stopPropagation();
ev.preventDefault();
}, true);
row.addEventListener('contextmenu', ev => ev.preventDefault());
}
// What swiping does, per list (this device). The defaults are an AntennaPod setup, except the
// queue's left: „Folge löschen“ where the host can (it was „Datei löschen“).
const SWIPE_LISTS = {
queue: {label: 'Warteschlange', left: N.podcastDiscard ? 'discard' : 'delete', right: 'dequeue'},
inbox: {label: 'Neu', left: 'dismiss', right: 'queue'},
episodes: {label: 'Folgen', left: 'download', right: 'favorite'},
feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'},
downloads: {label: 'Downloads', hint: 'Folgen mit dem Filter „Geladen“', left: 'delete', right: 'delete'},
};
const SWIPE_ACTIONS = [
{id: 'none', label: 'Nichts', icon: ''},
{id: 'queue', label: 'Einreihen', icon: '+'},
// Where there are files (DL is known only further down: getters).
{id: 'dequeue', get label() { return DL ? 'Aus Warteschlange · Datei bleibt' : 'Aus der Warteschlange'; },
icon: '✕', svg: 'listx'},
{id: 'download', label: 'Herunterladen', icon: '↓', dl: true},
{id: 'delete', label: 'Datei löschen', hint: 'nur die Datei auf diesem Gerät', icon: '✕', svg: 'trash', dl: true},
{id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'},
{id: 'favorite', label: 'Favorit an / aus', icon: '★'},
{id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘', svg: 'listx'},
{id: 'discard', label: 'Folge löschen', hint: 'aus Warteschlange und Neu, Datei auf allen Geräten weg – nicht gehört',
icon: '⊠', svg: 'trash', host: 'podcastDiscard'},
];
// An action the host can do (the browser has no downloads; an older host no „Folge löschen“).
const canSwipe = a => (!a.dl || DL) && (!a.host || !!N[a.host]);
function swipePrefs() {
let all = {};
try { all = JSON.parse(localStorage.getItem('nui_pod_swipes') || '{}') || {}; } catch (e) {}
return all;
}
// The queue's left swipe was „Download löschen“ by default: kept as such (stored when chosen),
// it becomes „Folge löschen“ once; any other choice stays.
(function newQueueDefault() {
if (!N.podcastDiscard) return;
const all = swipePrefs();
if (all.v >= 2) return;
if (all.queue && all.queue.left === 'delete') all.queue.left = 'discard';
all.v = 2;
try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {}
})();
function swipeAction(list, dir) {
const l = SWIPE_LISTS[list];
if (!l) return 'none';
const own = (swipePrefs()[list] || {})[dir];
const id = own || l[dir];
const a = SWIPE_ACTIONS.find(x => x.id === id);
return a && canSwipe(a) ? id : 'none';
}
// What the swipe says behind the moving row: the action by its full name (bold) and what
// it means for this episode (small) — never a bare „Löschen“ or „Entfernen“.
function swipeText(a, row) {
const id = row && Number(row.dataset.id);
switch (a.id) {
case 'discard': return {title: 'Folge löschen', hint: 'nicht gehört'};
case 'delete': return {title: 'Datei löschen', hint: id && deleteDequeues(id) ? 'auch aus Warteschlange' : 'nur auf diesem Gerät'};
case 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null};
case 'dismiss': return {title: 'Aus Neu entfernen', hint: 'nicht gehört'};
case 'queue': return {title: 'Einreihen'};
case 'download': return {title: 'Laden'};
case 'favorite': return {title: 'Favorit'};
case 'played': return {title: row && row.classList.contains('nui-pod-played') ? 'Ungehört' : 'Gehört'};
default: return {title: a.label};
}
}
// „Datei löschen“ takes the episode out of the queue too (the account's delete_dequeue, on by default).
function deleteDequeues(id) {
return 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(id);
}
function swipeSettings() {
openSheet('Wischgesten', Object.keys(SWIPE_LISTS).filter(id => id !== 'downloads' || DL).map(id => {
const l = SWIPE_LISTS[id];
const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(id, d)) || SWIPE_ACTIONS[0]).label;
return {icon: '⇆', label: l.label, hint: `← ${name('left')} · → ${name('right')}`, action: () => swipeListSheet(id)};
}).concat([{custom: el('div', 'nui-pod-meta nui-pod-sheetnote',
'Nur mit dem Finger. In der gesperrten Warteschlange ist Wischen aus. Nach jedem Wischen 4 Sekunden „Rückgängig“.')}]));
}
function swipeListSheet(list) {
const l = SWIPE_LISTS[list];
const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`,
SWIPE_ACTIONS.filter(canSwipe).map(a => ({
icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id,
hint: [a.hint, a.id === l[dir] ? 'Standard' : null].filter(Boolean).join(' · ') || null,
action: () => {
const all = swipePrefs();
all[list] = Object.assign({}, all[list], {[dir]: a.id});
try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {}
swipeListSheet(list);
},
})));
const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(list, d)) || SWIPE_ACTIONS[0]).label;
openSheet(`Wischen in „${l.label}“` + (l.hint ? ` (${l.hint})` : ''), [
{icon: '←', label: 'Nach links', hint: name('left'), action: () => pick('left')},
{icon: '→', label: 'Nach rechts', hint: name('right'), action: () => pick('right')},
{icon: '‹', label: 'Alle Listen', action: swipeSettings},
]);
}
// A swipe's change waits 4 s for „Rückgängig“ where it can't simply be undone
// (queued or dismissed: out of „Neu“ for good; out of the queue; played; a download
// deleted): a row that leaves its list is gone at once, the change is sent then.
// Downloading and starring happen at once; undoing them is their opposite.
let pendingSwipe = null; // {id, hide, run, undo, timer}
const recentlyGone = new Map();
function swipeGone(id) {
if (pendingSwipe && pendingSwipe.hide && pendingSwipe.id === id) return true;
const t = recentlyGone.get(id);
return !!t && Date.now() - t < 4000;
}
function commitSwipe() {
const p = pendingSwipe;
if (!p) return;
pendingSwipe = null;
clearTimeout(p.timer);
undoBar(null);
if (p.hide) recentlyGone.set(p.id, Date.now());
if (p.run) p.run();
}
function undoSwipe() {
const p = pendingSwipe;
if (!p) return;
pendingSwipe = null;
clearTimeout(p.timer);
undoBar(null);
if (p.undo) p.undo();
render();
}
// The bar with „Rückgängig“ (the toast can't be tapped).
function undoBar(text, svg) {
let bar = $id('nui-pod-undo');
if (!text) { if (bar) bar.classList.remove('nui-show'); return; }
if (!bar) {
bar = el('div');
bar.id = 'nui-pod-undo';
bar.append(el('span', 'nui-pod-undo-icon'), el('span', 'nui-pod-undo-text'));
const b = el('button', 'nui-pod-undo-btn', 'Rückgängig');
b.type = 'button';
b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); });
bar.append(b);
document.body.append(bar);
}
const t = $id('nui-radio-toast');
if (t) t.classList.remove('nui-show');
bar.querySelector('.nui-pod-undo-text').textContent = text;
const icon = bar.querySelector('.nui-pod-undo-icon');
icon.textContent = '';
if (svg) icon.append(svgIcon(svg, 18));
icon.hidden = !svg;
bar.classList.add('nui-show');
}
function offerUndo(e, text, change) {
commitSwipe();
pendingSwipe = Object.assign({id: e.id}, change);
pendingSwipe.timer = setTimeout(commitSwipe, 4000);
undoBar(text, change.svg);
if (change.hide) render();
}
// A swipe on an episode of `list` asks for `act`. Returns false when the row leaves the list.
function swipeDone(e, list, act, filters) {
const o = e.raw || {};
const has = d => DL && downloads[e.id] && downloads[e.id].state === d;
if (act === 'queue') {
if (inQueue(e.id)) { toast('Schon in der Warteschlange'); return true; }
// Queued episodes leave „Neu“ for good (AntennaPod's rule): sent only after the 4 s.
const hide = list === 'inbox';
offerUndo(e, 'Eingereiht', {hide, run: () => {
N.podcastEnqueue(JSON.stringify([o]), '', currentId());
o.in_queue = true;
if (!hide) render();
}});
return !hide;
}
if (act === 'dequeue') {
if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; }
const hide = list === 'queue';
offerUndo(e, dlDone(e.id) ? 'Aus Warteschlange · Datei bleibt' : 'Aus Warteschlange',
{hide, svg: 'listx', run: () => { N.podcastDequeue(e.id); o.in_queue = false; }});
return !hide;
}
if (act === 'download') {
if (!(o.audio_url || o.episode__audio_url)) { toast('Diese Folge lässt sich nicht laden'); return true; }
if (downloads[e.id] && downloads[e.id].state !== 'failed') {
toast(has('done') ? 'Schon heruntergeladen' : 'Wird schon geladen');
return true;
}
dlStart(e);
offerUndo(e, 'Wird heruntergeladen', {undo: () => N.podcastDeleteDownload(e.id)});
return true;
}
if (act === 'delete') {
if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; }
const running = !has('done') && !has('failed');
// "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too.
const out = !running && deleteDequeues(e.id);
const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out);
offerUndo(e, running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht',
{hide, svg: running ? null : 'trash', run: () => dlDelete(e, running, true)});
return !hide;
}
if (act === 'played') {
const played = !(o.played || e.played);
const hide = played
? list === 'inbox' || (list === 'queue' && devicePrefs().remove_played !== false) ||
filters.includes('unplayed') || filters.includes('in_progress')
: filters.includes('played');
offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => {
N.podcastMarkPlayed(e.id, played);
o.played = played;
if (played) o.position_seconds = 0;
if (!hide) render();
}});
return !hide;
}
if (act === 'favorite') {
toggleFavorite(e, star => {
o.favorite = star;
offerUndo(e, star ? '★ Favorit' : 'Kein Favorit mehr', {undo: () => toggleFavorite(e, s => { o.favorite = s; }, true)});
}, true);
return true;
}
if (act === 'dismiss') {
// „Neu“ is every unplayed episode not taken out (the page knows only the first ones).
if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; }
const hide = list === 'inbox';
offerUndo(e, 'Aus Neu entfernt', {hide, svg: 'listx', run: () => N.podcastDismiss(JSON.stringify([e.id]))});
return !hide;
}
if (act === 'discard' && N.podcastDiscard) return discardOne(e, list, filters);
return true;
}
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 + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 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: 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
discardAction(),
]));
} 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, list: 'queue'});
if (!selected) {
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', svg: 'listx', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
DL && {label: 'Laden', run: ids => downloadMany(chosen(ids))},
discardAction(),
]));
} else {
const head = el('div', 'nui-pod-head');
const total = inboxTotal();
head.append(el('div', 'nui-pod-summary', `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` +
(total > items.length ? ` · die ${items.length} neuesten` : '')),
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
root.append(head);
}
const list = el('div', 'nui-pod-list');
for (const e of items) list.append(episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
root.append(list);
}
// The subscriptions: sorted, each with a number (new, unplayed or downloaded
// episodes), both remembered on this device.
const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || ''));
const byTitle = (a, b) => plain(a.title).localeCompare(plain(b.title), 'de', {sensitivity: 'base'});
const FEED_SORTS = [
{id: 'latest', label: 'Neueste Folge zuerst', cmp: byDate('latest_episode_at')},
{id: 'title', label: 'Nach Titel', cmp: byTitle},
{id: 'count', label: 'Nach Zähler', hint: 'die meisten zuerst',
cmp: (a, b) => (feedCount(b) || 0) - (feedCount(a) || 0) || byTitle(a, b)},
{id: 'played', label: 'Zuletzt gehört', cmp: (a, b) => byDate('last_played_at')(a, b) || byDate('latest_episode_at')(a, b)},
{id: 'added', label: 'Zuletzt abonniert', cmp: byDate('added_at')},
];
const COUNTERS = [
{id: 'new', label: 'Neue Folgen', hint: 'wie in „Neu“', value: f => f.new_count},
{id: 'unplayed', label: 'Ungehörte Folgen', value: f => f.unplayed_count},
{id: 'downloaded', label: 'Geladene Folgen', app: true,
value: f => Object.values(downloads).filter(d => d.state === 'done' && d.feedId === f.id).length},
{id: 'none', label: 'Keine Zahl', value: () => null},
];
let feedSort = 'latest';
let feedCounter = 'new';
try {
feedSort = localStorage.getItem('nui_pod_feedsort') || feedSort;
feedCounter = localStorage.getItem('nui_pod_counter') || feedCounter;
} catch (e) {}
function counter() {
return COUNTERS.find(c => c.id === feedCounter && (!c.app || DL)) || COUNTERS[0];
}
function feedCount(f) {
const c = counter();
return c.id === 'none' ? COUNTERS[1].value(f) : c.value(f);
}
function feedSortMenu() {
const pick = (key, value) => {
if (key === 'sort') feedSort = value; else feedCounter = value;
try { localStorage.setItem(key === 'sort' ? 'nui_pod_feedsort' : 'nui_pod_counter', value); } catch (e) {}
render();
};
openSheet('Abos sortieren', FEED_SORTS.map(s => ({
icon: s.id === feedSort ? '●' : '○', label: s.label, hint: s.hint || null, active: s.id === feedSort,
action: () => pick('sort', s.id),
})).concat([{icon: '#', label: 'Zahl am Podcast', hint: counter().label, action: () => openSheet('Zahl am Podcast',
COUNTERS.filter(c => !c.app || DL).map(c => ({
icon: c.id === counter().id ? '●' : '○', label: c.label, hint: c.hint || null, active: c.id === counter().id,
action: () => pick('counter', c.id),
})))}]));
}
function feedRow(f) {
const row = el('div', 'nui-pod-row');
row.append(art(f.artwork_url, null, null, f.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(f.title)));
const meta = [];
if (f.author) meta.push(plain(f.author));
if (feedSort === 'played' && f.last_played_at) meta.push('gehört ' + day(f.last_played_at));
else 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'));
const n = counter().value(f);
if (n) {
const c = el('span', 'nui-pod-count nui-pod-feedcount', String(n));
c.title = `${n} ${counter().label.replace(' Folgen', '').toLowerCase()}`;
row.append(c);
}
row.addEventListener('click', () => openFeed(f));
return row;
}
function renderFeeds() {
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
const items = feeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b));
if (!items.length) {
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
return;
}
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)]));
root.append(head);
const list = el('div', 'nui-pod-list');
for (const f of items) list.append(feedRow(f));
root.append(list);
}
async function openFeed(f) {
feedView = {feed: f, episodes: null};
selected = 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();
}
}
}
// One podcast: cover, title, author, ⓘ and ⚙, one quiet line with what is set
// differently from the defaults, then its episodes. All its settings are in the ⚙ sheet.
function renderFeed() {
const f = feedView.feed;
const live = feeds().find(x => x.id === f.id) || f;
const head = el('div', 'nui-pod-feedhead');
head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; selected = null; render(); }, 'nui-pod-back-btn'),
art(f.artwork_url, 'big', null, f.title));
const info = el('div', 'nui-pod-main');
info.append(el('div', 'nui-pod-title', plain(f.title)));
if (f.author) info.append(el('div', 'nui-pod-meta', plain(f.author)));
head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn'));
if (feedHasSettings(live)) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear'));
root.append(head);
const line = feedHasSettings(live) ? feedSummary(f, live) : '';
if (line) {
const s = el('button', 'nui-pod-feedsum', line);
s.type = 'button';
s.title = 'Einstellungen für diesen Podcast';
s.addEventListener('click', () => feedSettingsSheet(f));
root.append(s);
}
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.'));
}
renderFeedEpisodes(f);
}
function feedHasSettings(live) {
return !!N.podcastFeedSettings || !!N.podcastAutoQueue || !!N.podcastFeedPrefs;
}
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, outro, volume): the server's where it keeps them, else this device's.
function feedOwn(f) {
const live = feeds().find(x => x.id === f.id) || f;
return '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 NEW_WHERE = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
function filterWords(live) {
return [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(', ');
}
// "1,3× · Intro 20 s · ohne „Trailer“ · neue Folgen → Warteschlange": only what differs from the defaults.
function feedSummary(f, live) {
const own = N.podcastFeedPrefs ? feedOwn(f) : {};
const parts = [];
if (own.speed) parts.push(speedLabel(own.speed));
if (own.intro) parts.push(`Intro ${own.intro} s`);
if (own.outro) parts.push(`Outro ${own.outro} s`);
if (volumeLabel(own.volume)) parts.push('Lautstärke ' + volumeLabel(own.volume));
if ('skip_silence' in live && live.skip_silence != null) parts.push(live.skip_silence ? 'Stille überspringen' : 'Stille nie überspringen');
const words = filterWords(live);
if (words) parts.push(words);
if (live.new_episodes === 'queue' || (!('new_episodes' in live) && live.auto_queue)) parts.push('neue Folgen → Warteschlange');
else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends');
if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download');
if (live.notify) parts.push('Benachrichtigen');
return parts.join(' · ');
}
// All of a podcast's settings in one sheet; after a change it opens again with the new values.
function feedSettingsSheet(f) {
const live = feeds().find(x => x.id === f.id) || f;
const title = plain(live.title || f.title);
const again = () => setTimeout(() => { if (feedView && feedView.feed.id === f.id && !preview) feedSettingsSheet(f); }, 180);
const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); setTimeout(render, 50); again(); };
const choose = (head, choices) => () => openSheet(head, choices);
const items = [];
const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
if ('new_episodes' in live && N.podcastFeedSettings) {
const ne = live.new_episodes || 'inbox';
items.push({icon: '◇', label: 'Neue Folgen', hint: NEW_WHERE[ne] || NEW_WHERE.inbox, active: ne !== 'inbox',
action: choose(`Neue Folgen von „${title}“ …`, [
{icon: ne === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', active: ne === 'inbox', action: () => save({new_episodes: 'inbox'})},
{icon: ne === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, active: ne === 'queue', action: () => save({new_episodes: 'queue'})},
{icon: ne === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', active: ne === 'ignore',
action: () => save({new_episodes: 'ignore'})},
])});
// AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves.
if ('auto_download' in live && N.podcastDownloads) {
items.push({icon: '↓', label: 'Automatisch herunterladen', hint: live.auto_download ? 'an: neue Folgen im WLAN, im Download-Limit' : 'aus',
active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})});
}
// AntennaPod's episode notifications: the phone tells when a new episode is in „Neu“.
if ('notify' in live) {
items.push({icon: '◔', label: 'Benachrichtigen', hint: live.notify ? 'an: eine Mitteilung bei jeder neuen Folge' : 'aus',
active: !!live.notify, action: () => save({notify: !live.notify})});
}
const words = filterWords(live);
const ask = async (key, head, now) => {
const v = typeof customPrompt === 'function' ? await customPrompt(head, now || '') : null;
if (v != null) save({[key]: v.trim()}); else again();
};
items.push({icon: '⊻', label: 'Filter für neue Folgen', hint: words || 'keiner', active: !!words,
action: choose('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: choose('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',
active: (live.min_duration || 0) === m, action: () => save({min_duration: m}),
})))},
])});
} else if (N.podcastAutoQueue) {
items.push({icon: live.auto_queue ? '☑' : '☐', label: 'Neue Folgen automatisch einreihen', hint: pos, active: !!live.auto_queue,
action: () => { N.podcastAutoQueue(f.id, !live.auto_queue); again(); }});
}
if (N.podcastFeedPrefs) {
const own = feedOwn(f);
const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); again(); };
const seconds = (key, head) => choose(head, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({
icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', active: (own[key] || 0) === v,
action: () => set(key, v),
})));
items.push(
{icon: '»', label: 'Tempo', hint: own.speed ? speedLabel(own.speed) : 'wie alle Podcasts', active: !!own.speed,
action: choose(`Geschwindigkeit für „${title}“`, speedChoices(own.speed).map(v => ({
icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v), active: Math.abs((own.speed || 0) - v) < 0.01,
action: () => set('speed', v)}))
.concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}])
.concat(presetsEditItem()))},
{icon: '⇥', label: 'Intro überspringen', hint: own.intro ? `${own.intro} Sekunden` : 'aus', active: !!own.intro,
action: seconds('intro', 'Am Anfang jeder Folge überspringen')},
{icon: '⇤', label: 'Outro überspringen', hint: own.outro ? `${own.outro} Sekunden` : 'aus', active: !!own.outro,
action: seconds('outro', 'Am Ende jeder Folge überspringen')},
{icon: '◖', label: 'Lautstärke', hint: volumeLabel(own.volume) || 'normal', active: !!volumeLabel(own.volume),
action: choose(`Lautstärke für „${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, active: (own.volume || 1) === v, action: () => set('volume', v)})))},
);
}
// Skip silence for this podcast (server), where this player can skip silence at all.
if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) {
const mine = live.skip_silence;
const device = !!devicePrefs().skip_silence;
items.push({icon: '∿', label: 'Stille', hint: mine === true ? 'überspringen' : mine === false ? 'nie überspringen' : 'wie eingestellt',
active: mine != null, action: choose(`Stille in „${title}“ …`, [
{icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)',
active: mine == null, action: () => save({skip_silence: null})},
{icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', active: mine === true, action: () => save({skip_silence: true})},
{icon: mine === false ? '●' : '○', label: 'Nie überspringen', active: mine === false, action: () => save({skip_silence: false})},
])});
}
// The list's order (its filters are the chips above the episodes).
const prefs = feedListPrefs(f.id);
const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new';
items.push({icon: '⇅', label: 'Folgen sortieren', hint: sortLabel(sort), active: sort !== 'new',
action: () => sortMenuFor(sort, false, id => {
saveFeedListPrefs(f.id, {filters: (prefs.filters || []).slice(), sort: id});
render();
again();
})});
items.push({custom: el('div', 'nui-pod-sheetsep')});
items.push({icon: '✕', label: 'Abo beenden', hint: 'fragt noch einmal nach', action: () => confirmUnsubscribe(f)});
openSheet(`Einstellungen für „${title}“`, items);
}
// Unsubscribing (server: POST /podcasts/feeds/<id>/remove/). Asked once more; then the
// server forgets the podcast with its episodes, and the app deletes their downloads
// (not those of favourites).
function confirmUnsubscribe(f) {
if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
openSheet(`„${plain(f.title)}“ nicht mehr abonnieren?`, [
{custom: el('div', 'nui-pod-meta nui-pod-sheetnote', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt ' +
'und Favoriten, auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : ''))},
{icon: '✕', label: 'Abo beenden', danger: true, action: () => unsubscribe(f)},
{icon: '‹', label: 'Abbrechen', action: () => feedSettingsSheet(f)},
]);
}
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; }
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 prefs = feedListPrefs(f.id);
const filters = (prefs.filters || []).filter(id => FILTERS.some(x => x.id === id && !x.all && (!x.app || DL)));
const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new';
const all = feedView.episodes;
const items = sortEpisodes(all.filter(o => matchesAll(o, filters)), sort).map(ep);
if (selected) {
root.append(episodeSelection(items));
} else {
const save = changes => { saveFeedListPrefs(f.id, Object.assign({filters, sort}, changes)); render(); };
root.append(filterRow(filters, next => save({filters: next}), false));
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', (items.length === all.length ? nEpisodes(all.length)
: `${items.length} von ${nEpisodes(all.length)}`) + ' · ' + sortLabel(sort).toLowerCase()),
listTools([
button('⇅', 'Sortieren', () => sortMenuFor(sort, false, id => save({sort: id}))),
items.length > 0 && button('☐', 'Mehrere auswählen', () => startSelecting()),
]));
root.append(head);
}
if (!items.length) { empty(all.length ? noneText(filters) : 'Dieser Podcast hat noch keine Folgen.'); return; }
const list = el('div', 'nui-pod-list');
for (const e of items) {
const o = e.raw;
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row);
}
root.append(list);
}
// A podcast's list: its filters and order, per podcast. On the server (episode_sort,
// episode_filter: the same on every device) where it knows them; else on this device.
const serverLists = live => !!live && 'episode_sort' in live && !!N.podcastFeedSettings;
function feedListPrefs(id) {
const live = feeds().find(x => x.id === id);
if (serverLists(live)) {
return {sort: live.episode_sort || 'new', filters: Array.isArray(live.episode_filter) ? live.episode_filter : []};
}
try { return JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}')[id] || {}; } catch (e) { return {}; }
}
function saveFeedListPrefs(id, prefs) {
const live = feeds().find(x => x.id === id);
if (serverLists(live)) {
const sort = prefs.sort === 'new' ? '' : prefs.sort;
// Shows at once; the host sends it on.
live.episode_sort = sort;
live.episode_filter = prefs.filters.slice();
N.podcastFeedSettings(id, JSON.stringify({episode_sort: sort, episode_filter: prefs.filters}));
return;
}
try {
const all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || '{}');
if (!prefs.filters.length && prefs.sort === 'new') delete all[id]; else all[id] = prefs;
localStorage.setItem('nui_pod_feedlists', JSON.stringify(all));
} catch (e) {}
}
// Once the server keeps the lists: what this device remembered goes there (where the
// server has nothing set for that podcast yet), then the device forgets it.
function moveFeedListsToServer() {
if (!N.podcastFeedSettings) return;
let all = null;
try { all = JSON.parse(localStorage.getItem('nui_pod_feedlists') || 'null'); } catch (e) { all = null; }
if (!all) return;
const list = feeds();
if (!list.length || !list.some(f => 'episode_sort' in f)) return;
try { localStorage.removeItem('nui_pod_feedlists'); } catch (e) { return; }
for (const id of Object.keys(all)) {
const live = list.find(f => String(f.id) === id);
const p = all[id] || {};
if (!serverLists(live) || live.episode_sort || (live.episode_filter || []).length) continue;
const filters = (Array.isArray(p.filters) ? p.filters : []).filter(x => FILTERS.some(y => y.id === x && !y.all));
const sort = EP_SORTS.some(s => s.id === p.sort && !s.all) ? p.sort : 'new';
if (filters.length || sort !== 'new') saveFeedListPrefs(live.id, {filters, sort});
}
}
// ---- a podcast's info page: description, website, feed address, numbers ----------------
// In the shownotes' sheet (closeNotes closes it, also with the back button).
function openFeedInfo(f) {
const live = Object.assign({}, f, feedView && feedView.feed.id === f.id ? feedView.feed : {},
feeds().find(x => x.id === f.id) || {});
closeNotes();
notesSheet = el('div');
notesSheet.id = 'nui-pod-player';
notesSheet.className = 'nui-pod-notes-sheet nui-pod-info';
const panel = el('div', 'nui-pod-player-panel');
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Schließen', closeNotes));
const head = el('div', 'nui-pod-feedhead');
const title = el('div', 'nui-pod-main');
title.append(el('div', 'nui-pod-title', plain(live.title)));
if (live.author) title.append(el('div', 'nui-pod-meta', plain(live.author)));
head.append(art(live.artwork_url, 'big', null, live.title), title);
panel.append(top, head);
// Feed descriptions may carry HTML: only its text.
const text = live.description ? (new DOMParser().parseFromString(live.description, 'text/html').body.textContent || '').trim() : '';
if (text) panel.append(el('div', 'nui-pod-info-desc', text));
else if (feedView && feedView.feed.id === f.id && feedView.episodes == null) {
panel.append(el('div', 'nui-pod-meta', 'Beschreibung wird geladen …'));
}
const facts = el('dl', 'nui-pod-facts');
const fact = (label, ...value) => {
if (!value.length || value[0] == null || value[0] === '') return;
const dd = el('dd');
dd.append(...value.map(v => (typeof v === 'string' ? document.createTextNode(v) : v)));
facts.append(el('dt', null, label), dd);
};
const when = iso => (iso ? day(iso) + ', ' + new Date(iso).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}) : null);
if (live.link && /^https?:\/\//i.test(live.link)) {
const a = el('a', null, live.link.replace(/^https?:\/\/(www\.)?/i, '').replace(/\/$/, ''));
a.href = live.link;
a.target = '_blank';
a.rel = 'noopener';
fact('Website', a);
}
if (live.rss_url) {
const row = el('div', 'nui-pod-info-actions');
const action = (label, onClick) => {
const b = el('button', 'btn', label);
b.type = 'button';
b.addEventListener('click', onClick);
row.append(b);
};
action('Kopieren', () => copyText(live.rss_url));
if (navigator.share) action('Teilen', () => navigator.share({title: plain(live.title), url: live.rss_url}).catch(() => {}));
fact('Feed', el('span', 'nui-pod-info-url', live.rss_url), row);
}
const count = live.episode_count != null ? live.episode_count : feedView && feedView.feed.id === f.id && feedView.episodes
? feedView.episodes.length : null;
if (count != null) {
fact('Folgen', [String(count), live.unplayed_count != null && `${live.unplayed_count} ungehört`,
live.new_count && `${live.new_count} neu`].filter(Boolean).join(' · '));
}
if (live.latest_episode_at) fact('Neueste Folge', day(live.latest_episode_at));
if (live.last_played_at) fact('Zuletzt gehört', day(live.last_played_at));
if (live.added_at) fact('Abonniert', new Date(live.added_at).toLocaleDateString('de-DE', {day: 'numeric', month: 'long', year: 'numeric'}));
fact('Abgerufen', when(live.last_refreshed_at) || 'noch nie');
if (live.last_error) {
fact('Fehler', el('span', 'nui-pod-feederr', `⚠ ${live.last_error}${live.last_error_at ? ' (' + day(live.last_error_at) + ')' : ''}` +
(live.error_count > 1 ? ` · ${live.error_count}× nacheinander` : '')));
}
panel.append(facts);
notesSheet.append(panel);
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
document.body.append(notesSheet);
}
async function copyText(text) {
try {
await navigator.clipboard.writeText(text);
toast('Kopiert');
return;
} catch (e) {}
const t = el('textarea');
t.value = text;
t.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.append(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (e) {}
t.remove();
toast(ok ? 'Kopiert' : 'Kopieren ging nicht');
}
async function unsubscribe(f) {
if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
toast('Abo wird beendet …');
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) {
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);
}
// The downloads list: an episode list with just the filter „Geladen“ (its button plays, its swipes delete).
const downloadsOnly = filters => DL && filters.length === 1 && filters[0] === 'downloaded';
// ---- all episodes, filtered (server: GET /podcasts/episodes/) ----------------------
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
let epFilters = [];
let epSort = 'new';
try {
const kept = localStorage.getItem('nui_pod_filters');
if (kept != null) epFilters = JSON.parse(kept);
else {
// Before filters combined: one of them.
const old = localStorage.getItem('nui_pod_filter');
epFilters = old == null ? ['unplayed'] : old === 'all' ? [] : [old];
if (old === 'history') epSort = 'played';
}
epSort = localStorage.getItem('nui_pod_epsort') || epSort;
} catch (e) {}
if (!Array.isArray(epFilters)) epFilters = [];
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || !!N.podcastDownloads)));
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'new';
let epList = null; // {key, items, total, loading, error}
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort;
function setEpisodeList(filters, sort) {
// The history goes by the last listening; leaving it, by date again.
if (sort == null) {
sort = epSort;
if (filters.includes('history') && !epFilters.includes('history') && sort === 'new') sort = 'played';
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'new';
}
epFilters = filters;
epSort = sort;
try {
localStorage.setItem('nui_pod_filters', JSON.stringify(epFilters));
localStorage.setItem('nui_pod_epsort', epSort);
} catch (e) {}
loadEpisodes(false);
}
async function loadEpisodes(append, quiet) {
const key = epKey();
const offset = append && epList && epList.key === key ? epList.items.length : 0;
if (!append) epList = {key, items: [], total: 0, loading: true};
else epList.loading = true;
if (!quiet) render();
const params = new URLSearchParams({
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: epSort, limit: '100', offset: String(offset),
});
if (epFilters.includes('downloaded')) {
const ids = Object.keys(downloads).filter(id => downloads[id].state === 'done');
if (!ids.length) { epList = {key, items: [], total: 0}; render(); return; }
params.set('ids', ids.join(','));
}
try {
const r = await fetch(`/podcasts/episodes/?${params}`);
if (r.status === 400) throw new Error('old');
if (!r.ok) throw new Error(r.status);
const d = await r.json();
if (!epList || epList.key !== key) return;
epList = {key, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0};
} catch (e) {
if (!epList || epList.key !== key) return;
epList.loading = false;
epList.error = e.message === 'old' ? 'Der Server kennt diese Filter noch nicht.' : 'Die Folgenliste braucht eine Verbindung.';
}
render();
}
function renderEpisodes() {
if (!selected) root.append(filterRow(epFilters, next => setEpisodeList(next), true));
if (!epList || epList.key !== epKey()) { loadEpisodes(false, true); empty('Wird geladen …'); return; }
if (epList.error && !epList.items.length) { empty(epList.error); return; }
// What changed here since (marked, starred, queued) shows at once.
const items = epList.items.filter(o => matchesAll(o, epFilters)).map(ep);
const total = Math.max(items.length, epList.total - (epList.items.length - items.length));
if (!items.length) {
empty(epList.loading ? 'Wird geladen …' : noneText(epFilters));
return;
}
if (selected) {
root.append(episodeSelection(items));
} else {
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`),
listTools([
button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))),
button('☐', 'Mehrere auswählen', () => startSelecting()),
]));
root.append(head);
}
const list = el('div', 'nui-pod-list');
for (const e of items) {
const o = e.raw;
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
filters: epFilters});
if (epSort === 'played' && o.last_played_at) {
const m = row.querySelector('.nui-pod-meta');
const mark = m.querySelector('.nui-pod-dlmark');
m.textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || '');
if (mark) m.prepend(mark);
}
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
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(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, quiet) {
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();
if (!quiet) 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 (epList) {
const item = epList.items.find(x => x.id === e.id);
if (item) item.favorite = d.favorite;
}
// The ★ in the lists (Folgen, one podcast, search) and their filters.
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, null, null, f.title));
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();
}
// ---- search: own podcasts, their episodes (also in the shownotes), the directory ----
// searchResults: {q, feeds, eps: {items, total, loading, local}, dir: {items, loading, error}}.
// Without a connection the episodes come from what this device knows (queue,
// "Neu", lists seen), the podcasts from the kept state.
let searchDraft = null;
function fold(s) {
return plain(s || '').toLocaleLowerCase('de').normalize('NFD').replace(/[̀-ͯ]/g, '');
}
// All words of the query, anywhere in the text.
function matcher(q) {
const words = fold(q).split(/\s+/).filter(Boolean);
return text => words.every(w => text.includes(w));
}
function knownEpisodes() {
const seen = new Map();
const add = o => { if (o && !seen.has(shape(o).id)) seen.set(shape(o).id, o); };
queue().forEach(add);
inbox().forEach(add);
if (epList) epList.items.forEach(add);
if (feedView && feedView.episodes) feedView.episodes.forEach(add);
return [...seen.values()];
}
function searchText(o) {
const e = shape(o);
const notes = o.description ? o.description.replace(/<[^>]*>/g, ' ') : '';
return fold(e.title + ' ' + notes);
}
function searchNote(text) {
root.append(el('div', 'nui-pod-sec-note', text));
}
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 = 'Abos, Folgen, Verzeichnis – oder Feed-Adresse …';
input.setAttribute('enterkeyhint', 'search');
input.value = searchDraft != null ? searchDraft : searchResults ? searchResults.q : '';
input.addEventListener('input', () => { searchDraft = input.value; });
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);
const s = searchResults;
if (!s) {
empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' +
'Eine Feed-Adresse (https://…) abonnierst du direkt.');
return;
}
if (s.feeds.length) {
root.append(el('div', 'nui-pod-sec', `Deine Podcasts (${s.feeds.length})`));
const list = el('div', 'nui-pod-list');
for (const f of s.feeds) list.append(feedRow(f));
root.append(list);
}
const eps = s.eps;
root.append(el('div', 'nui-pod-sec', 'Folgen' + (eps.items.length ? ` (${eps.total})` : '')));
if (eps.local) searchNote('Ohne Verbindung: nur Folgen, die dieses Gerät kennt.');
if (!eps.items.length) {
searchNote(eps.loading ? 'Wird gesucht …' : 'Keine Folgen gefunden.');
} else {
const list = el('div', 'nui-pod-list');
for (const o of eps.items) {
const e = ep(o);
const row = episodeRow(e, {showFeed: true, showDate: true, noSelect: true});
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
list.append(row);
}
root.append(list);
if (!eps.local && eps.items.length < eps.total) {
const more = el('button', 'btn nui-pod-more', eps.loading ? 'Wird geladen …' : 'Weitere Folgen');
more.type = 'button';
more.disabled = !!eps.loading;
more.addEventListener('click', () => searchEpisodes(s, true));
root.append(more);
}
}
const dir = s.dir;
root.append(el('div', 'nui-pod-sec', 'Im Podcast-Verzeichnis'));
if (dir.loading) { searchNote('Wird gesucht …'); return; }
if (dir.error) { searchNote(dir.error); return; }
if (!dir.items.length) { searchNote('Nichts gefunden.'); return; }
const subscribed = new Set(feeds().map(f => f.rss_url));
const list = el('div', 'nui-pod-list');
for (const r of dir.items) {
const row = el('div', 'nui-pod-row');
row.append(art(r.artwork_url, null, null, r.title));
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);
}
function search(q) {
if (!q) return;
if (/^https?:\/\//i.test(q)) { if (N.podcastPreview) openPreview(q); else subscribe(q, q); return; }
searchDraft = null;
const test = matcher(q);
const s = searchResults = {
q,
feeds: feeds().filter(f => test(fold(f.title) + ' ' + fold(f.author))),
eps: {items: [], total: 0, loading: true},
dir: {items: [], loading: true},
};
render();
searchEpisodes(s, false);
searchDirectory(s);
}
async function searchEpisodes(s, more) {
const offset = more ? s.eps.items.length : 0;
s.eps.loading = true;
if (more) render();
try {
const r = await fetch(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}&notes=1&limit=30&offset=${offset}`);
if (!r.ok) throw new Error(r.status);
const d = await r.json();
s.eps = {items: (more ? s.eps.items : []).concat(d.episodes || []), total: d.total || 0};
} catch (e) {
if (more) {
s.eps.loading = false;
toast('Weitere Folgen brauchen eine Verbindung.');
} else {
const test = matcher(s.q);
const items = knownEpisodes().filter(o => test(searchText(o)));
s.eps = {items, total: items.length, local: true};
}
}
if (searchResults === s) render();
}
async function searchDirectory(s) {
try {
const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(s.q));
const data = await r.json();
s.dir = {items: data.results || [], error: data.error ? 'Das Verzeichnis ist gerade nicht erreichbar.' : null};
} catch (e) {
s.dir = {items: [], error: 'Das Verzeichnis braucht eine Verbindung.'};
}
if (searchResults === s) 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);
return true;
} catch (e) {
toast(navigator.onLine ? 'Dieser Feed lässt sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
return false;
}
}
// A feed from another app (shared link, pcast:// …) or a pasted address:
// the app fetches it (N.podcastPreview, FeedPreview.kt) and the page shows
// title, cover, description and the newest episodes before subscribing.
function openPreview(url) {
if (!N.podcastPreview) { subscribe(url, url); return; }
closePlayer();
feedView = null;
selected = null;
preview = {id: ++previewSeq, url};
if (typeof showTab === 'function') showTab('podcasts');
render();
scrollTop(0);
N.podcastPreview(preview.id, url);
}
function closePreview() {
if (!preview) return false;
preview = null;
render();
return true;
}
function renderPreview() {
const p = preview;
const d = p.data || {};
const head = el('div', 'nui-pod-feedhead');
head.append(button('‹', 'Zurück', closePreview), art(d.artwork, 'big', null, d.title));
const info = el('div', 'nui-pod-main');
info.append(el('div', 'nui-pod-title', plain(d.title) || 'Podcast'),
el('div', 'nui-pod-meta', plain(d.author) || p.url));
head.append(info);
root.append(head);
if (p.choices) {
root.append(el('div', 'nui-pod-empty', 'Diese Seite verlinkt mehrere Feeds. Welcher ist es?'));
const list = el('div', 'nui-pod-list');
for (const c of p.choices) {
const row = el('div', 'nui-pod-row');
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(c.title) || c.url), el('div', 'nui-pod-meta', c.url));
row.append(main);
row.addEventListener('click', () => openPreview(c.url));
list.append(row);
}
root.append(list);
return;
}
if (p.error) {
// The server may still manage (it fetches the feed itself).
const box = el('div', 'nui-pod-offer', p.error);
const actions = el('div', 'nui-pod-offer-row');
const anyway = el('button', 'btn', 'Trotzdem abonnieren');
anyway.type = 'button';
anyway.addEventListener('click', () => subscribeFromPreview(p.url, p.url, anyway));
actions.append(anyway);
box.append(actions);
root.append(box);
return;
}
if (!p.data) { empty('Feed wird geladen …'); return; }
const known = feeds().find(f => f.rss_url === d.url || f.rss_url === p.url);
const actions = el('div', 'nui-pod-offer-row');
const go = el('button', 'btn', known ? 'Abonniert – öffnen' : 'Abonnieren');
go.type = 'button';
go.addEventListener('click', () => {
if (known) { preview = null; openFeed(known); return; }
subscribeFromPreview(d.url, d.title, go);
});
actions.append(go);
root.append(actions);
if (d.description) {
const desc = el('div', 'nui-pod-offer', plain(d.description));
desc.style.whiteSpace = 'pre-line';
root.append(desc);
}
const eps = d.episodes || [];
if (eps.length) {
const list = el('div', 'nui-pod-list');
for (const e of eps) {
const row = el('div', 'nui-pod-row');
row.append(art(e.artwork, null, d.artwork, d.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(e.title)),
el('div', 'nui-pod-meta', [e.duration && minutes(e.duration), day(e.date)].filter(Boolean).join(' · ')));
row.append(main);
list.append(row);
}
root.append(list);
}
}
async function subscribeFromPreview(url, title, btn) {
btn.disabled = true;
const ok = await subscribe(url, title);
btn.disabled = false;
if (!ok) return;
preview = null;
view = 'feeds';
try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
render();
scrollTop(0);
}
// ---- drag to reorder (touch) -----------------------------------------------------------
// Dragging the handle (≡) sorts the queue — once the move is mostly vertical (after the same
// 12 px the swipe waits for); a mostly sideways one is left to the row's swipe (rowGestures).
function dragSort(handle, row, list) {
handle.addEventListener('pointerdown', ev => {
if (ev.pointerType === 'mouse' && ev.button !== 0) return;
if (ev.pointerType === 'mouse') ev.preventDefault(); // no text selection while dragging
const id = ev.pointerId;
const startX = ev.clientX;
const startY = ev.clientY;
let rows = null;
let from = -1;
let rects = null;
let to = -1;
const begin = () => {
rows = [...list.children];
from = rows.indexOf(row);
rects = rows.map(r => r.getBoundingClientRect());
to = from;
row.classList.add('nui-pod-dragging');
try { handle.setPointerCapture(id); } catch (e) {}
};
const move = e => {
if (e.pointerId !== id) return;
const dy = e.clientY - startY;
if (!rows) {
const dx = e.clientX - startX;
if (Math.abs(dx) <= 12 && Math.abs(dy) <= 12) return;
if (Math.abs(dy) <= Math.abs(dx)) { stop(); return; }
begin();
}
e.preventDefault();
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)` : '';
});
};
// Listened to on the window: before the drag starts the finger may leave the narrow handle.
const stop = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', end);
window.removeEventListener('pointercancel', end);
};
const end = e => {
if (e.pointerId !== id) return;
stop();
if (!rows) return;
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);
}
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', end);
window.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"]'));
}
}
// The bar names a running sleep timer: "… · ☾ 12 min".
function sleepSuffix() {
const l = sleepLabel();
return l ? ' · ' + l : '';
}
// 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) + sleepSuffix();
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') + sleepSuffix();
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(shown((player.dur - player.pos) / 1000, 0, player.speed || 1)) + ' übrig' : ''))
+ sleepSuffix();
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,
// else with `jump(seconds)` (the episode screen) they start it there.
async function showDetails(box, id, live, jump) {
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 || [];
// The full player lists the chapters right under the title (chapterBox); here only the notes.
const chapBox = live && sheet ? sheet.querySelector('.nui-pod-chapbox') : null;
if (chapBox) chapterBox(chapBox, chapters, id);
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)));
else if (jump) li.addEventListener('click', () => jump(c.start || 0));
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 || jump) {
linkStamps(notes);
notes.addEventListener('click', ev => {
const a = ev.target.closest('a.nui-pod-stamp');
if (!a) return;
ev.preventDefault();
if (live) N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
else jump(parseInt(a.dataset.t, 10));
});
}
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 && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
tab('notes', 'Shownotes');
if (d.has_transcript) tab('transcript', 'Transkript');
box.append(tabs, body);
show(chapters.length && !chapBox ? 'chapters' : 'notes');
}
// The chapters in the full player: which one plays, previous / next (like AntennaPod's
// chapter buttons), and the list to tap; it keeps the playing chapter in view.
function chapterBox(box, chapters, id) {
box.textContent = '';
box.dataset.episode = String(id);
box.hidden = !chapters.length;
// A smaller cover makes room for them.
if (box.parentElement) box.parentElement.classList.toggle('nui-pod-withchapters', !!chapters.length);
if (!chapters.length) return;
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));
};
const head = el('div', 'nui-pod-chaphead');
head.append(el('span', 'nui-pod-chapcount', `${chapters.length} Kapitel`));
if (chapters.length > 1) {
head.append(button('⏮︎', 'Voriges Kapitel', () => jump(-1), 'nui-pod-chapbtn'),
button('⏭︎', 'Nächstes Kapitel', () => jump(1), 'nui-pod-chapbtn'));
}
const ol = el('ol', 'nui-pod-chapters nui-pod-chaplist');
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}`)));
li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000)));
ol.append(li);
});
box.append(head, ol);
markChapter(box, (player.pos || 0) / 1000);
}
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 count = scope.querySelector('.nui-pod-chapcount');
const list = scope.querySelector('.nui-pod-chaplist');
if (count && list) {
const n = list.children.length;
count.textContent = current >= 0 ? `Kapitel ${current + 1} von ${n}` : `${n} Kapitel`;
// The playing chapter stays in view in the short list (unless it was just scrolled by hand).
if (current >= 0 && list.dataset.shown !== String(current) && Date.now() - (list.touched || 0) > 6000) {
list.dataset.shown = String(current);
const li = list.children[current];
list.scrollTop = Math.max(0, li.offsetTop - list.offsetTop - li.offsetHeight);
}
if (!list.watched) {
list.watched = true;
list.addEventListener('touchstart', () => { list.touched = Date.now(); }, {passive: true});
list.addEventListener('wheel', () => { list.touched = Date.now(); }, {passive: true});
}
}
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'});
}
}
// ---- the episode screen: a tap on a row opens it ----------------------------------------
// Cover, title, podcast (→ its page), date, length, where it stands; one big button to play;
// the actions with the swipes' names and icons; chapters (a tap plays from there) and notes.
let notesSheet = null;
let epScreen = null; // {e, ctx, actions, playBtn} while the episode screen is open
function openEpisode(e, ctx) {
closeNotes();
ctx = ctx || {};
notesSheet = el('div');
notesSheet.id = 'nui-pod-player';
notesSheet.className = 'nui-pod-notes-sheet nui-pod-episode';
const panel = el('div', 'nui-pod-player-panel');
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Schließen', closeNotes));
const head = el('div', 'nui-pod-ephead');
head.append(art(e.art, 'big', e.feedArt, e.feed));
const info = el('div', 'nui-pod-main');
info.append(el('div', 'nui-pod-player-title', e.title));
const f = e.feedId ? feeds().find(x => x.id === e.feedId) : null;
if (f) {
const link = el('button', 'nui-pod-eplink', plain(f.title) + ' ›');
link.type = 'button';
link.title = 'Zur Podcast-Seite';
link.addEventListener('click', () => {
closeNotes();
closePlayer();
preview = null;
if (typeof showTab === 'function') showTab('podcasts');
openFeed(f);
});
info.append(link);
} else if (e.feed) {
info.append(el('div', 'nui-pod-player-feed', e.feed));
}
info.append(el('div', 'nui-pod-meta nui-pod-epfacts'));
head.append(info);
const playBtn = el('button', 'btn nui-pod-notes-play nui-pod-epplay');
playBtn.type = 'button';
playBtn.addEventListener('click', () => {
if (currentId() === e.id) { N.podcastToggle(); return; }
closeNotes();
play(e);
});
const actions = el('div', 'nui-pod-epactions');
const box = el('div', 'nui-pod-notesbox');
panel.append(top, head, playBtn, actions, box);
notesSheet.append(panel);
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
document.body.append(notesSheet);
epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')};
episodeScreenParts();
showDetails(box, e.id, false, seconds => playAt(e, seconds));
}
// Start the episode at `seconds` (a chapter, a time in the notes).
function playAt(e, seconds) {
const ms = Math.round(seconds * 1000);
if (currentId() === e.id) {
N.podcastSeek(ms);
if (!player.playing) N.podcastToggle();
} else if (N.podcastPlayEpisodeAt) {
N.podcastPlayEpisodeAt(JSON.stringify(e.raw), currentId(), ms);
} else {
play(e);
}
closeNotes();
}
// The parts of the screen that follow the state: facts, the play button, the actions.
function episodeScreenParts() {
if (!epScreen || !notesSheet) return;
const {e, ctx, actions, playBtn, facts} = epScreen;
// The freshest place and queue state of the episode.
const q = queue().find(x => x.episode__id === e.id);
const pos = q ? q.position_seconds || 0 : e.raw.position_seconds || 0;
const played = !!e.raw.played;
const dur = e.duration || 0;
const parts = [];
if (e.date) parts.push(day(e.date));
if (dur) parts.push(minutes(shown(dur, e.feedId)));
if (played) parts.push('gehört');
else if (pos > 30 && dur) parts.push('noch ' + minutes(shown(Math.max(0, dur - pos), e.feedId)));
facts.textContent = parts.join(' · ');
const now = currentId() === e.id;
playBtn.textContent = now && player.playing ? '❚❚ Pause'
: !played && pos > 5 ? `▶ Weiter ab ${clock(pos)}` : '▶ Abspielen';
actions.textContent = '';
const act = (svg, icon, label, run, cls) => {
const b = el('button', 'nui-pod-epact' + (cls ? ' ' + cls : ''));
b.type = 'button';
b.append(svg ? svgIcon(svg, 22) : el('span', 'nui-pod-epact-icon', icon), el('span', null, label));
b.addEventListener('click', ev => { ev.stopPropagation(); run(); setTimeout(episodeScreenParts, 150); });
actions.append(b);
};
if (inQueue(e.id)) act('listx', '', 'Aus Warteschlange', () => { N.podcastDequeue(e.id); e.raw.in_queue = false; });
else act(null, '+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; });
if (DL && (e.raw.audio_url || e.raw.episode__audio_url)) {
const d = downloads[e.id];
if (!d || d.state === 'failed') act(null, '↓', 'Laden', () => dlStart(e));
else if (d.state === 'done') act('trash', '', 'Datei löschen', () => dlDelete(e));
else act('trash', '', 'Download abbrechen', () => dlDelete(e, true));
}
if (N.podcastDiscard) {
act('trash', '', 'Folge löschen', () => { closeNotes(); discardOne(e, ctx.list || '', ctx.filters || []); }, 'nui-pod-epact-danger');
}
const fav = e.raw.favorite;
act(null, fav ? '★' : '☆', 'Favorit', () => toggleFavorite(e, star => { e.raw.favorite = star; episodeScreenParts(); }),
fav ? 'on' : '');
if (played) act(null, '↺', 'Ungehört', () => { N.podcastMarkPlayed(e.id, false); e.raw.played = false; });
else act(null, '✓', 'Gehört', () => { markPlayed(e); e.raw.played = true; e.raw.position_seconds = 0; });
}
function closeNotes() {
if (!notesSheet) return false;
notesSheet.remove();
notesSheet = null;
epScreen = 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', null, player.feed));
const chapBox = el('div', 'nui-pod-chapbox');
chapBox.hidden = true;
panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox);
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(shown(range.value, 0, player.speed || 1));
});
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('⏭', keepSkipped() ? 'Nächste Folge (diese bleibt in der Warteschlange)' : '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;
// At the speed, when so set: the place and what's left as long as they take.
const at = s => shown(s, 0, player.speed || 1);
if (!seeking) {
range.value = pos;
sheet.querySelector('.nui-pod-time-pos').textContent = clock(at(pos));
}
sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(at(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.
// The presets, and the speed now if it isn't one of them (a podcast's own 3,5× …).
function speedChoices(now) {
const list = presets().slice();
if (now > 0 && !list.some(s => Math.abs(s - now) < 0.01)) list.push(now);
return list.sort((a, b) => a - b);
}
function presetsEditItem() {
return 'speed_presets' in settings() ? [{icon: '✎', label: 'Voreinstellungen bearbeiten', hint: 'hinzufügen, entfernen',
action: () => presetsEditor()}] : [];
}
function speedMenu() {
const own = !!player.podcastSpeed;
const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts',
speedChoices(own === forPodcast ? player.speed || 1 : 0).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)}] : [])
.concat(presetsEditItem()));
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)},
].concat(presetsEditItem()));
}
// ---- sleep timer (runs in the app, also with the screen off; episodes and the radio) ----
// The timer that counts: the other device's while that one is the active one, else ours.
function sleepState() {
const r = remote();
return (r ? r.sleep : player.sleep) || {};
}
function sleepLabel() {
const t = sleepState();
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 '';
}
const SLEEP_MINUTES = [5, 10, 15, 20, 30, 45, 60, 90];
const SLEEP_MAX = 600;
// A typed-in time: "25", "25 min", "1:30", "1,5 h" → minutes (1 to 600), else null.
function parseSleepMinutes(text) {
const t = String(text || '').trim().toLowerCase().replace(',', '.').replace(/\s*(minuten|min|m)$/, '');
let m;
const hm = /^(\d+):(\d{1,2})$/.exec(t);
const h = /^(\d+(?:\.\d+)?)\s*(h|std|stunden?)$/.exec(t);
if (hm) m = Number(hm[2]) < 60 ? Number(hm[1]) * 60 + Number(hm[2]) : NaN;
else if (h) m = Math.round(Number(h[1]) * 60);
else m = /^\d+(\.\d+)?$/.test(t) ? Math.round(Number(t)) : NaN;
return m >= 1 && m <= SLEEP_MAX ? m : null;
}
function sleepMinutesLabel(m) {
if (m === 1) return '1 Minute';
return m < 60 ? `${m} Minuten` : `${Math.floor(m / 60)} h` + (m % 60 ? ` ${m % 60} min` : '');
}
function hourOf(v, fallback) {
return Number.isInteger(v) && v >= 0 && v <= 23 ? v : fallback;
}
function sleepWindow() {
const p = devicePrefs();
const from = hourOf(p.sleep_from, 22);
const to = hourOf(p.sleep_to, 6);
return from === to ? 'ganztags' : `${from}–${to} Uhr`;
}
// Minutes count only while playing; shaking the phone starts them over. With another
// device playing, the timer is that device's (set there by the player sync).
function sleepMenu(radio) {
const r = remote();
const set = r ? (m => remoteCmd('sleep', m)) : N.podcastSleep ? (m => N.podcastSleep(m)) : null;
if (!set) return;
const onRadio = r ? r.kind === 'radio' : radio === true;
const t = sleepState();
const own = player.sleep || {};
const on = !!sleepLabel();
const items = [];
if (r) items.push({custom: el('div', 'nui-pod-meta nui-pod-devnote', `Der Sleep-Timer gilt für ${remoteName(r)}.`)});
if (on) {
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => set(0)});
if (t.leftMs > 0) {
for (const m of [5, 10]) {
items.push({icon: '+', label: `${m} Minuten länger`, action: () => (!r && N.podcastSleepExtend
? N.podcastSleepExtend(m) : set(Math.min(SLEEP_MAX, Math.ceil(t.leftMs / 60000) + m)))});
}
}
}
for (const m of SLEEP_MINUTES) {
items.push({icon: '☾', label: `${m} Minuten`, hint: !r && m === own.last && !on ? 'zuletzt' : null, action: () => set(m)});
}
if (typeof customPrompt === 'function') {
// A time of one's own that was set last comes back as its own line.
const lastOwn = !r && own.last && !SLEEP_MINUTES.includes(own.last) ? own.last : 0;
if (lastOwn) items.push({icon: '☾', label: sleepMinutesLabel(lastOwn), hint: on ? null : 'zuletzt', action: () => set(lastOwn)});
items.push({icon: '✎', label: 'Eigene Zeit …', hint: 'z. B. 25, 2 h oder 1:30', action: () => customSleep(set)});
}
if (!onRadio) {
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => set(-1)});
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => set(-2)});
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => set(-3)});
}
if (!r && N.podcastSleepAuto) {
items.push({icon: own.auto ? '☑' : '☐', label: 'Nachts von selbst',
hint: own.auto ? `an: ${sleepWindow()}, ${sleepMinutesLabel(own.last || 15)}, sobald etwas startet`
: `aus · ${sleepWindow()}`,
action: () => {
N.podcastSleepAuto(!own.auto);
own.auto = !own.auto;
toast(own.auto ? `Sleep-Timer nachts (${sleepWindow()}) von selbst` : 'Sleep-Timer nachts: aus');
}});
items.push({icon: '⚙', label: 'Einstellungen', hint: sleepSettingsHint(), action: sleepSettings});
}
openSheet('Sleep-Timer', items);
}
async function customSleep(set) {
const v = await customPrompt('Sleep-Timer: wie lange? (Minuten, z. B. 25, 2 h oder 1:30)', '');
if (v == null || !String(v).trim()) return;
const m = parseSleepMinutes(v);
if (!m) { toast('Bitte 1 Minute bis 10 Stunden, z. B. 25, 2 h oder 1:30'); return; }
set(m);
toast(`Sleep-Timer: ${sleepMinutesLabel(m)}`);
}
function sleepSettingsHint() {
const p = devicePrefs();
const parts = [`nachts = ${sleepWindow()}`];
if ('sleep_shake' in p) parts.push(p.sleep_shake ? 'schütteln an' : 'schütteln aus');
if (p.sleep_vibrate) parts.push('vibrieren');
return parts.join(' · ');
}
// The night window and, in the app, shaking and buzzing.
function sleepSettings() {
const p = devicePrefs();
const from = hourOf(p.sleep_from, 22);
const to = hourOf(p.sleep_to, 6);
const again = () => setTimeout(sleepSettings, 60);
const setHour = (key, h) => { if (N.podcastSetPrefValue) N.podcastSetPrefValue(key, h); again(); };
const items = [
{icon: '◐', label: 'Nachts beginnt um', hint: `${from} Uhr`,
action: () => hourPicker('Nachts beginnt um …', from, h => setHour('sleep_from', h))},
{icon: '◑', label: 'Nachts endet um', hint: `${to} Uhr`,
action: () => hourPicker('Nachts endet um …', to, h => setHour('sleep_to', h))},
];
if ('sleep_shake' in p && N.podcastSetPref) {
items.push({icon: p.sleep_shake ? '☑' : '☐', label: 'Schütteln startet die Zeit neu',
hint: p.sleep_shake ? 'an: Handy schütteln, und die Minuten laufen von vorn' : 'aus',
action: () => { N.podcastSetPref('sleep_shake', !p.sleep_shake); again(); }});
}
if ('sleep_vibrate' in p && N.podcastSetPref) {
items.push({icon: p.sleep_vibrate ? '☑' : '☐', label: 'Vor dem Ende vibrieren',
hint: p.sleep_vibrate ? 'an: kurz, wenn die letzten 10 s leiser werden' : 'aus',
action: () => { N.podcastSetPref('sleep_vibrate', !p.sleep_vibrate); again(); }});
}
openSheet('Sleep-Timer: Einstellungen', items);
}
function hourPicker(title, now, done) {
const hours = [];
for (let i = 0; i < 24; i++) hours.push((18 + i) % 24);
openSheet(title, hours.map(h => ({icon: h === now ? '●' : '○', label: `${h} Uhr`, action: () => done(h)})));
}
// ⏭: the next episode plays. The host decides about this one ("keep skipped"): kept
// unplayed in the queue, or played. With nothing after it, it only counts as played
// when skipped episodes aren't kept.
function finishAndNext() {
const id = currentId();
if (!id) return;
const ids = queue().map(q => q.episode__id);
const at = ids.indexOf(id);
const last = at < 0 ? !ids.length : at === ids.length - 1;
N.podcastNext();
if (last && !keepSkipped()) N.podcastMarkPlayed(id, true);
else if (last) toast('Das ist die letzte Folge der Warteschlange');
}
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'));
extra.append(button('☾', `Sleep-Timer auf ${remoteName(r)}`, () => sleepMenu(onRadio), 'nui-pod-sleep'));
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);
const sl = sheet.querySelector('.nui-pod-sleep');
sl.textContent = sleepLabel() || '☾';
sl.classList.toggle('on', !!sleepLabel());
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(), {}) : {};
// How many of the queue's first episodes load by themselves; 0 = all of them.
const DL_COUNTS = [1, 2, 3, 5, 10, 0];
const dlCountLabel = n => (n === 0 ? 'Alle' : n === 1 ? '1 Folge' : n + ' Folgen');
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, quiet) {
N.podcastDeleteDownload(e.id);
// "Löschen nimmt aus der Warteschlange" (account): the app takes it out too.
const out = !running && deleteDequeues(e.id);
if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Datei gelöscht · auch aus Warteschlange' : 'Datei gelöscht');
}
// The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here.
function dlButton(e) {
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);
return b;
}
// Elsewhere a small mark in front of the length: an arrow in a circle when it's here,
// a filling ring while it loads, ! when it failed.
function dlMark(e) {
if (!DL) return null;
const d = downloads[e.id];
if (!d) return null;
const m = el('span', 'nui-pod-dlmark nui-pod-dlmark-' + d.state);
m.dataset.dlmark = e.id;
m.title = d.state === 'done' ? 'Heruntergeladen' : d.state === 'failed' ? 'Download hat nicht geklappt' : 'Wird heruntergeladen';
m.textContent = d.state === 'done' ? '↓' : d.state === 'failed' ? '!' : '';
dlRing(m);
return m;
}
function dlRing(b) {
const d = downloads[b.dataset.dl || b.dataset.dlmark];
if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return;
b.style.setProperty('--p', (d.progress || 0) + '%');
if (b.dataset.dl) 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: '✕', svg: 'trash', label: deleteDequeues(e.id) ? 'Datei löschen · auch aus der Warteschlange' : 'Datei löschen',
hint: 'nur die Datei auf diesem Gerät · ' + 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 ? (s.count === 0 ? 'die ganze Warteschlange' : `die ersten ${s.count} Folgen der Warteschlange`) : 'aus',
action: () => dlSet({auto: !s.auto})},
s.auto && {icon: '#', label: 'Wie viele Folgen', hint: dlCountLabel(s.count), action: () => openSheet('Automatisch herunterladen',
DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: dlCountLabel(n), hint: n === 0 ? 'die ganze Warteschlange' : null,
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: '✕', svg: 'trash', label: `Alle Dateien löschen (${size(bytes)})`,
hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
action: () => openSheet(`Alle ${done.length} Dateien löschen?`, [
{icon: '✕', svg: 'trash', label: 'Alle Dateien löschen', hint: size(bytes), danger: true,
action: () => { N.podcastDeleteAllDownloads(); toast('Dateien 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], #nui-pod [data-dlmark]').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);
}
// The finished downloads: the episode list with the filter „Geladen“ (▶ plays, swipes delete).
function showDownloaded() {
closeDownloads();
closePlayer();
selected = null;
feedView = null;
preview = null;
view = 'episodes';
try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
if (typeof showTab === 'function') showTab('podcasts');
setEpisodeList(['downloaded'], 'new');
scrollTop(0);
}
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', action: () => N.podcastDeleteDownload(d.id)}
: {icon: '✕', label: 'Abbrechen', hint: d.bytes ? 'was schon geladen ist, geht mit' : null,
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'));
const row = el('div', 'nui-pod-dls-donerow');
row.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))}`),
button('Anzeigen ›', 'Die geladenen Folgen als Liste', showDownloaded, 'btn nui-pod-dls-show'));
dlBody.append(row);
}
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 ---------------------------------------------------------------
// A podcast address from another app is waiting (MainActivity): show its preview.
window.__dioraSubscribe = function () {
const url = N.podcastTakeSubscribe ? N.podcastTakeSubscribe() : '';
if (url) openPreview(url);
};
window.__dioraPreview = function (id, d) {
if (!preview || preview.id !== id) return;
if (d && d.ok) preview = {id, url: d.url, data: d};
else if (d && d.choices && d.choices.length) preview = {id, url: preview.url, choices: d.choices};
else preview = {id, url: preview.url, error: (d && d.error) || 'Dieser Feed ließ sich nicht laden.'};
render();
};
window.__dioraPodcasts = function (json) {
state = typeof json === 'string' ? parse(json, state) : json;
moveFeedListsToServer();
render();
};
// The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream).
window.__dioraAskStream = function (id, title) {
if (!N.podcastAllowStream) return;
openSheet('Über mobile Daten streamen?', [
{icon: '▶', label: 'Ja, diese Folge streamen', hint: plain(title) || null, action: () => N.podcastAllowStream(id)},
{icon: '✕', label: 'Nein', hint: 'im WLAN oder heruntergeladen hören', action: () => {}},
]);
};
// A new-episode notification was tapped: that podcast's page (gone meanwhile: „Neu“).
window.__dioraOpenFeed = function (feedId) {
if (typeof showTab !== 'function') return;
closeStats();
closeDownloads();
closeNotes();
closePlayer();
selected = null;
showTab('podcasts');
const f = feeds().find(x => x.id === feedId);
if (f) {
openFeed(f);
} else {
feedView = null;
view = 'inbox';
render();
scrollTop(0);
}
};
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};
// Something starts playing here (an episode, the radio): Ambient stops (app.js).
if (player.playing && !before.playing && !player.remote && window.__dioraAmbientStop) window.__dioraAmbientStop();
renderMini();
renderPlayer();
// The current episode changed: the queue view marks it.
if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
else if (!!before.playing !== !!player.playing) playButtons();
if (epScreen && (before.episodeId !== player.episodeId || !!before.playing !== !!player.playing)) episodeScreenParts();
};
// Ambient starts (app.js): an episode playing here pauses.
window.__dioraPausePlayer = function () {
if (player.kind === 'podcast' && player.playing && !player.remote && N.podcastToggle) N.podcastToggle();
};
// 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});
moveFeedListsToServer();
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 (closePreview()) return true;
if (feedView) { feedView = null; render(); return true; }
// From the search back to the list it was opened from.
if (view === 'search') {
try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) { view = 'queue'; }
if (!VIEWS.some(v => v.id === view)) view = 'queue';
render();
return true;
}
showTab('books');
return true;
}
return false;
};
// A swipe waiting for „Rückgängig“ is done when the page goes away.
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') commitSwipe(); });
window.addEventListener('pagehide', commitSwipe);
// Pull the latest state once per start; the server has done the fetching.
N.podcastRefresh(false);
window.__dioraSubscribe();
});
})();