diora-web/static/podcasts/native-podcast.js
mrwnslz 0a9b618858
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 13m32s
Test / e2e (push) Failing after 1m31s
Podcast-Oberfläche: wartender Transkriptionsauftrag zeigt den Grund (sw CACHE v171)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:17:23 +01:00

6462 lines
295 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 ba1e933 2026-11-09: 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'},
];
// With the two lists (diora-web docs/podcasts.md, state.lists_v2): the queue is the
// Wiedergabeliste, the Merkliste holds what to hear some day, the Strom replaces „Neu“
// and „Folgen“.
const VIEWS_V2 = [
{id: 'queue', label: 'Wiedergabeliste'},
{id: 'later', label: 'Merkliste'},
{id: 'stream', label: 'Strom'},
{id: 'feeds', label: 'Abos'},
];
const VIEW_IDS = VIEWS.map(v => v.id).concat('later', 'stream', 'browse', 'search');
// The app's start page (this device; native.js opens it, the browser host too).
const IN_APP = !window.DioraPodcastHost;
// A function: the app loads this file in <head>, before #tab-home exists.
const startPages = () => [
document.getElementById('tab-home') && {id: 'home', label: 'Home', hint: 'Standard'},
!IN_APP && {id: '', label: 'Zuletzt benutzt'},
{id: 'books', label: 'Bücher', hint: IN_APP && !document.getElementById('tab-home') ? 'Standard' : null},
{id: 'queue', label: 'Podcasts: Warteschlange'},
{id: 'inbox', label: 'Podcasts: Neu'},
{id: 'radio', label: 'Radio'},
// Sections of the page's navigation (nav.js), where the page has them.
window.DioraNav && DioraNav.sections().some(s => s.id === 'audiobooks') && {id: 'audiobooks', label: 'Hörbücher'},
window.DioraNav && DioraNav.sections().some(s => s.id === 'news') && {id: 'news', label: 'Nachrichten'},
].filter(Boolean);
function startPage() {
let v = null;
try { v = localStorage.getItem('nui_start_page'); } catch (e) {}
const pages = startPages();
return pages.some(p => p.id === v) ? v : 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 streamSeen = false; // podcastStreamSeen() sent for this visit (until the app was away 10 min)
let lastView = '';
let hiddenAt = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) { hiddenAt = Date.now(); return; }
if (hiddenAt && Date.now() - hiddenAt > 10 * 60000) { streamSeen = false; lastView = ''; if (view === 'stream') render(); }
});
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
// Downloads (Downloads.kt; see "downloads" further down): where the host has them.
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(), {}) : {};
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 || [];
const later = () => state.later || [];
const v2 = () => !!state.lists_v2;
const views = () => (v2() ? VIEWS_V2 : VIEWS);
// A view of the other model (a start page, a notice, an older choice): its counterpart.
function fixView() {
if (!v2() && view === 'browse') view = 'inbox';
if (v2() && (view === 'inbox' || view === 'episodes')) view = 'stream';
if (!v2() && view === 'stream') view = 'inbox';
if (!v2() && view === 'later') view = 'queue';
}
// The play list's name.
const QNAME = () => (v2() ? 'Wiedergabeliste' : 'Warteschlange');
const inLater = e => later().some(x => x.episode__id === e.id) || !!(e.raw && e.raw.in_later);
// Favourite subscriptions (feed.favorite, docs/podcasts.md): ★ in Abos, the „★ Faves“ chips.
const isFave = feedId => { const f = feeds().find(x => x.id === feedId); return !!(f && f.favorite); };
let podFaves = false;
try { podFaves = localStorage.getItem('nui_pod_faves') === '1'; } catch (e) {}
function setFaves(on) {
podFaves = !!on;
try { localStorage.setItem('nui_pod_faves', podFaves ? '1' : '0'); } catch (e) {}
render();
scrollTop(0);
}
function toggleFeedFave(f) {
const on = !f.favorite;
f.favorite = on;
N.podcastFeedSettings(f.id, JSON.stringify({favorite: on}));
toast(on ? '★ Lieblings-Abo' : 'Kein Lieblings-Abo mehr');
render();
}
// 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 || [];
// "Abos & Quellen" (diora-web docs/sources.md, sources.js): where subscriptions are managed, if the page has it.
const hasSources = () => !!window.DioraNav && DioraNav.sections().some(s => s.id === 'sources');
const toSources = () => DioraNav.go('sources');
// Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here.
const isBook = f => !!f && f.kind === 'audiobook';
const podcastFeeds = () => feeds().filter(f => !isBook(f));
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');
}
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');
// The element is shared (radio and podcasts): so is its timer, or one hides the other's text early.
clearTimeout(t.nuiTimer);
t.nuiTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
}
// ---- sharing an episode: its page from the feed (`link`), else the file / the video's watch URL ----
function shareUrl(e) {
const r = e.raw || {};
const d = e.id > 0 ? storedDetails(e.id) : null;
return r.link || r.episode__link || (d && d.link) || r.audio_url || r.episode__audio_url || (d && d.audio_url) || '';
}
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(() => true, () => copyTextOld(text));
}
return Promise.resolve(copyTextOld(text));
}
function copyTextOld(text) {
const t = el('textarea');
t.value = text;
t.setAttribute('readonly', '');
t.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(t);
t.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (err) {}
t.remove();
return ok;
}
// Android: the system's share sheet. Elsewhere (browser, desktop app): title and link onto the clipboard.
async function shareEpisode(e) {
let url = shareUrl(e);
if (!url && e.id > 0 && navigator.onLine) { // the phone's own list data may not know the link yet
const d = await loadDetails(e.id);
url = (d && (d.link || d.audio_url)) || '';
}
if (!url) { toast('Diese Folge hat keine Adresse zum Teilen'); return; }
const headline = e.feed ? `${e.title} – ${e.feed}` : e.title;
if (N && N.shareText) { N.shareText(headline, url); return; }
toast(await copyText(headline + '\n' + url) ?'Link kopiert' : 'Kopieren ging nicht');
}
// GET from our server; a status other than 2xx throws (as a network error does).
async function getJson(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return r.json();
}
// A full sheet (the player, shownotes, a podcast's info, statistics, downloads): the
// overlay — one id, the CSS and the TV's focus know it — its panel, the ⌄ at the top,
// and a tap beside the panel closes it. The caller fills the panel (top first) and
// puts the sheet on the page.
function podSheet(cls, closeLabel, onClose, dialogLabel) {
const sheet = el('div');
sheet.id = 'nui-pod-player';
if (cls) sheet.className = cls;
const panel = el('div', 'nui-pod-player-panel');
if (dialogLabel) {
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-label', dialogLabel);
}
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', closeLabel, onClose));
sheet.append(panel);
sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); });
swipeToClose(panel, onClose);
return {sheet, panel, top};
}
// A sheet (the player, an episode's details …) follows the finger down from its top and
// closes when let go far enough down (or flicked); otherwise it springs back. Only while
// nothing under the finger can scroll up any more, and never on a slider.
function swipeToClose(panel, onClose) {
let g = null;
const scrolledUp = target => {
for (let n = target; n && n !== panel.parentNode; n = n.parentNode) {
if (n.nodeType === 1 && n.scrollTop > 0 && n.scrollHeight > n.clientHeight) return true;
}
return false;
};
panel.addEventListener('touchstart', e => {
g = null;
if (e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable="true"]')) return;
if (scrolledUp(e.target)) return;
g = {y: e.touches[0].clientY, x: e.touches[0].clientX, t: Date.now(), on: false, dy: 0};
}, {passive: true});
panel.addEventListener('touchmove', e => {
if (!g || e.touches.length !== 1) return;
const t = e.touches[0];
const dy = t.clientY - g.y;
if (!g.on) {
if (dy < 0 || Math.abs(t.clientX - g.x) > Math.abs(dy)) { g = null; return; }
if (dy < 10) return;
g.on = true;
panel.style.transition = 'none';
}
e.preventDefault(); // the sheet moves, the page under it doesn't scroll
g.dy = dy;
panel.style.transform = `translateY(${Math.max(0, dy)}px)`;
}, {passive: false});
const end = () => {
if (!g || !g.on) { g = null; return; }
const fast = g.dy / Math.max(1, Date.now() - g.t) > 0.6; // a flick: px per ms
const far = g.dy > Math.min(160, panel.offsetHeight * 0.25);
g = null;
panel.style.transition = 'transform 0.18s ease-out';
if (far || fast) {
panel.style.transform = 'translateY(100%)';
setTimeout(onClose, 150);
} else {
panel.style.transform = '';
setTimeout(() => { panel.style.transition = ''; }, 200);
}
};
panel.addEventListener('touchend', end, {passive: true});
panel.addEventListener('touchcancel', end, {passive: true});
}
// 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,
yt: isYouTube(o.episode__audio_url),
};
}
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,
yt: isYouTube(o.audio_url),
};
}
// "Kapitel 7/24 · <book>" while a chapter of an audiobook plays (playerState().book);
// "Podcast-Radio · <podcast>" while the radio plays (playerState().radio, diora-web docs/podcasts.md).
function bookLine(p) {
if (p && p.radio) return 'Podcast-Radio · ' + plain(p.feed || '');
const b = p && p.book;
if (!b || !b.chapters) return '';
return `Kapitel ${b.index + 1}/${b.chapters}` + (b.title || p.feed ? ' · ' + plain(b.title || p.feed) : '');
}
// A video of a subscribed YouTube channel (diora-web docs/youtube.md): its sound
// is found on this device; the feed knows no length until it has played.
function isYouTube(url) {
return /^https:\/\/www\.youtube\.com\/watch\?v=/.test(url || '');
}
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 one drawn from the podcast's name
// (diora-web gencover.js, square), else a tile with its 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 title = plain(name || '').trim();
if (title && window.DioraGenCover) srcs.push(DioraGenCover.url({title}, {square: true, podcast: true}));
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; }
// An audiobook's chapter: heard in the library (its place, the rest of the book), never queued.
if (isBook(feeds().find(f => f.id === e.feedId)) && openAudiobook(e.feedId)) return;
// Two lists: ▶ puts it first in the Wiedergabeliste (in the Merkliste it stays until heard).
if (v2()) {
if (inQueue(e.id)) N.podcastMove(e.id, 0);
else N.podcastEnqueue(JSON.stringify([e.raw]), 'front', currentId());
}
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 ' + QNAME() + (label ? ': ' + label.label.toLowerCase() : ''));
}
// An episode into a research project (diora-web docs/projects.md); from the player with
// the place it's at (a time mark „ab 12:34“).
function linkProject(e, position) {
if (!window.DioraProjects) return;
const r = e.raw || queue().find(q => q.episode__id === e.id) || later().find(q => q.episode__id === e.id) || {};
const d = storedDetails(e.id) || {};
DioraProjects.link('episode', e.id, {
title: plain(e.title || r.title || r.episode__title || d.title || ''),
podcast: plain(e.feed || r.feed__title || r.episode__feed__title || ''),
author: plain(d.author || r.author || ''),
date: e.date || r.pub_date || r.episode__pub_date || d.pub_date || null,
url: d.link || r.link || r.audio_url || r.episode__audio_url || '',
feed: e.feedId || r.feed__id || r.episode__feed__id || null,
position: Math.max(0, Math.round(position || 0)),
});
}
const projectItem = e => window.DioraProjects && {icon: '◇', label: 'Mit Projekt verknüpfen …',
hint: (DioraProjects.of('episode', e.id) || []).map(p => p.name).join(', ') || null, action: () => linkProject(e, 0)};
function openAudiobook(feedId) {
if (!feedId || typeof window.__dioraAudiobookOpen !== 'function') return false;
closeNotes();
closePlayer();
if (window.DioraNav) DioraNav.go('audiobooks');
window.__dioraAudiobookOpen(feedId);
return true;
}
// +: onto the Merkliste, or off it again.
function toggleLater(e) {
if (!N.podcastLaterAdd) return;
if (inLater(e)) {
N.podcastLaterRemove(JSON.stringify([e.id]));
if (e.raw) e.raw.in_later = false;
toast('Aus der Merkliste genommen');
} else {
N.podcastLaterAdd(JSON.stringify([e.raw]));
if (e.raw) e.raw.in_later = true;
toast('Auf der Merkliste');
}
for (const l of [epList && epList.items, feedView && feedView.episodes]) {
for (const o of l || []) if (o.id === e.id) o.in_later = e.raw.in_later;
}
render();
}
function laterButton(e) {
const on = inLater(e);
return button(on ? '✓' : '+', on ? 'Auf der Merkliste – antippen nimmt es heraus' : 'Auf die Merkliste', () => toggleLater(e),
'nui-pod-mini' + (on ? ' on' : ''));
}
function starButton(e) {
const on = !!(e.raw && e.raw.favorite);
return button(on ? '★' : '☆', on ? 'Favorit' : 'Als Favorit markieren', () => toggleFavorite(e, star => { e.raw.favorite = star; }),
'nui-pod-mini nui-pod-starbtn' + (on ? ' on' : ''));
}
// 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_OLD = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört';
const DISCARD_HINT_V2 = 'aus Wiedergabeliste und Merkliste, Datei auf allen Geräten löschen – nicht als gehört';
const discardHint = () => (v2() ? DISCARD_HINT_V2 : DISCARD_HINT_OLD);
const discardItem = (e, opts) => N.podcastDiscard &&
{icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: discardHint(), 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 === 'later' || 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: discardHint(), 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),
shareItem(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: v2() ? 'verlässt beide Listen' : 'verlässt die Warteschlange', action: () => markPlayed(e)},
...dlMenuItems(e),
v2() && N.podcastLaterAdd && !inLater(e) && {icon: '+', label: 'Auf die Merkliste', hint: 'für später', action: () => toggleLater(e)},
{icon: '✕', svg: 'listx', label: 'Aus der ' + QNAME() + ' nehmen', hint: dlDone(e.id) && !v2() ? 'Datei bleibt' : null,
action: () => N.podcastDequeue(e.id)},
projectItem(e),
discardItem(e, opts),
selectItem(e, opts),
]);
}
function shareItem(e) {
return {icon: '⇪', label: 'Teilen', hint: N && N.shareText ? null : 'Link in die Zwischenablage', action: () => shareEpisode(e)};
}
function episodeMenu(e, opts) {
if (v2()) { listsMenu(e, opts); return; }
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),
shareItem(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),
]);
}
// An episode's menu with the two lists (Merkliste, Strom, a podcast's page).
function listsMenu(e, opts) {
const queued = inQueue(e.id);
const kept = inLater(e);
openSheet(e.title, [
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e),
shareItem(e),
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
!queued && {icon: '⇊', label: 'Ans Ende der Wiedergabeliste', action: () => enqueue(e, 'back')},
queued && {icon: '✕', svg: 'listx', label: 'Aus der Wiedergabeliste nehmen', action: () => N.podcastDequeue(e.id)},
!e.played && {icon: '✓', label: 'Als gehört markieren', hint: kept || queued ? 'verlässt die Listen' : null, action: () => markPlayed(e)},
e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
...dlMenuItems(e),
projectItem(e),
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)},
'lists_v2' in state && N.podcastListsSwitch && {icon: '☷', label: 'Wiedergabeliste und Merkliste',
hint: v2() ? 'an: kurze Wiedergabeliste, lange Merkliste, der Strom statt „Neu“' : 'aus: eine Warteschlange, „Neu“ und „Folgen“',
action: () => listsSwitch(!v2())},
group('≡', QNAME(), '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',
'skip_sponsor' in prefs && 'Werbung', '„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(),
sponsorItem(),
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: 'Transkription', hint: 'Whisper-Instanz für Transkripte und Artikel, Modell, Sprache', action: transcriptionSettings},
{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},
hasSources() && {icon: '⚙', label: 'Abos & Quellen', hint: 'alle Abos an einem Ort: Podcasts, YouTube, Nachrichten, Blogs', action: toSources},
].filter(Boolean)),
{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 listsSwitch(on) {
N.podcastListsSwitch(!!on);
try { localStorage.setItem('nui_pod_lists', '1'); } catch (e) {}
state.lists_v2 = !!on; // until the state comes anew
view = on ? 'later' : 'queue';
toast(on ? 'Die Warteschlange ist jetzt deine Merkliste; die Wiedergabeliste beginnt leer'
: 'Die Merkliste steht jetzt am Ende der Warteschlange');
render();
}
// The two lists come on by themselves once per device, when the server has them (the
// plan of 2026-10-19, docs/podcasts.md); the settings switch turns them off again.
function listsOnOnce() {
if (!('lists_v2' in state) || state.lists_v2 || !N.podcastListsSwitch) return;
let asked = false;
try { asked = localStorage.getItem('nui_pod_lists') === '1'; } catch (e) {}
if (asked) return;
listsSwitch(true);
}
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 => (startPages().find(p => p.id === id) || startPages()[0]).label;
return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'),
action: () => openSheet('Beim Öffnen zeigen …', startPages().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)};
}
// „Werbung und Schluss überspringen“ next to „Stille überspringen“ in the speed menu: one
// setting for this device, no per-podcast choice (only YouTube episodes have it at all).
function sponsorSpeedItems() {
const prefs = devicePrefs();
if (!N.podcastSetPref || !('skip_sponsor' in prefs)) return [];
const device = !!prefs.skip_sponsor;
return [{icon: device ? '☑' : '☐', label: 'Werbung und Schluss überspringen',
hint: (device ? 'an' : 'aus') + ' · YouTube-Folgen (SponsorBlock)',
action: () => { N.podcastSetPref('skip_sponsor', !device);
toast(device ? 'Werbung und Schluss werden gespielt' : 'Werbung und Schluss werden übersprungen'); }}];
}
// Ad and outro of a YouTube episode (server: sponsor.py, SponsorBlock). Only where the
// player does it at all — the app knows the setting once PlaybackPrefs has it.
function sponsorItem() {
if (!N.podcastSetPref || !('skip_sponsor' in devicePrefs())) return null;
const on = !!devicePrefs().skip_sponsor;
return {icon: on ? '☑' : '☐', label: 'Werbung und Schluss überspringen',
hint: on ? 'an: in YouTube-Folgen' : 'aus',
action: () => N.podcastSetPref('skip_sponsor', !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, hint) {
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` : (hint || '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', get label() { return v2() ? 'Wiedergabeliste' : 'Warteschlange'; }, test: o => inQueue(o.id),
get none() { return 'Keine davon steht in der ' + QNAME() + '.'; }},
{id: 'later', label: 'Merkliste', v2: true, test: o => inLater({id: o.id, raw: o}), none: 'Keine davon steht auf der Merkliste.'},
{id: 'fave_feeds', label: '★ Faves', v2: true, test: o => isFave(o.feed__id), none: 'Keine Folgen deiner Lieblings-Abos.'},
{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 = [
// Days and weeks (Heute, Gestern, …), in each the podcasts that seldom come first (timeSections).
{id: 'rare', label: 'Nach Zeitraum, seltene zuerst', server: 'new', all: true, key: o => -(Date.parse(o.pub_date) || -Infinity)},
{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';
}
// ---- by time: Heute, Gestern, Diese Woche, Diesen Monat, then the months (after Kino's feed;
// diora-web feeds.js the same for blogs). Within one, with rare: the podcasts that seldom come
// first (feed.interval_days from the server: the mean gap of its newest episodes), so a weekly
// show isn't lost among the daily news.
const MONTHS = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober',
'November', 'Dezember'];
function timeLabel(iso, now) {
const t = Date.parse(iso);
if (!t) return 'Ohne Datum';
const midnight = new Date(now);
midnight.setHours(0, 0, 0, 0);
const days = Math.floor((midnight.getTime() - t) / 86400000) + 1; // 0: today, 1: yesterday
if (t > now) return 'Demnächst';
if (t >= midnight.getTime()) return 'Heute';
if (days <= 1) return 'Gestern';
if (days < 7) return 'Diese Woche';
if (days < 30) return 'Diesen Monat';
const d = new Date(t);
return MONTHS[d.getMonth()] + (d.getFullYear() === new Date(now).getFullYear() ? '' : ' ' + d.getFullYear());
}
function intervalOf(feedId) {
const f = feeds().find(x => x.id === feedId);
return f && typeof f.interval_days === 'number' ? f.interval_days : 0;
}
// items (ep() shapes), newest first already: [{label, items}].
function timeSections(items, rare) {
const now = Date.now();
const out = [];
for (const e of items) {
const label = timeLabel(e.date, now);
const last = out[out.length - 1];
if (last && last.label === label) last.items.push(e);
else out.push({label, items: [e]});
}
if (rare) {
const gap = new Map();
for (const sec of out) {
sec.items = sec.items.map((e, i) => [e, i]).sort((a, b) => {
for (const e of [a[0], b[0]]) if (!gap.has(e.feedId)) gap.set(e.feedId, intervalOf(e.feedId));
return (gap.get(b[0].feedId) - gap.get(a[0].feedId)) || a[1] - b[1];
}).map(x => x[0]);
}
}
return out;
}
// The list's rows, under headings by time when sorted so.
function appendRows(list, items, sort, rowOf) {
if (sort !== 'new' && sort !== 'rare') {
for (const e of items) list.append(rowOf(e));
return;
}
for (const sec of timeSections(items, sort === 'rare')) {
list.append(el('div', 'nui-pod-when', sec.label));
for (const e of sec.items) list.append(rowOf(e));
}
}
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) || (f.v2 && !v2())) 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);
loadFolderOrder();
}
setTimeout(loadFolderOrder, 1500);
// Pull to refresh (native-gestures.js).
window.__dioraPodcastRefresh = refresh;
// ---- 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 {
fixView();
if (view === 'stream' && lastView !== 'stream') epList = null; // fetched anew: what came since shows
if (view === 'stream' && !streamSeen) {
// The Strom looked at: what came since the last visit keeps its mark for this one.
streamSeen = true;
if (N.podcastStreamSeen) N.podcastStreamSeen();
}
lastView = view;
const tabs = el('div', 'nui-pod-tabs' + (v2() ? ' nui-pod-tabs-v2' : ''));
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') folderChips(queue().map(ep), e => e.feedId);
else if (view === 'inbox') folderChips(inbox().map(ep), e => e.feedId);
else if (view === 'later') folderChips(later().map(ep), e => e.feedId);
else if (view === 'feeds') folderChips(podcastFeeds(), f => f.id, v2());
else if ((view === 'episodes' || view === 'stream') && !selected) folderChips(podcastFeeds(), f => f.id);
if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox();
else if (view === 'later') renderLater();
else if (view === 'episodes' || view === 'stream') renderEpisodes();
else if (view === 'browse') renderBrowse();
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 (e.yt) meta.push('YouTube');
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));
if (list === 'stream') {
// The Strom: ★, + (Merkliste), ▶ (first in the Wiedergabeliste); the rest by a long press.
if (opts.fresh) row.classList.add('nui-pod-fresh');
row.append(...[starButton(e), N.podcastLaterAdd && laterButton(e), playButton(e)].filter(Boolean));
row.querySelectorAll(':scope > .nui-pod-mini').forEach(b => b.addEventListener('click', ev => ev.stopPropagation()));
} else {
if (v2() && list === 'feed' && N.podcastLaterAdd) row.append(laterButton(e));
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 => swipeDone(e, swipes, act, opts.filters || [])), 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) {
// Two lists: downloads follow the lists; a podcast's page plays (and + keeps).
if (v2() && list === 'feed') return false;
return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url);
}
function playButton(e) {
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;
}
function primaryButton(e, list) {
if (list === 'queue' || list === 'downloads' || list === 'later' || (v2() && list === 'feed')) return playButton(e);
if (dlPrimary(e, list)) return dlButton(e);
const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue);
return queued ? button('✓', 'In der ' + QNAME(), () => toast('Schon in der ' + QNAME()), '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'},
// The two lists (docs/podcasts.md).
later: {label: 'Merkliste', v2: true, left: 'unlater', right: 'queue'},
stream: {label: 'Strom', v2: true, left: 'favorite', right: 'later'},
};
const SWIPE_ACTIONS = [
{id: 'none', label: 'Nichts', icon: ''},
{id: 'queue', label: 'Einreihen', icon: '+'},
// Where there are files.
{id: 'dequeue', label: 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: 'later', label: 'Auf die Merkliste', icon: '+', host: 'podcastLaterAdd'},
{id: 'unlater', label: 'Aus der Merkliste', icon: '✕', svg: 'listx', host: 'podcastLaterRemove'},
{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 'later': return {title: 'Merkliste'};
case 'unlater': return {title: 'Aus der Merkliste', 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 === 'later') {
if (inLater(e)) { toast('Schon auf der Merkliste'); return true; }
offerUndo(e, 'Auf der Merkliste', {run: () => {
N.podcastLaterAdd(JSON.stringify([o]));
o.in_later = true;
render();
}});
return true;
}
if (act === 'unlater') {
if (!inLater(e)) { toast('Nicht auf der Merkliste'); return true; }
const hide = list === 'later';
offerUndo(e, 'Aus der Merkliste', {hide, svg: 'listx', run: () => {
N.podcastLaterRemove(JSON.stringify([e.id]));
o.in_later = false;
if (!hide) render();
}});
return !hide;
}
if (act === 'queue') {
if (inQueue(e.id)) { toast('Schon in der ' + QNAME()); 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 === 'later' || (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 all = queue().map(ep);
if (!all.length) {
empty(v2() ? 'Die Wiedergabeliste ist leer. ▶ bei einer Folge – in der Merkliste, im Strom oder bei einem Abo – spielt sie und stellt sie hierher; ist die Liste durch, hört der Player auf.'
: 'Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
return;
}
const items = all.filter(e => inFolder(e.feedId));
if (!items.length) { empty(folderEmpty('in der ' + QNAME())); 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: v2() ? 'Aus der Liste' : '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}` +
(podFolder != null && items.length < all.length ? ` · von ${all.length}` : '')),
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() && podFolder == null) {
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);
}
// The Merkliste: by podcast, each one folds (this device remembers which are open); in a
// podcast its newest episode first. Heard or „Folge löschen“ takes an episode off.
let laterOpen = new Set();
try { laterOpen = new Set(JSON.parse(localStorage.getItem('nui_pod_later_open') || '[]')); } catch (e) {}
function toggleLaterOpen(feedId) {
if (laterOpen.has(feedId)) laterOpen.delete(feedId); else laterOpen.add(feedId);
try { localStorage.setItem('nui_pod_later_open', JSON.stringify([...laterOpen])); } catch (e) {}
render();
}
function renderLater() {
const all = later().map(ep);
if (!all.length) {
empty('Die Merkliste ist leer. Mit + im Strom oder bei einem Abo merkst du dir Folgen; neue Folgen deiner Stamm-Podcasts kommen von selbst hierher (und werden geladen).');
return;
}
const items = all.filter(e => inFolder(e.feedId));
if (!items.length) { empty(folderEmpty('auf der Merkliste')); return; }
if (selected) {
root.append(selectionBar(items, [
{label: 'Abspielen', run: ids => {
N.podcastEnqueue(JSON.stringify(items.filter(e => ids.includes(e.id)).map(e => e.raw)), 'front', currentId());
toast(`${nEpisodes(ids.length)} vorn in der Wiedergabeliste`);
stopSelecting();
}},
{label: 'In die Liste', run: ids => {
N.podcastEnqueue(JSON.stringify(items.filter(e => ids.includes(e.id)).map(e => e.raw)), 'back', currentId());
toast(`${nEpisodes(ids.length)} am Ende der Wiedergabeliste`);
stopSelecting();
}},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Herausnehmen', svg: 'listx', run: ids => { N.podcastLaterRemove(JSON.stringify(ids)); stopSelecting(); }},
DL && {label: 'Laden', run: ids => downloadMany(items.filter(e => ids.includes(e.id)))},
discardAction(),
]));
} else {
const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0);
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${nEpisodes(items.length)} · ${minutes(total)}` +
(podFolder != null && items.length < all.length ? ` · von ${all.length}` : '')),
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
root.append(head);
}
// By podcast, in the order of their newest episode (the list comes newest first).
const groups = new Map();
for (const e of items) {
if (!groups.has(e.feedId)) groups.set(e.feedId, []);
groups.get(e.feedId).push(e);
}
const list = el('div', 'nui-pod-list nui-pod-later');
for (const [feedId, eps] of groups) {
const open = !!selected || laterOpen.has(feedId) || groups.size === 1;
const head = el('button', 'nui-pod-group' + (open ? ' open' : ''));
head.type = 'button';
head.setAttribute('aria-expanded', open ? 'true' : 'false');
head.append(art(eps[0].feedArt, null, null, eps[0].feed));
const text = el('span', 'nui-pod-group-text');
text.append(el('span', 'nui-pod-group-title', eps[0].feed || ''),
el('span', 'nui-pod-meta', open ? nEpisodes(eps.length) : `${nEpisodes(eps.length)} · ${eps[0].title}`));
head.append(text, el('span', 'nui-pod-group-count', String(eps.length)), el('span', 'nui-pod-chev', open ? '▾' : '›'));
head.addEventListener('click', () => { if (!selected) toggleLaterOpen(feedId); });
list.append(head);
if (open) for (const e of eps) list.append(episodeRow(e, {showDate: true, list: 'later'}));
}
root.append(list);
}
// ---- Stöbern (diora-web docs/podcasts.md): within the subscriptions, and a step out ---------
// Search in titles and shownotes, the week's topics, people, forgotten episodes,
// recommendations; a person's episodes elsewhere go onto the Merkliste (their podcast
// becomes „Auf Abruf“). Everything here needs a connection.
let browse = {mode: 'home'};
const browseCache = new Map(); // path → {at, data}
async function browseGet(path, maxAge) {
const hit = browseCache.get(path);
if (hit && Date.now() - hit.at < (maxAge || 10 * 60000)) return hit.data;
const data = await getJson(path);
browseCache.set(path, {at: Date.now(), data});
return data;
}
function openBrowse() {
selected = null;
browse = {mode: 'home'};
view = 'browse';
render();
scrollTop(0);
}
function browseBack() {
if (browse.mode !== 'home') { browse = {mode: 'home', q: browse.q && browse.mode === 'search' ? '' : ''}; render(); scrollTop(0); return true; }
view = 'stream';
render();
scrollTop(0);
return true;
}
// A section that loads by itself: its heading, „Wird geladen …“, then fill(box, data).
function browseSection(title, path, fill, maxAge) {
const sec = el('div', 'nui-pod-browsesec');
if (title) sec.append(el('div', 'nui-pod-when', title));
const box = el('div', 'nui-pod-browsebox');
sec.append(box);
const hit = browseCache.get(path);
if (hit && Date.now() - hit.at < (maxAge || 10 * 60000)) {
fill(box, hit.data);
} else {
box.append(el('div', 'nui-pod-meta', 'Wird geladen …'));
browseGet(path, maxAge).then(d => { box.textContent = ''; fill(box, d); })
.catch(() => { box.textContent = ''; box.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Ließ sich gerade nicht laden.' : 'Stöbern braucht eine Verbindung.')); });
}
return sec;
}
function browseRows(box, rows, limit) {
const list = el('div', 'nui-pod-list');
const shownRows = limit ? rows.slice(0, limit) : rows;
for (const o of shownRows) list.append(episodeRow(ep(o), {showFeed: true, showDate: true, list: 'stream', noSelect: true}));
box.append(list);
if (!rows.length) box.append(el('div', 'nui-pod-meta', 'Nichts gefunden.'));
return rows.length > shownRows.length;
}
function moreLink(box, label, onClick) {
const b = el('button', 'nui-pod-bundle', label);
b.type = 'button';
b.addEventListener('click', onClick);
box.append(b);
}
function chipRow(items) {
const row = el('div', 'nui-pod-filters nui-pod-browsechips');
for (const it of items) {
const b = el('button', 'nui-pod-chip', it.label);
b.type = 'button';
if (it.count) b.append(el('span', 'nui-pod-chipcount', ' ' + it.count));
b.addEventListener('click', it.onClick);
row.append(b);
}
return row;
}
function renderBrowse() {
const head = el('div', 'nui-pod-head nui-pod-browsehead');
const titles = {home: 'Stöbern', search: 'Suche in deinen Podcasts', topic: browse.label, person: browse.name,
forgotten: 'Vergessen', recommend: 'Empfehlungen'};
head.append(button('‹', browse.mode === 'home' ? 'Zurück zum Strom' : 'Zurück zum Stöbern', browseBack, 'nui-pod-back'),
el('div', 'nui-pod-browsetitle', titles[browse.mode] || 'Stöbern'));
root.append(head);
if (browse.mode === 'home' || browse.mode === 'search') {
const form = el('form', 'nui-pod-search');
const input = el('input', 'search-input');
input.type = 'search';
input.id = 'nui-pod-q';
input.placeholder = 'In Titeln und Shownotes deiner Podcasts …';
input.setAttribute('enterkeyhint', 'search');
input.value = browse.q || '';
const go = el('button', 'btn', 'Suchen');
go.type = 'submit';
form.append(input, go);
form.addEventListener('submit', ev => {
ev.preventDefault();
const q = input.value.trim();
browse = q ? {mode: 'search', q} : {mode: 'home'};
render();
});
root.append(form);
}
const body = el('div', 'nui-pod-browse');
root.append(body);
if (browse.mode === 'search') {
body.append(browseSection('', `/podcasts/discover/search/?q=${encodeURIComponent(browse.q)}&stream=1`,
(box, d) => browseRows(box, d.episodes || [])));
return;
}
if (browse.mode === 'topic') {
body.append(browseSection('', `/podcasts/episodes/?ids=${browse.ids.join(',')}&limit=100`,
(box, d) => browseRows(box, (d.episodes || []).sort((a, b) => (Date.parse(b.pub_date) || 0) - (Date.parse(a.pub_date) || 0)))));
return;
}
if (browse.mode === 'person') {
body.append(browseSection('In deinen Podcasts', `/podcasts/discover/person/?name=${encodeURIComponent(browse.name)}`,
(box, d) => browseRows(box, d.episodes || [])));
body.append(browseSection('Anderswo', `/podcasts/discover/elsewhere/?name=${encodeURIComponent(browse.name)}`,
(box, d) => elsewhereRows(box, d.episodes || []), 60 * 60000));
return;
}
if (browse.mode === 'forgotten') {
body.append(browseSection('Von den Podcasts, die du am meisten hörst', '/podcasts/discover/forgotten/',
(box, d) => browseRows(box, d.loved || [])));
body.append(browseSection('Von den seltenen', '/podcasts/discover/forgotten/', (box, d) => browseRows(box, d.rare || [])));
return;
}
if (browse.mode === 'recommend') {
body.append(browseSection('', '/podcasts/discover/recommend/', (box, d) => recommendCards(box, d.podcasts || []), 60 * 60000));
return;
}
// home
body.append(browseSection('Themen der Woche', '/podcasts/discover/topics/?days=7', (box, d) => {
const ts = d.topics || [];
if (!ts.length) { box.append(el('div', 'nui-pod-meta', 'Diese Woche keine gemeinsamen Themen.')); return; }
box.append(chipRow(ts.map(t => ({label: t.label, count: t.ids.length,
onClick: () => { browse = {mode: 'topic', label: t.label, ids: t.ids}; render(); scrollTop(0); }}))));
}));
body.append(browseSection('Personen', '/podcasts/discover/people/', (box, d) => {
const ps = (d.people || []).slice(0, 24);
if (!ps.length) { box.append(el('div', 'nui-pod-meta', 'Noch keine Namen gefunden.')); return; }
box.append(chipRow(ps.map(x => ({label: x.name, count: x.episodes,
onClick: () => { browse = {mode: 'person', name: x.name}; render(); scrollTop(0); }}))));
}, 60 * 60000));
body.append(browseSection('Vergessen', '/podcasts/discover/forgotten/', (box, d) => {
const rows = (d.loved || []).slice(0, 3).concat((d.rare || []).slice(0, 2));
browseRows(box, rows);
moreLink(box, 'Alles Vergessene ›', () => { browse = {mode: 'forgotten'}; render(); scrollTop(0); });
}));
body.append(browseSection('Empfohlen', '/podcasts/discover/recommend/', (box, d) => {
const ps = d.podcasts || [];
recommendCards(box, ps.slice(0, 3));
if (ps.length > 3) moreLink(box, `Alle ${ps.length} Empfehlungen ›`, () => { browse = {mode: 'recommend'}; render(); scrollTop(0); });
}, 60 * 60000));
}
// Episodes naming a person in podcasts not subscribed: + puts one on the Merkliste and
// subscribes its podcast „Auf Abruf“ (POST /podcasts/discover/take/).
function elsewhereRows(box, items) {
if (!items.length) { box.append(el('div', 'nui-pod-meta', 'Anderswo nichts gefunden.')); return; }
const list = el('div', 'nui-pod-list');
for (const it of items) {
const row = el('div', 'nui-pod-row');
row.append(art(it.artwork_url, null, null, it.podcast));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(it.title)));
const meta = [];
if (it.duration_seconds) meta.push(minutes(it.duration_seconds));
if (it.pub_date) meta.push(day(it.pub_date));
if (it.podcast) meta.push(plain(it.podcast));
main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
row.append(main);
const take = button('+', 'Auf die Merkliste (der Podcast kommt „Auf Abruf“ in die Abos)', async () => {
take.disabled = true;
try {
const r = await fetch('/podcasts/discover/take/', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(it)});
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || r.status);
take.textContent = '✓';
take.classList.add('on');
toast(d.subscribed ? `Auf der Merkliste · „${plain(it.podcast)}“ auf Abruf abonniert` : 'Auf der Merkliste');
refresh(false);
} catch (e) {
take.disabled = false;
toast(navigator.onLine ? 'Ließ sich nicht merken' : 'Braucht eine Verbindung');
}
}, 'nui-pod-mini');
row.append(take);
list.append(row);
}
box.append(list);
}
// Podcasts like the ones heard most (fyyd): subscribe „Auf Abruf“ (or open the preview).
function recommendCards(box, items) {
if (!items.length) { box.append(el('div', 'nui-pod-meta', 'Gerade keine Empfehlungen.')); return; }
const have = new Set(feeds().map(f => f.rss_url));
for (const p of items) {
const card = el('div', 'nui-pod-row nui-pod-reco');
card.append(art(p.artwork_url, null, null, p.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(p.title)));
if (p.because && p.because.length) main.append(el('div', 'nui-pod-meta', 'weil du ' + p.because.slice(0, 2).map(plain).join(' und ') + ' hörst'));
if (p.description) main.append(el('div', 'nui-pod-meta nui-pod-reco-desc', plain(p.description).slice(0, 160)));
card.append(main);
if (have.has(p.feed_url)) {
card.append(el('span', 'nui-pod-badge', 'Abo'));
} else {
const sub = button('+', 'Abonnieren – „Auf Abruf“: nicht im Strom, nur auf seiner Seite', async () => {
sub.disabled = true;
if (await subscribeOnDemand(p.feed_url, p.title)) { sub.textContent = '✓'; sub.classList.add('on'); } else sub.disabled = false;
}, 'nui-pod-mini');
card.append(sub);
}
if (N.podcastPreview) main.addEventListener('click', () => openPreview(p.feed_url));
box.append(card);
}
}
async function subscribeOnDemand(url, title) {
try {
const r = await fetch('/podcasts/feeds/add/', {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({rss_url: url, title})});
const d = await r.json();
if (!r.ok || !d.ok) throw new Error(d.error || r.status);
await fetch(`/podcasts/feeds/${d.feed_id}/settings/`, {method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({new_episodes: 'ignore'})});
toast(`„${plain(d.title || title)}“ auf Abruf abonniert`);
refresh(false);
return true;
} catch (e) {
toast(navigator.onLine ? 'Ließ sich nicht abonnieren' : 'Abonnieren braucht eine Verbindung');
return false;
}
}
function renderInbox() {
const all = inbox().map(ep);
if (!all.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;
}
const items = all.filter(e => inFolder(e.feedId));
if (!items.length) { empty(folderEmpty('neu')); 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', podFolder != null
? `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'} · von ${total}`
: `${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');
appendRows(list, items, 'rare', e => episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
root.append(list);
}
// ---- folders (diora-web docs/library.md) ------------------------------------------------------
// Like tags: a subscription may be in several (feed.folders; `folder`, the first, is for older
// clients). Podcasts and audiobooks share the names and their order (/podcasts/folders/).
// One filter for Neu, Warteschlange and Abos, remembered on this device. Putting into
// folders, renaming and deleting go through the subscriptions' settings, so they work
// offline too; only the order is posted on its own (and again later when that failed).
// folders.js loads after this file.
const hasFolders = () => !!window.DioraFolders;
let podFolder = null; // null: all, '': without a folder, else a name
let folderOrder = [];
try {
podFolder = JSON.parse(localStorage.getItem('nui_pod_folder') || 'null');
folderOrder = JSON.parse(localStorage.getItem('nui_pod_folder_order') || '[]') || [];
} catch (e) {}
const foldersOf = f => (!f ? [] : f.folders && f.folders.length ? f.folders.slice() : f.folder ? [f.folder] : []);
const sameName = (a, b) => a.toLowerCase() === b.toLowerCase();
const dedupe = names => names.filter((n, i) => names.findIndex(m => sameName(m, n)) === i);
// The names in their order: the order the server keeps first, the rest by name.
function orderedNames(names) {
const first = folderOrder.filter(n => names.includes(n));
const rest = names.filter(n => !first.includes(n)).sort((a, b) => a.localeCompare(b, 'de', {sensitivity: 'base'}));
return first.concat(rest);
}
const namesOf = list => orderedNames(dedupe([].concat(...list.map(foldersOf))));
function inFolder(feedId) {
if (podFolder == null) return true;
const fs = foldersOf(feeds().find(f => f.id === feedId));
return podFolder === '' ? !fs.length : fs.includes(podFolder);
}
function setFolder(name) {
podFolder = name;
try { localStorage.setItem('nui_pod_folder', JSON.stringify(name)); } catch (e) {}
render();
scrollTop(0);
}
function folderEmpty(what) {
return podFolder === '' ? `Ohne Ordner ist nichts ${what}.` : `In „${podFolder}“ ist nichts ${what}.`;
}
// The chips (folders.js) above a list: each folder with how many of the list are in it.
function folderChips(items, feedIdOf, faves) {
if (!hasFolders()) return;
const names = namesOf(podcastFeeds());
if (!names.length || (podFolder && !names.includes(podFolder))) podFolder = null;
if (!names.length) return;
const byId = new Map(feeds().map(f => [f.id, f]));
const counts = new Map();
let none = 0;
for (const x of items) {
const fs = foldersOf(byId.get(feedIdOf(x)));
if (!fs.length) none++;
for (const n of fs) counts.set(n, (counts.get(n) || 0) + 1);
}
const box = el('div', 'nui-pod-folders');
const favesOn = faves && podFaves;
const nFaves = faves ? items.filter(x => isFave(feedIdOf(x))).length : 0;
DioraFolders.chips(box, {
folders: names.map(name => ({name, count: counts.get(name) || 0})),
active: podFolder,
none: none || (podFolder === '' ? '0' : 0),
lead: faves && nFaves ? [{id: 'all', label: 'Alle', on: podFolder == null && !favesOn},
{id: 'faves', label: '★ Faves', count: nFaves, on: favesOn}] : null,
onPick: name => {
if (name && typeof name === 'object') {
if (name.lead === 'faves') { setFaves(!podFaves); return; }
podFaves = false;
try { localStorage.setItem('nui_pod_faves', '0'); } catch (e) {}
setFolder(null);
return;
}
setFolder(typeof name === 'string' ? name : null);
},
onManage: manageFolders,
});
root.append(box);
}
function saveFeedFolders(feedId, names) {
N.podcastFeedSettings(feedId, JSON.stringify({folders: names}));
}
// Each subscription's folders through fn (renaming, deleting a folder).
function rewriteFolders(fn) {
for (const f of feeds()) {
const before = foldersOf(f);
if (!before.length) continue;
const after = dedupe(fn(before));
if (after.join('\n') !== before.join('\n')) saveFeedFolders(f.id, after);
}
}
// "In Ordner …" for one subscription or several: what all of them are in comes ticked;
// unticking takes it from all, ticking puts all in; the folders only some are in stay.
function assignFolders(list) {
if (!list.length) return;
const common = foldersOf(list[0]).filter(n => list.every(f => foldersOf(f).includes(n)));
DioraFolders.assign({
title: list.length === 1 ? `„${plain(list[0].title)}“ in Ordner` : `${list.length} Abos in Ordner`,
folders: namesOf(feeds()),
selected: common,
onDone: picked => {
for (const f of list) {
const before = foldersOf(f);
const after = dedupe(before.filter(n => !common.includes(n) || picked.includes(n)).concat(picked));
if (after.join('\n') !== before.join('\n')) saveFeedFolders(f.id, after);
}
toast(list.length > 1 ? `${list.length} Abos eingeordnet`
: picked.length ? 'In ' + picked.map(n => `„${n}“`).join(', ') : 'In keinem Ordner');
stopSelecting();
setTimeout(render, 80);
},
});
}
function manageFolders() {
DioraFolders.manage({
title: 'Ordner verwalten',
folders: namesOf(feeds()),
onRename: (old, name) => {
// Onto an existing name: both become one (its spelling stays).
const target = namesOf(feeds()).find(n => n !== old && sameName(n, name)) || name;
rewriteFolders(fs => fs.map(n => (n === old ? target : n)));
keepOrder(dedupe(folderOrder.map(n => (n === old ? target : n))), true);
if (podFolder === old) podFolder = target;
setTimeout(render, 80);
},
onDelete: name => {
rewriteFolders(fs => fs.filter(n => n !== name));
keepOrder(folderOrder.filter(n => n !== name), true);
if (podFolder === name) podFolder = null;
setTimeout(render, 80);
},
onOrder: order => keepOrder(order, true),
});
}
async function postJsonTo(url, body) {
const headers = {'Content-Type': 'application/json'};
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
const r = await fetch(url, {method: 'POST', headers, body: JSON.stringify(body)});
if (!r.ok) throw new Error(r.status);
return r.json();
}
// The chips' order: kept here, and on the server (posted again later when that failed).
function keepOrder(order, post) {
const changed = order.join('\n') !== folderOrder.join('\n');
folderOrder = order.slice();
try {
localStorage.setItem('nui_pod_folder_order', JSON.stringify(folderOrder));
if (post) localStorage.setItem('nui_pod_folder_order_dirty', '1');
} catch (e) {}
if (post) sendOrder();
if (changed && !post) render();
}
async function sendOrder() {
try {
const d = await postJsonTo('/podcasts/folders/', {order: folderOrder});
try { localStorage.removeItem('nui_pod_folder_order_dirty'); } catch (e) {}
if (d && Array.isArray(d.order)) keepOrder(d.order, false);
} catch (e) { /* offline: again on the next load */ }
}
async function loadFolderOrder() {
if (!hasFolders()) return;
let dirty = false;
try { dirty = localStorage.getItem('nui_pod_folder_order_dirty') === '1'; } catch (e) {}
if (dirty) { sendOrder(); return; }
try {
const d = await getJson('/podcasts/folders/');
if (d && Array.isArray(d.order)) keepOrder(d.order, false);
} catch (e) { /* offline: the order kept here */ }
}
// 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');
if (selected) {
row.classList.add('nui-pod-selecting');
row.append(el('span', 'nui-pod-check' + (selected.has(f.id) ? ' on' : ''), selected.has(f.id) ? '☑' : '☐'));
}
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));
// Its folders, where the chips don't already say it.
const fs = foldersOf(f).filter(n => n !== podFolder);
if (fs.length && hasFolders()) main.append(el('div', 'nui-pod-meta nui-pod-folderline', '▣ ' + fs.join(', ')));
row.append(main);
if (selected) {
row.addEventListener('click', () => toggleSelected(f.id));
return row;
}
if (v2() && 'favorite' in f && N.podcastFeedSettings) {
const star = button(f.favorite ? '★' : '☆', f.favorite ? 'Lieblings-Abo – antippen nimmt es heraus' : 'Als Lieblings-Abo markieren',
() => toggleFeedFave(f), 'nui-pod-mini nui-pod-starbtn' + (f.favorite ? ' on' : ''));
star.addEventListener('click', ev => ev.stopPropagation());
row.append(star);
}
if (v2()) {
if (f.new_episodes === 'queue' || f.new_episodes === 'play') row.append(el('span', 'nui-pod-badge', 'Stamm'));
else if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'Abruf'));
} else 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;
}
// ---- ✦ Ordnen with Claude (diora-web docs/ai.md) ------------------------------------------
// Folders and (with the two lists) levels for the subscriptions, from their titles, authors,
// newest episode titles and how much they are heard. The user ticks what to take.
const POD_AI_SYSTEM = `Du hilfst, die Podcast-Abos eines Hörers zu ordnen. Du bekommst die Abos als JSON (id, Titel,
Autor, Ordner, Stufe, wie viele Folgen in 12 Monaten gehört, mittlerer Abstand zwischen Folgen in Tagen, Titel der
neuesten Folgen) und die vorhandenen Ordner. Schlage nur Änderungen vor, die wirklich helfen; ein Abo ohne sinnvolle
Änderung lässt du weg.
- Ordner sind Kategorien wie Schlagwörter (Politik, Geschichte, Wissenschaft …), ein Abo kann in mehreren sein.
Nutze vorhandene Ordner bevorzugt; einen neuen nur, wenn mindestens drei Abos hineinpassen, kurzer deutscher Name.
Höchstens zwei Ordner pro Abo vorschlagen, nur hinzufügen, nichts entfernen.
- Stufe (nur wenn angegeben): "queue" = Stamm (neue Folgen auf die Merkliste und geladen) – für Podcasts, die
regelmäßig gehört werden; "inbox" = Strom (neue Folgen nur im Strom) – der Normalfall; "ignore" = Auf Abruf (nicht im
Strom) – für Abos, die kaum gehört werden und deren Folgen im Strom nur stören (z. B. sehr häufig erscheinend und
nie gehört, abgeschlossene Archive). Ändere eine Stufe nur mit klarem Grund aus den Hörzahlen; sonst null.
- warum: ein kurzer deutscher Satz.`;
function podAiSchema() {
return {
type: 'object',
properties: {
vorschlaege: {
type: 'array',
items: {
type: 'object',
properties: {
id: {type: 'integer'},
ordner_hinzu: {type: ['array', 'null'], items: {type: 'string'}},
stufe: {type: ['string', 'null']},
warum: {type: 'string'},
},
required: ['id', 'ordner_hinzu', 'stufe', 'warum'],
additionalProperties: false,
},
},
},
required: ['vorschlaege'],
additionalProperties: false,
};
}
function podcastOrganize() {
DioraAI.withKey(async () => {
const list = podcastFeeds();
if (!list.length) return;
DioraAI.progress('Mit KI ordnen', `Claude sieht sich ${list.length} Abos an – das kann eine Minute dauern …`);
// The newest episode titles of each (one request; what the feeds are about).
const titles = new Map();
try {
const d = await getJson('/podcasts/episodes/?limit=500');
for (const e of d.episodes || []) {
const a = titles.get(e.feed__id) || [];
if (a.length < 3) { a.push(plain(e.title)); titles.set(e.feed__id, a); }
}
} catch (e) { /* titles and authors alone then */ }
const levels = v2() && list.some(f => 'new_episodes' in f);
const data = list.map(f => ({
id: f.id, titel: plain(f.title), autor: plain(f.author || ''), ordner: foldersOf(f),
stufe: levels ? (f.new_episodes || 'inbox') : undefined, gehoert_12m: f.heard_365 || 0,
abstand_tage: typeof f.interval_days === 'number' ? Math.round(f.interval_days * 10) / 10 : null,
neueste: titles.get(f.id) || [],
}));
let answer;
try {
answer = await DioraAI.ask({
system: POD_AI_SYSTEM,
prompt: `Vorhandene Ordner: ${JSON.stringify(namesOf(list))}\n\nAbos:\n${JSON.stringify(data)}`,
schema: podAiSchema(),
});
} catch (e) {
DioraAI.showError('Mit KI ordnen', e);
return;
}
const byId = new Map(list.map(f => [f.id, f]));
const levelName = v => (LEVELS.find(l => l.value === v) || {}).label || v;
const items = [];
for (const x of answer.vorschlaege || []) {
const f = byId.get(x.id);
if (!f) continue;
const have = foldersOf(f);
const add = (x.ordner_hinzu || []).map(n => String(n).trim()).filter(n => n && !have.some(h => sameName(h, n)));
const change = {};
const lines = [];
if (add.length) { change.folders = have.concat(add); lines.push('Ordner + ' + add.join(', ')); }
if (levels && ['queue', 'inbox', 'ignore'].includes(x.stufe) && x.stufe !== (f.new_episodes || 'inbox')) {
change.new_episodes = x.stufe;
lines.push(`Stufe: ${levelName(f.new_episodes || 'inbox')} → ${levelName(x.stufe)}`);
}
if (!lines.length) continue;
if (x.warum) lines.push('– ' + x.warum);
items.push({id: f.id, title: plain(f.title), lines, change});
}
DioraAI.review({
title: 'Mit KI ordnen',
intro: 'Vorschläge von Claude. Was angehakt ist, wird übernommen; vorhandene Ordner bleiben.',
items,
onApply: async ids => {
for (const id of ids) {
const it = items.find(i => i.id === id);
if (it) N.podcastFeedSettings(id, JSON.stringify(it.change));
}
setTimeout(render, 200);
return `${ids.length} ${ids.length === 1 ? 'Abo' : 'Abos'} geordnet`;
},
});
});
}
function renderFeeds() {
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
const items = podcastFeeds().filter(f => inFolder(f.id) && (!podFaves || !v2() || f.favorite))
.sort((a, b) => sort.cmp(a, b) || byTitle(a, b));
const books = feeds().length - items.length;
const booksHint = () => el('div', 'nui-pod-hint',
`${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`);
if (!items.length) {
if (podFolder != null && podcastFeeds().length) { empty(folderEmpty('abonniert')); return; }
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
if (books) root.append(booksHint());
return;
}
if (selected) {
root.append(selectionBar(items, [
hasFolders() && N.podcastFeedSettings && {label: 'In Ordner …', run: ids => assignFolders(items.filter(f => ids.includes(f.id)))},
], 'Abos antippen'));
} else {
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([
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
window.DioraAI && DioraAI.available() && button('✦', 'Mit KI ordnen: Ordner und Stufen vorschlagen', podcastOrganize),
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()),
].filter(Boolean)));
root.append(head);
}
const list = el('div', 'nui-pod-list');
for (const f of items) list.append(feedRow(f));
root.append(list);
if (books) root.append(booksHint());
}
// For Abos & Quellen (sources.js): a podcast's settings sheet, its episode list.
window.__dioraPodcastFeedSettings = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false;
feedSettingsSheet(f);
return true;
};
// The search (diora-web docs/search.md): a row as the episode lists give it; true when opened.
window.__dioraPodcastOpenEpisode = function (row) {
if (!row || row.id == null) return false;
const e = ep(row);
if (isBook(feeds().find(f => f.id === e.feedId)) && openAudiobook(e.feedId)) return true;
openEpisode(e, {list: 'search'});
return true;
};
// A transcript/article notice was tapped (TranscriptNotices.kt): the episode by id.
window.__dioraOpenEpisodeById = async function (id) {
const d = await loadDetails(id);
if (!d) return false;
return window.__dioraPodcastOpenEpisode(Object.assign({}, d, {
feed__id: d.feed && d.feed.id, feed__title: d.feed && d.feed.title, feed__artwork_url: d.feed && d.feed.artwork_url,
}));
};
window.__dioraPodcastOpenFeed = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false;
if (window.DioraNav) DioraNav.go('podcasts');
else if (typeof showTab === 'function') showTab('podcasts');
openFeed(f);
return true;
};
async function openFeed(f) {
feedView = {feed: f, episodes: null};
selected = null;
render();
scrollTop(0);
try {
const data = await getJson(`/podcasts/feeds/${f.id}/episodes/`);
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()) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear'));
root.append(head);
const line = feedHasSettings() ? 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);
}
// The host keeps per-podcast settings (the browser and the app do; an old host doesn't).
function feedHasSettings() {
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'};
// With the two lists: the three levels of a subscription (docs/podcasts.md).
const LEVELS = [
{value: 'queue', label: 'Stamm', hint: 'neue Folgen auf die Merkliste, geladen'},
{value: 'play', label: 'Stamm, gleich in die Wiedergabeliste', hint: 'neue Folgen spielen als Nächstes, geladen'},
{value: 'inbox', label: 'Strom', hint: 'neue Folgen im Strom – nichts abzuarbeiten, nichts geladen'},
{value: 'ignore', label: 'Auf Abruf', hint: 'nicht im Strom; auf der Seite des Podcasts und in der Suche'},
];
const levelOf = f => LEVELS.find(l => l.value === (f.new_episodes || 'inbox')) || LEVELS[2];
const REFRESH = [
{value: '', label: 'Automatisch', hint: 'nach seinem Rhythmus: wer täglich kommt, stündlich; seltene täglich'},
{value: 'hourly', label: 'Stündlich'},
{value: 'daily', label: 'Täglich'},
{value: 'weekly', label: 'Wöchentlich'},
];
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 (v2() && 'new_episodes' in live) {
parts.push(levelOf(live).label);
const r = REFRESH.find(x => x.value === (live.refresh_interval || ''));
if (live.refresh_interval && r) parts.push('abrufen: ' + r.label.toLowerCase());
} else {
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 (v2() && 'new_episodes' in live && N.podcastFeedSettings) {
const lv = levelOf(live);
items.push({icon: '◇', label: 'Stufe: ' + lv.label, hint: lv.hint, active: lv.value !== 'inbox',
action: choose(`„${title}“ ist …`, LEVELS.map(l => ({
icon: l.value === lv.value ? '●' : '○', label: l.label, hint: l.hint, active: l.value === lv.value,
action: () => save({new_episodes: l.value}),
})))});
if ('refresh_interval' in live) {
const r = REFRESH.find(x => x.value === (live.refresh_interval || '')) || REFRESH[0];
items.push({icon: '⟳', label: 'Abrufen: ' + r.label, hint: r.hint || null, active: !!live.refresh_interval,
action: choose('Wie oft der Server nachsieht', REFRESH.map(x => ({
icon: x.value === r.value ? '●' : '○', label: x.label, hint: x.hint || null, active: x.value === r.value,
action: () => save({refresh_interval: x.value}),
})))});
}
}
if ('new_episodes' in live && N.podcastFeedSettings) {
const ne = live.new_episodes || 'inbox';
if (!v2()) 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 && !v2()) {
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)})))},
);
}
// An audiobook: its episodes are chapters, listened to in the library (diora-web docs/audiobooks.md).
if ('kind' in live && N.podcastFeedSettings) {
const book = isBook(live);
items.push({icon: '▤', label: book ? 'Hörbuch (unter Hören → Hörbücher)' : 'Als Hörbuch',
hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht ' + (v2() ? 'im Strom' : 'in „Neu“'),
active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})});
}
if (hasFolders() && N.podcastFeedSettings) {
const fs = foldersOf(live);
items.push({icon: '▣', label: 'In Ordner …', hint: fs.length ? fs.join(', ') : 'in keinem', active: fs.length > 0,
action: () => assignFolders([live])});
}
// 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');
appendRows(list, items, sort, e => {
const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
if (e.raw.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
return 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();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-info', 'Schließen', closeNotes);
notesSheet = made;
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);
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 = 'rare';
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;
// Since 0.5.71 by time with the seldom podcasts first; who had the newest first gets that once.
if (!localStorage.getItem('nui_pod_rare') && epSort === 'new') epSort = 'rare';
localStorage.setItem('nui_pod_rare', '1');
} catch (e) {}
if (!Array.isArray(epFilters)) epFilters = [];
epFilters = epFilters.filter(id => FILTERS.some(f => f.id === id && (!f.app || DL)));
if (!EP_SORTS.some(s => s.id === epSort)) epSort = 'rare';
let epList = null; // {key, items, total, loading, error}
// The folder too: "Folgen" with a folder chosen is all episodes of that category.
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort + '|' + JSON.stringify(podFolder) + '|' + (view === 'stream' ? 's' : '');
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 === 'rare')) sort = 'played';
if (!filters.includes('history') && epFilters.includes('history') && sort === 'played') sort = 'rare';
}
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 sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
// The Strom: every subscription but the „Auf Abruf“ ones (docs/podcasts.md).
const base = view === 'stream' ? ['stream'] : [];
const params = new URLSearchParams({
filter: base.concat(epFilters.filter(id => id !== 'downloaded')).join(','), sort: sorting.server || sorting.id, limit: '100',
offset: String(offset),
});
if (podFolder != null && hasFolders()) params.set('folder', podFolder);
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 …' : podFolder != null && !epFilters.length ? folderEmpty('zu hören') : 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([
view === 'stream' && N.podcastRadio && button('📻', 'Podcast-Radio: immer die neueste ungehörte Folge – ohne Spuren in Listen und Verlauf',
() => N.podcastRadio(), 'nui-pod-radiobtn'),
view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'),
button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))),
button('☐', 'Mehrere auswählen', () => startSelecting()),
].filter(Boolean)));
root.append(head);
}
const list = el('div', 'nui-pod-list');
if (view === 'stream' && !selected && (epSort === 'new' || epSort === 'rare')) {
streamRows(list, items);
root.append(list);
moreButton();
return;
}
appendRows(list, items, epSort, e => {
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', '★ '));
return row;
});
root.append(list);
moreButton();
}
function moreButton() {
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);
}
}
// The Strom (docs/podcasts.md): by period; in one, the podcasts most listened to first
// (heard_365: episodes heard in 12 months), every one heard at all before the others,
// among those the rarely publishing first. Each podcast once per period: its newest
// episode and „+n ›“ for the others. New since the last look: a small mark.
const streamOpen = new Set();
function streamRank(feedId) {
const f = feeds().find(x => x.id === feedId) || {};
return [f.heard_365 || 0, typeof f.interval_days === 'number' ? f.interval_days : 0];
}
function streamRows(list, items) {
const before = Date.parse(state.stream_seen_before || '') || 0;
const fresh = e => !!before && Date.parse(e.raw.discovered_at || '') > before;
const rank = new Map();
const rankOf = id => { if (!rank.has(id)) rank.set(id, streamRank(id)); return rank.get(id); };
for (const sec of timeSections(items, false)) {
list.append(el('div', 'nui-pod-when', sec.label));
const groups = new Map();
sec.items.forEach((e, i) => {
if (!groups.has(e.feedId)) groups.set(e.feedId, {first: i, eps: []});
groups.get(e.feedId).eps.push(e);
});
const ordered = [...groups.entries()].sort((a, b) => {
const [ha, ia] = rankOf(a[0]);
const [hb, ib] = rankOf(b[0]);
if (ha !== hb) return hb - ha;
if (!ha && ia !== ib) return ib - ia;
return a[1].first - b[1].first;
});
for (const [feedId, g] of ordered) {
const key = sec.label + '|' + feedId;
const open = streamOpen.has(key);
const shownEps = open ? g.eps : g.eps.slice(0, 1);
shownEps.forEach((e, i) => {
const row = episodeRow(e, {showFeed: true, showDate: true, list: 'stream', fresh: fresh(e), filters: epFilters});
if (i > 0) row.classList.add('nui-pod-bundled');
list.append(row);
});
if (g.eps.length > 1) {
const rest = g.eps.length - 1;
const freshRest = !open && g.eps.slice(1).some(fresh);
const b = el('button', 'nui-pod-bundle' + (freshRest ? ' nui-pod-fresh' : ''),
open ? 'weniger ‹' : `+${rest} ${rest === 1 ? 'weitere' : 'weitere'} von ${g.eps[0].feed || 'diesem Podcast'} ›`);
b.type = 'button';
b.addEventListener('click', () => {
if (open) streamOpen.delete(key); else streamOpen.add(key);
render();
});
list.append(b);
}
}
}
}
// ---- 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();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet', 'Schließen', closeStats);
statsSheet = made;
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);
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 {
d = await getJson('/podcasts/stats/' + (range.days ? `?days=${range.days}` : ''));
} 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);
if (hasSources()) {
const more = el('button', 'nui-pod-hint nui-pod-link', 'Alles abonnieren und verwalten – auch YouTube, Nachrichten, Blogs: Abos & Quellen ›');
more.type = 'button';
more.addEventListener('click', toSources);
root.append(more);
}
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: podcastFeeds().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 d = await getJson(`/podcasts/episodes/?q=${encodeURIComponent(s.q)}&notes=1&limit=30&offset=${offset}`);
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'));
const openFromMini = () => (miniKind === 'tts' ? ttsShowBook()
: miniKind === 'ambient' ? showTab('ambient')
: miniKind === 'radio' ? openRadioPlayer() : openPlayer());
text.addEventListener('click', openFromMini);
miniGestures(mini, openFromMini);
const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
devices.hidden = here.hidden = true;
const tts = (sign, label, fn, cls) => {
const b = button(sign, label, () => { const t = window.__dioraTts; if (t) fn(t); }, 'nui-tts-btn ' + cls);
b.hidden = true;
return b;
};
mini.append(art('', 'mini'), text,
tts('⏮︎', 'Voriger Satz', t => t.prev(), 'nui-tts-prev'),
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 === 'tts' ? window.__dioraTts && window.__dioraTts.toggle()
: miniKind === 'ambient' ? ambientToggle()
: miniKind === 'radio' ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'),
tts('⏭︎', 'Nächster Satz', t => t.next(), 'nui-tts-next'),
tts('■', 'Vorlesen beenden', t => t.stop(), 'nui-tts-stop'));
dock.prepend(mini);
// Read-aloud uses this bar; app.js leaves out its own (.tts-bar).
window.__dioraTtsDock = true;
// 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 (with the page's
// navigation, nav.js, the bar has the podcasts as a section already).
const tabbar = $id('nui-tabbar');
if (tabbar && !window.DioraNav && !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"]'));
}
}
// Ambient (app.js's #tab-ambient): its sleep timer, a ☾ in the section's header
// (native-ui.js), else at the frame's top right corner.
function ambientSleepButton() {
const tab = $id('tab-ambient');
if (!tab || !N.podcastSleep || $id('nui-amb-sleep')) return;
const b = button('☾', 'Sleep-Timer', () => sleepMenu(true, true), 'nui-pod-sleep');
b.id = 'nui-amb-sleep';
const bar = tab.querySelector('.nui-section-bar');
if (bar) bar.append(b); else tab.append(b);
}
function renderAmbientSleep() {
const b = $id('nui-amb-sleep');
if (!b) return;
b.textContent = sleepLabel(true) || '☾';
b.classList.toggle('on', !!sleepLabel(true));
}
// Ambient (app.js) started last: the bar is its — the sound world's name, ▶/■, a tap opens
// its tab — until the radio or an episode starts.
function ambientInfo() {
return window.__dioraAmbientInfo ? window.__dioraAmbientInfo() : null;
}
function ambientToggle() {
const a = ambientInfo();
if (a && window.__dioraAmbientCmd) window.__dioraAmbientCmd(a.playing ? 'stop' : 'start');
}
window.__dioraAmbientChanged = () => renderMini();
function renderAmbientMini() {
const a = ambientInfo();
if (!mini || !a || !a.last) return false;
// An episode or the radio plays here: the bar is theirs, whatever came before.
const r = radioNow();
if (!a.playing && ((player.playing && !player.remote) || (r && r.playing))) return false;
if (player.kind === 'podcast' && player.playing && !player.remote) return false;
miniKind = 'ambient';
document.documentElement.classList.remove('nui-pod-playing');
document.documentElement.classList.add('nui-pod-mini-open');
mini.hidden = false;
mini.classList.add('nui-mini-radio');
mini.classList.remove('nui-mini-remote');
for (const c of ['.nui-pod-skip', '.nui-mini-save', '.nui-mini-devices', '.nui-mini-here']) mini.querySelector(c).hidden = true;
mini.querySelector('.nui-mini-station').textContent = 'Ambient';
const z = sleepLabel(true);
mini.querySelector('.nui-mini-track').textContent = (a.mode || 'Drift') + (a.playing ? '' : ' · angehalten') + (z ? ' · ' + z : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = a.playing ? '■' : '▶';
t.title = a.playing ? 'Ambient stoppen' : 'Ambient abspielen';
const box = mini.querySelector('.nui-pod-art');
setArt(box, '');
const ttsArt = box.querySelector('.nui-tts-art');
if (ttsArt) ttsArt.remove();
if (!box.querySelector('.nui-amb-art')) { box.textContent = ''; box.append(el('span', 'nui-pod-initial nui-amb-art', '∿')); }
mini.querySelector('.nui-pod-mini-bar span').style.width = a.playing ? '100%' : '0';
return true;
}
// Read-aloud (app.js, window.__dioraTts; on Android with the device's voice) has the bar
// while it runs: the book, the chapter, sentence back / pause / sentence on / stop.
function ttsState() {
try { return window.__dioraTts ? window.__dioraTts.state() : null; } catch (e) { return null; }
}
function ttsButtons(on) {
if (!mini) return;
mini.querySelectorAll('.nui-tts-btn').forEach(b => { b.hidden = !on; });
}
// A tap on the text: back to the book that's being read, if it isn't open.
function ttsShowBook() {
const s = ttsState();
if (s && s.bookId && typeof openBook === 'function' && (typeof currentBookId === 'undefined' || currentBookId !== s.bookId)) openBook(s.bookId);
}
function renderTtsMini() {
const s = ttsState();
document.documentElement.classList.toggle('nui-tts-on', !!(s && s.active));
if (!mini || !s || !s.active) return false;
miniKind = 'tts';
document.documentElement.classList.remove('nui-pod-playing');
document.documentElement.classList.add('nui-pod-mini-open');
mini.hidden = false;
mini.classList.add('nui-mini-radio');
mini.classList.remove('nui-mini-remote');
for (const c of ['.nui-pod-skip', '.nui-mini-save', '.nui-mini-devices', '.nui-mini-here']) mini.querySelector(c).hidden = true;
ttsButtons(true);
mini.querySelector('.nui-mini-station').textContent = s.title || 'Vorlesen';
mini.querySelector('.nui-mini-track').textContent = (s.paused ? 'Angehalten' : 'Vorlesen') + (s.chapter ? ' · ' + s.chapter : '');
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = s.paused ? '▶' : '❚❚';
t.title = s.paused ? 'Weiterlesen' : 'Pause';
const box = mini.querySelector('.nui-pod-art');
setArt(box, '');
const amb = box.querySelector('.nui-amb-art');
if (amb) amb.remove();
if (!box.querySelector('.nui-tts-art')) { box.textContent = ''; box.append(el('span', 'nui-pod-initial nui-tts-art', '❝')); }
mini.querySelector('.nui-pod-mini-bar span').style.width = typeof s.progress === 'number' ? Math.min(100, s.progress * 100) + '%' : '0';
return true;
}
window.addEventListener('diora-tts', () => renderMini());
// 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() {
renderAmbientSleep();
if (renderTtsMini()) return;
ttsButtons(false);
if (renderAmbientMini()) return;
const r = remote();
const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod || !!r);
if (!mini) return;
const ambArt = mini.querySelector('.nui-amb-art, .nui-tts-art');
if (ambArt) ambArt.remove();
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 …'
: (bookLine(player) || 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 d = await getJson(`/podcasts/episodes/${id}/`);
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;
}
const tr = trOf(id);
trFill(tr, d, !!fresh);
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');
// Jump to a second of the episode (taps on the article's "▶ ab 12:34" links).
const seek = s => {
if (live) N.podcastSeek(s * 1000);
else if (jump) jump(s);
else toast('Zum Anhören die Folge öffnen');
};
let current = '';
let sig = '';
const sigOf = () => tabList().map(x => x[0]).join() + '|' +
(current === 'transcript' ? (tr.has ? 'has' : trSig(id)) : current === 'article' ? trSig(id) + ':' + tr.article : '');
const show = which => {
current = which;
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
body.textContent = '';
if (which === 'article') {
articlePanel(body, id, seek);
} else 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);
}
sig = sigOf();
};
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);
};
// The Artikel tab comes first (when there is one); Transkript for every episode with sound
// (not YouTube: the server never fetches its sound), with a button to make one when it is missing.
const canTranscribe = !!d.audio_url && !isYouTube(d.audio_url);
function tabList() {
const list = [];
if (tr.article) list.push(['article', 'Artikel']);
if (chapters.length && !chapBox) list.push(['chapters', `Kapitel (${chapters.length})`]);
list.push(['notes', 'Shownotes']);
if (tr.has || canTranscribe) list.push(['transcript', 'Transkript']);
return list;
}
const draw = want => {
const list = tabList();
tabs.textContent = '';
list.forEach(([w, l]) => tab(w, l));
show(list.some(x => x[0] === want) ? want : list[0][0]);
};
// Called when the state of the episode's transcript / article changes.
box.trUpdate = select => {
if (select) draw(select);
else if (sigOf() !== sig) draw(current);
};
trBoxes.add(box);
box.append(tabs, body);
draw('');
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
}
// 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) {
// None yet: the button to make one (or its progress).
if (!trOf(id).has) { transcriptPanel(body, id); return; }
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
let t = transcripts.get(id);
if (!t) {
try {
t = await getJson(`/podcasts/episodes/${id}/transcript/`);
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 = '';
if (trOf(id).origin === 'whisper') body.append(el('div', 'nui-pod-meta nui-pod-tr-origin', 'automatisch transkribiert'));
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'});
}
}
// ---- smart transcription (diora-web docs/transcription.md) ------------------------------
// The server transcribes (a job per episode, polled every 5 s while it is on screen); the article
// is written on this device with the user's own key (DioraPodcastArticle). One state per episode
// feeds the Transkript tab, the Artikel tab and the "✦ Artikel" button.
const trStates = new Map(); // episode id -> {job, writing, has, origin, article}
const trBoxes = new Set(); // the details boxes on screen (showDetails)
const trArticles = new Map(); // feed item id -> its item (the article's text)
let trTimer = null;
async function trFetch(method, url, body) {
const headers = {'Content-Type': 'application/json'};
if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken();
const r = await fetch(url, {method, headers, cache: 'no-store', body: body === undefined ? undefined : JSON.stringify(body)});
let data = null;
try { data = await r.json(); } catch (e) {}
return {ok: r.ok, status: r.status, data};
}
function trFill(t, d, fresh) {
if (fresh || !t.has) t.has = !!d.has_transcript;
if (fresh || !t.origin) t.origin = d.transcript_origin || null;
if (fresh || t.article == null) t.article = d.article_item_id || null;
if ((fresh || !t.job) && 'transcription' in d) t.job = d.transcription || null;
}
function trOf(id) {
let t = trStates.get(id);
if (!t) {
t = {job: null, writing: null, has: false, origin: null, article: null, polling: false};
trStates.set(id, t);
const d = storedDetails(id);
if (d) trFill(t, d, true);
}
return t;
}
const trActive = j => !!j && (j.state === 'queued' || j.state === 'downloading' || j.state === 'transcribing');
// What is going on with the episode right now (a transcription on the server, or the article
// being written here), as the line the buttons show; null when nothing.
function trStatus(id) {
const t = trOf(id);
if (t.writing) return {text: t.writing.text, kind: 'writing'};
const j = t.job;
if (!trActive(j)) return null;
const text = j.state === 'queued' ? (j.error ? 'Wartet: ' + j.error : 'Wartet …')
: j.state === 'downloading' ? 'Lade Folge …'
: j.chunks_total ? `Transkribiere ${j.chunks_done || 0}/${j.chunks_total} …` : 'Transkribiere …';
return {text, kind: 'job'};
}
// Changes only when what a view shows changes.
function trSig(id) {
const t = trOf(id);
const st = trStatus(id);
return (st ? st.text : '') + '|' + (t.job && t.job.state === 'error' ? t.job.error || 'x' : '');
}
const trVisible = id => (epScreen && epScreen.e.id === id && !!notesSheet && notesSheet.isConnected) ||
[...trBoxes].some(b => b.isConnected && b.dataset.episode === String(id));
function trChanged(id, select) {
for (const b of [...trBoxes]) {
if (!b.isConnected) { trBoxes.delete(b); continue; }
if (b.dataset.episode === String(id) && b.trUpdate) b.trUpdate(select);
}
if (epScreen && epScreen.e.id === id) episodeScreenParts();
trSchedule();
}
// Poll the jobs on screen every five seconds; nothing runs when none is shown.
function trSchedule() {
const ids = [...trStates].filter(([id, t]) => trActive(t.job) && trVisible(id)).map(([id]) => id);
if (ids.length && !trTimer) {
trTimer = setInterval(trTick, 5000);
trTick();
} else if (!ids.length && trTimer) {
clearInterval(trTimer);
trTimer = null;
}
}
async function trTick() {
if (document.hidden) return;
for (const [id, t] of [...trStates]) {
if (!trActive(t.job) || !trVisible(id) || t.polling) continue;
t.polling = true;
try {
const r = await trFetch('GET', `/podcasts/episodes/${id}/transcribe/`);
if (r.ok && r.data) await trApply(id, r.data);
} catch (e) {} finally { t.polling = false; }
}
trSchedule();
}
async function trApply(id, job) {
const t = trOf(id);
if (!job || job.state === 'none') { t.job = null; trChanged(id); return; }
t.job = job;
if (job.state !== 'done') { trChanged(id); return; }
const want = !!job.want_article;
t.job = null;
t.has = true;
transcripts.delete(id);
await trFresh(id);
trChanged(id);
// diora is open: the article is written right away (when this device has a key; else it waits).
if (want && !t.article && !t.writing) writeArticle(id, {claim: true});
}
async function trFresh(id) {
const d = await loadDetails(id);
if (d) trFill(trOf(id), d, true);
return d;
}
async function startTranscription(id, article) {
const t = trOf(id);
if (!navigator.onLine) { toast('Das braucht eine Verbindung'); return; }
let r;
try { r = await trFetch('POST', `/podcasts/episodes/${id}/transcribe/`, {article: !!article}); } catch (e) { r = {ok: false, status: 0}; }
if (!r.ok) {
const err = r.data && r.data.error;
const msg = err === 'youtube' ? 'YouTube-Folgen lassen sich nicht transkribieren.'
: err === 'too large' ? 'Die Folge ist zu groß zum Transkribieren (über 800 MB).'
: r.status ? `Das Transkribieren ließ sich nicht starten (${r.status}).` : 'Keine Verbindung.';
t.job = {state: 'error', error: msg, want_article: !!article};
trChanged(id);
return;
}
const job = r.data || {};
if (job.state === 'done') {
t.job = null;
t.has = true;
await trFresh(id);
trChanged(id);
if (article) writeArticle(id);
return;
}
t.job = Object.assign({state: 'queued', want_article: !!article}, job);
if (article && window.DioraPodcastArticle) DioraPodcastArticle.watch(true);
trChanged(id);
}
async function cancelTranscription(id) {
const t = trOf(id);
try { await trFetch('DELETE', `/podcasts/episodes/${id}/transcribe/`); } catch (e) {}
t.job = null;
trChanged(id);
}
// The key first: without one the settings open (write() would wait for ever behind the sheet).
async function trKeyReady() {
const AI = window.DioraAI;
if (!AI || !AI.available()) { toast('Die KI-Funktionen gibt es hier nicht'); return false; }
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) {
toast('Für den Artikel braucht diora deinen KI-Schlüssel');
AI.settings();
}
return has;
}
async function articleAction(id) {
const st = trStatus(id);
if (st) { toast(st.text); return; }
if (!(await trKeyReady())) return;
if (trOf(id).has) writeArticle(id);
else startTranscription(id, true);
}
// Write the article here and save it. `claim`: a wish of the server (reserve it first, so that the
// phone and the desktop don't both write it) and no questions when there is no key.
async function writeArticle(id, opts) {
const claim = !!(opts && opts.claim);
const t = trOf(id);
if (t.writing) return;
const AI = window.DioraAI;
const PA = window.DioraPodcastArticle;
if (!AI || !AI.available() || !PA) { if (!claim) toast('Artikel schreiben geht hier nicht'); return; }
if (claim) {
let has = false;
try { has = await AI.hasKey(); } catch (e) {}
if (!has) return;
}
const ctrl = new AbortController();
t.writing = {text: 'Lade Transkript …', ctrl};
trChanged(id);
let claimed = false;
try {
if (claim) {
const c = await PA.claim(id);
if (!c.ok) { t.writing = null; trChanged(id); return; } // another device has it
claimed = true;
}
let last = 0;
const saved = await PA.write(id, {
signal: ctrl.signal,
onState: s => {
if (!t.writing) return;
t.writing.text = s.text;
const now = Date.now();
if (s.phase !== 'writing' || now - last > 1000) { last = now; trChanged(id); }
},
});
if (t.article) trArticles.delete(t.article);
t.article = saved.item_id;
t.writing = null;
await trFresh(id);
if (claim && PA.notifyDone) PA.notifyDone(saved.title, id); else toast('Artikel fertig');
trChanged(id, 'article');
} catch (e) {
t.writing = null;
if (claimed) { try { await PA.release(id); } catch (e2) {} }
trChanged(id);
if (ctrl.signal.aborted) toast('Abgebrochen');
else if (AI.showError) AI.showError('Artikel schreiben', e);
else toast((e && e.message) || 'Der Artikel ließ sich nicht schreiben');
}
}
async function removeArticle(id) {
const t = trOf(id);
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel zu dieser Folge entfernen? Er verschwindet auch aus „Podcast-Artikel“ in den Blogs.', {danger: true}) : true;
if (!ok) return;
let r;
try { r = await trFetch('DELETE', `/podcasts/episodes/${id}/article/`); } catch (e) { r = {ok: false}; }
if (!r.ok) { toast('Entfernen ging nicht'); return; }
if (t.article) trArticles.delete(t.article);
t.article = null;
await trFresh(id);
trChanged(id);
toast('Artikel entfernt');
}
// The Transkript tab of an episode without a transcript: start, progress, error.
function transcriptPanel(body, id) {
const t = trOf(id);
body.textContent = '';
const st = trStatus(id);
const wrap = el('div', 'nui-pod-tr');
if (st) {
wrap.append(el('div', 'nui-pod-tr-status', st.text));
if (st.kind === 'job') {
wrap.append(el('div', 'nui-pod-meta', t.job.want_article
? 'Das dauert je nach Länge Minuten bis Stunden. Danach schreibt diora den Artikel, wenn diora offen ist.'
: 'Das dauert je nach Länge Minuten bis Stunden. Du bekommst eine Benachrichtigung, wenn es fertig ist.'));
wrap.append(trButton('Abbrechen', () => cancelTranscription(id)));
} else {
wrap.append(trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
}
} else if (t.job && t.job.state === 'error') {
wrap.append(el('div', 'nui-pod-tr-error', t.job.error || 'Das Transkribieren ist fehlgeschlagen.'));
wrap.append(trButton('Erneut versuchen', () => startTranscription(id, !!t.job.want_article), true));
} else {
wrap.append(el('div', 'nui-pod-meta', 'Diese Folge hat kein Transkript. Der Server kann es erstellen; das dauert je nach Länge ' +
'Minuten bis Stunden, am Ende kommt eine Benachrichtigung.'));
wrap.append(trButton('Transkript erstellen', () => startTranscription(id, false), true));
}
body.append(wrap);
}
function trButton(label, run, primary) {
const b = el('button', 'btn nui-pod-trbtn' + (primary ? ' btn-primary' : ''), label);
b.type = 'button';
b.addEventListener('click', run);
return b;
}
// The Artikel tab: the article from Blogs (a feed item), its three actions, taps on "▶ ab 12:34" seek.
async function articlePanel(body, id, seek) {
const t = trOf(id);
const itemId = t.article;
body.textContent = '';
const bar = el('div', 'nui-pod-trbar');
const st = trStatus(id);
if (st && st.kind === 'writing') {
bar.append(el('span', 'nui-pod-tr-status', st.text), trButton('Abbrechen', () => { if (t.writing) t.writing.ctrl.abort(); }));
} else {
if (window.__dioraFeedsOpenItem) {
bar.append(trButton('In Blogs öffnen', () => {
closeNotes();
closePlayer();
window.__dioraFeedsOpenItem(itemId);
}));
}
if (window.DioraAI && DioraAI.available() && !st) bar.append(trButton('Neu erstellen', () => rewriteArticle(id)));
bar.append(trButton('Entfernen', () => removeArticle(id)));
}
const text = el('div', 'nui-pod-notes nui-pod-article');
body.append(bar, text);
let item = trArticles.get(itemId);
if (!item) {
text.append(el('div', 'nui-pod-meta', 'Artikel wird geladen …'));
try {
item = (await getJson(`/feeds/items/${itemId}/`)).item;
if (item) trArticles.set(itemId, item);
} catch (e) {}
if (!text.isConnected || t.article !== itemId) return;
text.textContent = '';
}
if (!item) {
text.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Der Artikel ließ sich nicht laden.' : 'Der Artikel braucht eine Verbindung.'));
return;
}
// The server cleaned the HTML (feeds/fetch.py clean_html), as for every feed item.
if (item.content) text.innerHTML = item.content;
else text.append(el('p', null, item.summary || ''));
text.querySelectorAll('a[href]:not([href^="#t="])').forEach(a => a.setAttribute('target', '_blank'));
text.addEventListener('click', ev => {
const a = ev.target.closest('a[href^="#t="]');
if (!a) return;
ev.preventDefault();
const s = parseInt(a.getAttribute('href').slice(3), 10);
if (s >= 0) seek(s);
});
}
// "Neu erstellen" replaces the article: ask first.
async function rewriteArticle(id) {
const ok = typeof customConfirm === 'function'
? await customConfirm('Den Artikel neu schreiben? Der jetzige wird ersetzt.') : true;
if (ok) articleAction(id);
}
// The "✦ Artikel" button of the episode screen, or null where it isn't offered.
function trArticleButton(e) {
const id = e.id;
if (!(id > 0)) return null;
const t = trOf(id);
const d = storedDetails(id) || {};
const url = (e.raw && (e.raw.audio_url || e.raw.episode__audio_url)) || d.audio_url || '';
if (e.yt || !url || isYouTube(url)) return null;
const st = trStatus(id);
if (st) return {label: st.text, run: () => toast(st.text), cls: 'on'};
if (t.article) return {label: 'Artikel', run: () => { if (epScreen && epScreen.box && epScreen.box.trUpdate) epScreen.box.trUpdate('article'); }, cls: 'on'};
if (!(window.DioraAI && DioraAI.available())) return null;
return {label: t.has ? 'Artikel erstellen' : 'Transkribieren und Artikel erstellen', run: () => articleAction(id)};
}
// ---- settings of the transcription instance (GET/POST /podcasts/transcription/settings/) ----
const TR_LANGUAGES = [['', 'Automatisch'], ['de', 'Deutsch'], ['en', 'Englisch'], ['fr', 'Französisch'], ['es', 'Spanisch'],
['it', 'Italienisch'], ['nl', 'Niederländisch'], ['pt', 'Portugiesisch'], ['pl', 'Polnisch'], ['ru', 'Russisch'],
['tr', 'Türkisch'], ['uk', 'Ukrainisch'], ['ja', 'Japanisch'], ['zh', 'Chinesisch']];
let trSheet = null;
function closeTrSettings() {
if (!trSheet) return false;
trSheet.remove();
trSheet = null;
return true;
}
async function transcriptionSettings() {
closeTrSettings();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-trset', 'Schließen', closeTrSettings);
trSheet = made;
const body = el('div', 'nui-pod-trform');
panel.append(top, el('div', 'nui-pod-player-title', 'Transkription'), body);
document.body.append(made);
body.append(el('div', 'nui-pod-meta', 'Einstellungen werden geladen …'));
let s;
try {
const r = await trFetch('GET', '/podcasts/transcription/settings/');
if (!r.ok) throw new Error(r.status);
s = r.data;
} catch (e) {
body.textContent = '';
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Die Einstellungen ließen sich nicht laden.' : 'Die Einstellungen brauchen eine Verbindung.'));
return;
}
if (trSheet !== made) return;
drawTranscriptionSettings(body, s, made);
}
function drawTranscriptionSettings(body, s, made) {
body.textContent = '';
const field = (label, input) => {
const l = el('label', 'nui-pod-trfield');
l.append(el('span', null, label), input);
return l;
};
const text = (type, value, placeholder) => {
const i = el('input', 'search-input');
i.type = type;
i.value = value == null ? '' : value;
if (placeholder) i.placeholder = placeholder;
i.setAttribute('autocomplete', 'off');
i.setAttribute('autocapitalize', 'off');
i.spellcheck = false;
return i;
};
body.append(el('div', 'nui-pod-meta', 'Der Server schneidet die Folge in Stücke und schickt sie an eine Whisper-Instanz ' +
'(OpenAI-kompatibel). Das passiert nur, wenn du es in einer Folge auslöst.'));
let engine = s.engine === 'builtin' && s.builtin_available ? 'builtin' : 'custom';
const engines = el('div', 'nui-pod-subtabs');
const options = [s.builtin_available && ['builtin', 'Eingebaute Instanz'], ['custom', 'Eigene Instanz']].filter(Boolean);
const url = text('url', s.url, 'https://whisper.example.org/v1');
const token = text('password', '', s.has_token ? 'gespeichert (leer lassen = behalten)' : 'optional');
token.setAttribute('autocomplete', 'new-password');
const urlField = field('Adresse', url);
const tokenField = field('Token', token);
const sync = () => {
engines.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.engine === engine));
urlField.hidden = tokenField.hidden = engine !== 'custom';
};
for (const [value, label] of options) {
const b = el('button', 'nui-pod-subtab', label);
b.type = 'button';
b.dataset.engine = value;
b.addEventListener('click', () => { engine = value; sync(); });
engines.append(b);
}
body.append(engines, urlField, tokenField);
const model = text('text', s.model, 'z. B. large-v3-turbo');
const lang = el('select', 'search-input');
const langs = TR_LANGUAGES.some(l => l[0] === (s.language || '')) ? TR_LANGUAGES : TR_LANGUAGES.concat([[s.language, s.language]]);
for (const [v, l] of langs) {
const o = el('option', null, l);
o.value = v;
lang.append(o);
}
lang.value = s.language || '';
const par = text('number', s.parallel || 3);
par.min = '1';
par.max = '8';
par.inputMode = 'numeric';
body.append(field('Modell', model), field('Sprache', lang), field('Parallel (1 bis 8 Stücke gleichzeitig)', par));
const status = el('div', 'nui-pod-tr-status');
status.setAttribute('aria-live', 'polite');
const say = (msg, bad) => { status.textContent = msg; status.classList.toggle('nui-pod-tr-error', !!bad); };
// Save; the token is sent only when typed (empty keeps it) or removed on purpose.
const save = async removeToken => {
if (engine === 'custom' && !url.value.trim()) { say('Bitte eine Adresse eintragen.', true); return null; }
const post = {
engine, url: url.value.trim(), model: model.value.trim(), language: lang.value,
parallel: Math.max(1, Math.min(8, parseInt(par.value, 10) || 3)),
};
if (removeToken) post.token = '';
else if (token.value) post.token = token.value;
const r = await trFetch('POST', '/podcasts/transcription/settings/', post);
if (!r.ok) { say((r.data && r.data.error) || `Speichern ging nicht (${r.status}).`, true); return null; }
let next = r.data && 'engine' in r.data ? r.data : null;
if (!next) {
const g = await trFetch('GET', '/podcasts/transcription/settings/');
next = g.ok ? g.data : Object.assign({}, s, post, {has_token: !!(s.has_token && !removeToken) || !!post.token});
}
return next;
};
const run = fn => async () => {
try { await fn(); } catch (e) { say(navigator.onLine ? 'Das ging nicht.' : 'Keine Verbindung.', true); }
};
const buttons = el('div', 'nui-pod-trbar');
buttons.append(
trButton('Speichern', run(async () => {
const next = await save(false);
if (next && trSheet === made) { toast('Gespeichert'); drawTranscriptionSettings(body, next, made); }
}), true),
trButton('Verbindung testen', run(async () => {
say('Speichere und teste …');
const next = await save(false);
if (!next) return;
const r = await trFetch('POST', '/podcasts/transcription/test/', {});
if (trSheet !== made) return;
drawTranscriptionSettings(body, next, made);
const out = body.querySelector('.nui-pod-tr-status');
const ok = r.ok && r.data && r.data.ok;
out.textContent = ok ? `Verbindung steht${r.data.model ? ' (Modell ' + r.data.model + ')' : ''}.`
: 'Verbindung fehlgeschlagen: ' + ((r.data && r.data.error) || `Fehler ${r.status}`);
out.classList.toggle('nui-pod-tr-error', !ok);
})));
if (s.has_token) {
buttons.append(trButton('Token entfernen', run(async () => {
const next = await save(true);
if (next && trSheet === made) { toast('Token entfernt'); drawTranscriptionSettings(body, next, made); }
})));
}
body.append(buttons, status);
if (s.has_token) body.append(el('div', 'nui-pod-meta', 'Token: gespeichert'));
sync();
}
// ---- 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 || {};
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-episode', 'Schließen', closeNotes);
notesSheet = made;
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);
// Similar episodes from the other podcasts (docs/podcasts.md, Stöbern).
if (v2() && e.id > 0) {
const sim = el('div', 'nui-pod-similar');
panel.append(sim);
browseGet(`/podcasts/discover/similar/${e.id}/`, 60 * 60000).then(d => {
const rows = (d.episodes || []).slice(0, 6);
if (!rows.length || !sim.isConnected) return;
sim.append(el('div', 'nui-pod-when', 'Ähnliche Folgen'));
const list = el('div', 'nui-pod-list');
for (const o of rows) list.append(episodeRow(ep(o), {showFeed: true, showDate: true, list: 'stream', noSelect: true}));
sim.append(list);
}).catch(() => {});
}
document.body.append(notesSheet);
epScreen = {e, ctx, actions, playBtn, box, 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 (v2() && N.podcastLaterAdd) {
if (inLater(e)) act('listx', '', 'Aus der Merkliste', () => toggleLater(e));
else act(null, '+', 'Merken', () => toggleLater(e));
if (inQueue(e.id)) act('listx', '', 'Aus der Liste', () => { N.podcastDequeue(e.id); e.raw.in_queue = false; });
} else 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 (window.DioraProjects) act(null, '◇', 'Projekt', () => linkProject(e, 0));
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');
}
act(null, '⇪', 'Teilen', () => shareEpisode(e));
const art = trArticleButton(e);
if (art) act(null, '✦', art.label, art.run, art.cls);
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 = '';
// ---- gestures on the player (native-gestures.js has the reader's and the lists') -----------
// The mini player: up opens the big player; sideways skips back / forward (an episode,
// read-aloud's sentences) or goes to the previous / next saved station.
function miniGestures(bar, open) {
let g = null;
let swiped = 0;
bar.addEventListener('touchstart', e => {
g = e.touches.length === 1 ? {x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now()} : null;
}, {passive: true});
bar.addEventListener('touchend', e => {
if (!g) return;
const t = e.changedTouches[0];
const dx = t.clientX - g.x;
const dy = t.clientY - g.y;
const quick = Date.now() - g.t < 700;
g = null;
if (!quick) return;
if (dy < -40 && Math.abs(dy) > Math.abs(dx) * 1.2) {
swiped = Date.now();
open();
} else if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
swiped = Date.now();
miniStep(dx < 0);
}
}, {passive: true});
// The tap that a swipe ends with is no tap.
bar.addEventListener('click', e => {
if (Date.now() - swiped < 450) { e.stopPropagation(); e.preventDefault(); }
}, true);
}
function miniStep(forward) {
const prefs = devicePrefs();
const secs = forward ? (prefs.skip_forward || 30) : (prefs.skip_back || 15);
if (miniKind === 'remote') {
remoteCmd('skip', forward ? secs : -secs);
toast((forward ? '+' : '−') + secs + ' s');
} else if (miniKind === 'tts') {
const t = window.__dioraTts;
if (t) forward ? t.next() : t.prev();
} else if (miniKind === 'radio') {
radioStep(forward);
} else if (miniKind !== 'ambient') {
N.podcastSkip(forward);
toast((forward ? '+' : '−') + secs + ' s');
}
}
// The previous / next of the saved stations (the radio's list, in its order).
function radioStep(forward) {
const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')];
if (!rows.length || typeof playStation !== 'function') return;
const now = typeof currentStation !== 'undefined' && currentStation ? currentStation.url : null;
const at = rows.findIndex(r => r.dataset.url === now);
const row = rows[((at < 0 ? (forward ? -1 : 0) : at) + (forward ? 1 : -1) + rows.length) % rows.length];
playStation(row.dataset.url, row.dataset.name, parseInt(row.dataset.id, 10) || null);
toast('◉ ' + row.dataset.name);
}
// The big player's cover: tap twice on its left / right half to skip back / forward.
function coverGestures(cover) {
let last = 0;
let lastSide = null;
cover.addEventListener('touchend', e => {
if (e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const r = cover.getBoundingClientRect();
const side = t.clientX < r.left + r.width / 2 ? 'back' : 'fwd';
const now = Date.now();
if (now - last < 320 && side === lastSide) {
e.preventDefault();
last = 0;
const prefs = devicePrefs();
const fwd = side === 'fwd';
N.podcastSkip(fwd);
const mark = el('span', 'nui-pod-cover-skip ' + (fwd ? 'nui-right' : 'nui-left'),
(fwd ? '+' : '−') + (fwd ? (prefs.skip_forward || 30) : (prefs.skip_back || 15)) + ' s');
cover.append(mark);
setTimeout(() => mark.remove(), 700);
return;
}
last = now;
lastSide = side;
}, {passive: false});
}
function openPlayer() {
if (remote()) { openRemotePlayer(); return; }
if (player.kind !== 'podcast') return;
closePlayer();
sheetKind = 'podcast';
const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Player');
sheet = made;
const cover = art(player.artwork, 'player', null, player.feed);
coverGestures(cover);
panel.append(top, cover);
const chapBox = el('div', 'nui-pod-chapbox');
chapBox.hidden = true;
const feedLine = el('div', 'nui-pod-player-feed nui-pod-player-link');
feedLine.setAttribute('role', 'button');
feedLine.title = 'Zur Seite des Podcasts';
// Tapping the podcast's (or the audiobook's) name: its page.
feedLine.addEventListener('click', () => {
const id = player.feedId;
if (!id) return;
if (isBook(feeds().find(f => f.id === id)) || player.book) {
if (openAudiobook(id)) return;
}
closePlayer();
window.__dioraPodcastOpenFeed(id);
});
// The Podcast-Radio: what comes next; + (Merkliste) and "Normal weiterhören" (diora-web docs/podcasts.md).
const radioBox = el('div', 'nui-pod-radiobox');
radioBox.hidden = true;
const radioActs = el('div', 'nui-pod-radioacts');
radioActs.append(
button('+ Merkliste', 'Auf die Merkliste (das Radio spielt weiter)', () => { N.podcastRadioLater(); toast('Auf der Merkliste'); }, 'nui-pod-radiolater'),
button('Normal weiterhören', 'Aus dem Radio nehmen: zuerst in die Wiedergabeliste, ab hier mit Verlauf',
() => { N.podcastRadioCommit(); toast('Läuft jetzt normal – zuerst in der Wiedergabeliste'); }, 'nui-pod-radiocommit'),
);
radioBox.append(el('div', 'nui-pod-radionext'), radioActs);
panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox, radioBox);
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'),
window.DioraProjects && button('◇', 'Mit Projekt verknüpfen – ab dieser Stelle', () => linkProject({
id: player.episodeId, title: player.title, feed: player.feed, feedId: player.feedId,
}, (player.pos || 0) / 1000), 'nui-pod-project'),
button('≡', QNAME(), showQueue),
].filter(Boolean));
panel.append(extra);
const notes = el('div', 'nui-pod-notesbox');
panel.append(notes);
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';
const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Radio');
sheet = made;
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);
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());
// Home (diora-web static/js/home.js): one of the lists, e.g. "inbox" for the new episodes.
window.__dioraPodcastView = function (id) {
if (VIEW_IDS.indexOf(id) < 0) return;
selected = null;
feedView = null;
preview = null;
view = id;
try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
render();
scrollTop(0);
};
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 = bookLine(player) || player.feed || '';
const radioBox = sheet.querySelector('.nui-pod-radiobox');
if (radioBox) {
radioBox.hidden = !player.radio || !N.podcastRadioCommit;
const n = player.radio && player.radio.next;
radioBox.querySelector('.nui-pod-radionext').textContent = n ? `Danach: ${plain(n.feed)} – ${plain(n.title)}` : '';
}
const nx = sheet.querySelector('.nui-pod-next');
if (nx) {
if (!nx.dataset.title) nx.dataset.title = nx.title;
nx.title = player.radio ? 'Überspringen – das Radio spielt die nächste' : nx.dataset.title;
}
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(), silenceSpeedItems(), sponsorSpeedItems()));
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(), silenceSpeedItems(), sponsorSpeedItems()));
}
// „Stille überspringen“ where AntennaPod has it: in the speed menu. For all podcasts (this
// device), and for the playing one its own choice (server) — where the player can skip silence.
function silenceSpeedItems() {
const prefs = devicePrefs();
if (!N.podcastSetPref || !('skip_silence' in prefs)) return [];
const device = !!prefs.skip_silence;
const live = player.feedId ? feeds().find(x => x.id === player.feedId) : null;
const mine = live && 'skip_silence' in live ? live.skip_silence : undefined;
const items = [{icon: device ? '☑' : '☐', label: 'Stille überspringen',
hint: (device ? 'an' : 'aus') + ' · alle Podcasts' + (mine === true ? ' · hier: immer' : mine === false ? ' · hier: nie' : ''),
action: () => { N.podcastSetPref('skip_silence', !device); toast(device ? 'Stille wird nicht mehr übersprungen' : 'Stille wird übersprungen'); }}];
if (mine !== undefined && N.podcastFeedSettings) {
const title = plain(player.feed);
const save = v => N.podcastFeedSettings(player.feedId, JSON.stringify({skip_silence: v}));
items.push({icon: '∿', label: `Stille bei „${title}“`, hint: mine === true ? 'immer überspringen' : mine === false ? 'nie überspringen' : 'wie alle Podcasts',
action: () => openSheet(`Stille bei „${title}“ …`, [
{icon: mine == null ? '●' : '○', label: 'Wie alle Podcasts', hint: device ? 'überspringen' : 'nicht überspringen', action: () => save(null)},
{icon: mine === true ? '●' : '○', label: 'Immer überspringen', action: () => save(true)},
{icon: mine === false ? '●' : '○', label: 'Nie überspringen', action: () => save(false)},
])});
}
return items;
}
// ---- 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
// (`local`: ours anyway — Ambient's button, Ambient plays here).
function sleepState(local) {
const r = local ? null : remote();
return (r ? r.sleep : player.sleep) || {};
}
function sleepLabel(local) {
const t = sleepState(local);
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, local) {
const r = local ? null : 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(local);
const own = player.sleep || {};
const on = !!sleepLabel(local);
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(local).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;
if (player.radio) { N.podcastNext(); return; } // the radio is done with it, nothing else
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';
const {sheet: made, panel, top} = podSheet('nui-pod-remote', 'Player schließen', closePlayer, 'Player');
sheet = made;
sheet.dataset.remote = remoteKey(r);
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);
}
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.
// 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();
const {sheet: made, panel, top} = podSheet('nui-pod-notes-sheet nui-pod-dls', 'Schließen', closeDownloads);
dlSheet = made;
dlBody = el('div', 'nui-pod-dls-body');
panel.append(top, el('div', 'nui-pod-player-title', 'Downloads'), dlBody);
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();
listsOnOnce();
render();
};
setTimeout(listsOnOnce, 2000);
// 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();
};
// What plays, for the screensaver (native-saver.js): the player's report and the chapter at its place.
const nowAsked = new Set();
window.__dioraPlayerNow = function () {
if (!player || player.kind !== 'podcast') return player;
const d = details.get(player.episodeId);
if (!d && player.episodeId && !nowAsked.has(player.episodeId)) {
nowAsked.add(player.episodeId);
Promise.resolve(loadDetails(player.episodeId)).catch(() => {});
}
let chapter = '';
const chapters = (d && d.chapters) || [];
const t = (player.pos || 0) / 1000;
for (const c of chapters) if ((c.start || 0) <= t + 0.5) chapter = c.title || '';
return Object.assign({}, player, {chapter: plain(chapter)});
};
// Ambient starts (app.js): an episode playing here pauses.
window.__dioraPausePlayer = function () {
if (player.kind !== 'podcast' || !player.playing || player.remote) return;
// Pause, not toggle: Android's audio focus may have paused it a moment before.
if (N.podcastPause) N.podcastPause(); else if (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();
ambientSleepButton();
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 (closeTrSettings()) return true;
if (closeNotes()) return true;
if (closePlayer()) return true;
const onPodcasts = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
if (onPodcasts && stopSelecting()) return true;
// The tab's own pages go back one by one, before the next handler: that one would
// leave the tab for Home (native-radio.js). A menu or dialog on top closes first.
if (onPodcasts && !somethingOnTop()) {
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;
}
if (view === 'browse') return browseBack();
}
if (back && back()) return true;
if (onPodcasts) {
showTab('books');
return true;
}
return false;
};
// A menu, dialog or note sheet over the page (native-ui.js, app.js's modal).
function somethingOnTop() {
if (document.querySelector('#nui-sheet, .nui-backdrop, .note-bottom-sheet')) return true;
const modal = $id('modal-dialog');
return !!modal && getComputedStyle(modal).display !== 'none';
}
// 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();
});
})();