Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1901 lines
82 KiB
JavaScript
1901 lines
82 KiB
JavaScript
// Copied from diora-android 302c300 2026-10-02: web-native/static/native-podcast.js
|
||
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
|
||
// and copy again. The browser host is static/js/podcasts-web.js.
|
||
|
||
// Podcasts for the phone, the AntennaPod way: the queue comes first.
|
||
//
|
||
// The app keeps the podcast state (Podcasts.kt: feeds, queue, inbox and
|
||
// settings, fetched from the server in one request and kept for offline use)
|
||
// and plays it (RadioService: the queue is the playlist, it keeps the place
|
||
// and moves on by itself). This page only shows that state and hands what
|
||
// the user does to DioraNative.podcast*. The web app's own podcast tab stays
|
||
// hidden; its endpoints are the server side of all this.
|
||
//
|
||
// The browser runs this same file (diora-web copies it, tools/sync_podcast_ui.py):
|
||
// there window.DioraPodcastHost (diora-web static/js/podcasts-web.js) plays the
|
||
// part of the app, with the same calls.
|
||
(function () {
|
||
'use strict';
|
||
const N = window.DioraPodcastHost || window.DioraNative;
|
||
if (!N || !N.podcastState) return;
|
||
// E-readers have no speaker; they stay a pure book app.
|
||
if (N.isEinkDevice && N.isEinkDevice()) return;
|
||
|
||
document.documentElement.classList.add('nui-podcasts');
|
||
|
||
const $id = id => document.getElementById(id);
|
||
const VIEWS = [
|
||
{id: 'queue', label: 'Warteschlange'},
|
||
{id: 'inbox', label: 'Neu'},
|
||
{id: 'episodes', label: 'Folgen'},
|
||
{id: 'feeds', label: 'Abos'},
|
||
{id: 'search', label: 'Suche'},
|
||
];
|
||
const POSITIONS = [
|
||
{value: 'back', label: 'Ans Ende'},
|
||
{value: 'front', label: 'An den Anfang'},
|
||
{value: 'after_current', label: 'Nach der laufenden Folge'},
|
||
];
|
||
const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2];
|
||
|
||
function parse(s, fallback) {
|
||
try { return JSON.parse(s); } catch (e) { return fallback; }
|
||
}
|
||
|
||
let state = parse(N.podcastState(), {});
|
||
let player = {kind: null};
|
||
let view = 'queue';
|
||
let feedView = null; // {feed, episodes} while one podcast is open
|
||
let searchResults = null;
|
||
let refreshing = false;
|
||
let selected = null; // Set of episode ids while choosing several (queue or inbox)
|
||
let locked = false; // queue locked: no dragging
|
||
try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {}
|
||
try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {}
|
||
if (!VIEWS.some(v => v.id === view)) view = 'queue';
|
||
|
||
const queue = () => state.queue || [];
|
||
const inbox = () => state.inbox || [];
|
||
const feeds = () => state.feeds || [];
|
||
const settings = () => state.settings || {};
|
||
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
|
||
|
||
// ---- small helpers ---------------------------------------------------------------
|
||
|
||
function el(tag, cls, text) {
|
||
const e = document.createElement(tag);
|
||
if (cls) e.className = cls;
|
||
if (text != null) e.textContent = text;
|
||
return e;
|
||
}
|
||
|
||
function button(label, title, onClick, cls) {
|
||
const b = el('button', 'btn-icon nui-icon' + (cls ? ' ' + cls : ''), label);
|
||
b.type = 'button';
|
||
b.title = title;
|
||
b.setAttribute('aria-label', title);
|
||
b.addEventListener('click', e => { e.stopPropagation(); onClick(e); });
|
||
return b;
|
||
}
|
||
|
||
function openSheet(title, items) {
|
||
if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items);
|
||
}
|
||
|
||
function clock(seconds) {
|
||
seconds = Math.max(0, Math.round(seconds || 0));
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.floor((seconds % 3600) / 60);
|
||
const s = seconds % 60;
|
||
return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
|
||
}
|
||
|
||
function minutes(seconds) {
|
||
const m = Math.round((seconds || 0) / 60);
|
||
if (m < 60) return `${m} min`;
|
||
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`;
|
||
}
|
||
|
||
function day(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return '';
|
||
const diff = Math.floor((Date.now() - d) / 86400000);
|
||
if (diff <= 0) return 'heute';
|
||
if (diff === 1) return 'gestern';
|
||
if (diff < 7) return `vor ${diff} Tagen`;
|
||
return d.toLocaleDateString('de-DE', {day: 'numeric', month: 'short', year: diff > 300 ? 'numeric' : undefined});
|
||
}
|
||
|
||
let toastTimer = 0;
|
||
function toast(text) {
|
||
let t = $id('nui-radio-toast');
|
||
if (!t) {
|
||
t = el('div');
|
||
t.id = 'nui-radio-toast';
|
||
document.body.appendChild(t);
|
||
}
|
||
t.textContent = text;
|
||
t.classList.add('nui-show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
|
||
}
|
||
|
||
// Feed titles sometimes arrive with HTML entities still in them (&).
|
||
const decoder = document.createElement('textarea');
|
||
function plain(s) {
|
||
if (!s || s.indexOf('&') < 0) return s || '';
|
||
decoder.innerHTML = s;
|
||
return decoder.value;
|
||
}
|
||
|
||
// Episodes come in two shapes: queue entries (episode__*) and inbox/feed
|
||
// episodes. One view of both:
|
||
function ep(o) {
|
||
const e = shape(o);
|
||
e.title = plain(e.title);
|
||
e.feed = plain(e.feed);
|
||
return e;
|
||
}
|
||
|
||
function shape(o) {
|
||
if (o.episode__id != null) {
|
||
return {
|
||
id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id,
|
||
art: o.episode__artwork_url || o.episode__feed__artwork_url, duration: o.episode__duration_seconds,
|
||
pos: o.position_seconds, date: o.episode__pub_date, raw: o,
|
||
};
|
||
}
|
||
return {
|
||
id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id,
|
||
art: o.artwork_url || o.feed__artwork_url || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds,
|
||
pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o,
|
||
};
|
||
}
|
||
|
||
function art(src, size) {
|
||
const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : ''));
|
||
if (src) {
|
||
const img = el('img');
|
||
img.loading = 'lazy';
|
||
img.alt = '';
|
||
img.src = src.replace(/^http:/, 'https:');
|
||
img.addEventListener('error', () => img.remove());
|
||
box.append(img);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
function inQueue(id) {
|
||
return queue().some(q => q.episode__id === id);
|
||
}
|
||
|
||
// ---- actions ---------------------------------------------------------------------------
|
||
|
||
function play(e) {
|
||
if (currentId() === e.id) { openPlayer(); return; }
|
||
N.podcastPlayEpisode(JSON.stringify(e.raw), currentId());
|
||
}
|
||
|
||
function enqueue(e, where) {
|
||
N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId());
|
||
const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position));
|
||
toast('In der Warteschlange' + (label ? ': ' + label.label.toLowerCase() : ''));
|
||
}
|
||
|
||
// Move within the queue: "next" is right after the episode that plays.
|
||
function moveTo(e, where) {
|
||
const ids = queue().map(q => q.episode__id).filter(id => id !== e.id);
|
||
let index = where === 'top' ? 0 : ids.length;
|
||
if (where === 'next') {
|
||
const at = ids.indexOf(currentId());
|
||
index = at >= 0 ? at + 1 : 0;
|
||
}
|
||
N.podcastMove(e.id, index);
|
||
}
|
||
|
||
function markPlayed(e) {
|
||
N.podcastMarkPlayed(e.id, true);
|
||
toast('Als gehört markiert');
|
||
}
|
||
|
||
function queueMenu(e) {
|
||
const playing = currentId() === e.id;
|
||
openSheet(e.title, [
|
||
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
|
||
!playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
|
||
favoriteItem(e),
|
||
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
|
||
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
|
||
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
|
||
{icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)},
|
||
...dlMenuItems(e),
|
||
{icon: '✕', label: 'Aus der Warteschlange nehmen', danger: true, action: () => N.podcastDequeue(e.id)},
|
||
]);
|
||
}
|
||
|
||
function episodeMenu(e) {
|
||
const queued = inQueue(e.id);
|
||
openSheet(e.title, [
|
||
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
|
||
{icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
|
||
favoriteItem(e),
|
||
!queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
|
||
!queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
|
||
!queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
|
||
queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)},
|
||
!e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)},
|
||
e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
|
||
...dlMenuItems(e),
|
||
{icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
|
||
action: () => N.podcastDismiss(JSON.stringify([e.id]))},
|
||
]);
|
||
}
|
||
|
||
function settingsMenu() {
|
||
const s = settings();
|
||
const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({
|
||
icon: s[name] === p.value ? '●' : '○',
|
||
label: p.label,
|
||
active: s[name] === p.value,
|
||
action: () => N.podcastSettings(JSON.stringify({[name]: p.value})),
|
||
})));
|
||
const label = v => (POSITIONS.find(p => p.value === v) || POSITIONS[0]).label;
|
||
const prefs = devicePrefs();
|
||
// Grouped like AntennaPod's settings; each group opens its own list.
|
||
const group = (icon, title, hint, items) => ({icon, label: title, hint, action: () => openSheet(title, items())});
|
||
openSheet('Podcasts', [
|
||
{icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, die App lädt das Ergebnis',
|
||
action: () => refresh(true)},
|
||
group('≡', 'Warteschlange', 'wohin neue Folgen kommen, gehörte entfernen', () => [
|
||
{icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position),
|
||
action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')},
|
||
{icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position),
|
||
action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')},
|
||
removePlayedItem(),
|
||
]),
|
||
group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“',
|
||
'headset_next' in prefs && 'Kopfhörer'].filter(Boolean).join(', '), () => [
|
||
skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]),
|
||
skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]),
|
||
silenceItem(),
|
||
playedItem(),
|
||
headsetItem(),
|
||
]),
|
||
DL && group('↓', 'Downloads', 'automatisch, Limit, aufräumen', () => dlSettingsItems()),
|
||
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
|
||
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
|
||
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
|
||
]),
|
||
{icon: '▤', label: 'Hörstatistik', action: openStats},
|
||
]);
|
||
}
|
||
|
||
function skipItem(name, label, choices) {
|
||
if (!N.podcastSetPrefValue) return null;
|
||
const now = devicePrefs()[name];
|
||
return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`,
|
||
action: () => openSheet(label, choices.map(c => ({
|
||
icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c),
|
||
})))};
|
||
}
|
||
|
||
function playedItem() {
|
||
if (!N.podcastSetPrefValue) return null;
|
||
const now = devicePrefs().smart_played;
|
||
if (now == null) return null;
|
||
const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende');
|
||
return {icon: '✓', label: 'Als gehört zählen', hint: label(now),
|
||
action: () => openSheet('Eine Folge zählt als gehört …', [0, 15, 30, 60, 120, 300, 600].map(v => ({
|
||
icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v),
|
||
})))};
|
||
}
|
||
|
||
// A host without headset buttons or silence skipping (the browser) leaves them out of its prefs.
|
||
function headsetItem() {
|
||
if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null;
|
||
const next = !!devicePrefs().headset_next;
|
||
return {icon: '🎧', label: 'Kopfhörer: zweimal tippen', hint: next ? 'nächste Folge' : 'vorspulen (dreimal: zurück)',
|
||
action: () => openSheet('Kopfhörer zweimal tippen …', [
|
||
{icon: !next ? '●' : '○', label: 'Vorspulen', hint: 'dreimal tippen springt zurück', action: () => N.podcastSetPref('headset_next', false)},
|
||
{icon: next ? '●' : '○', label: 'Nächste Folge', action: () => N.podcastSetPref('headset_next', true)},
|
||
])};
|
||
}
|
||
|
||
function silenceItem() {
|
||
if (!N.podcastSetPref || !('skip_silence' in devicePrefs())) return null;
|
||
const on = !!devicePrefs().skip_silence;
|
||
return {icon: on ? '☑' : '☐', label: 'Stille überspringen',
|
||
hint: on ? 'an: Pausen in Folgen werden übersprungen' : 'aus',
|
||
action: () => N.podcastSetPref('skip_silence', !on)};
|
||
}
|
||
|
||
function devicePrefs() {
|
||
return parse(N.podcastPrefs ? N.podcastPrefs() : '{}', {});
|
||
}
|
||
|
||
function removePlayedItem() {
|
||
if (!N.podcastSetPref) return null;
|
||
const on = devicePrefs().remove_played !== false;
|
||
return {icon: on ? '☑' : '☐', label: 'Gehörte Folgen aus der Warteschlange nehmen',
|
||
hint: on ? 'an: fertige Folgen verlassen die Warteschlange' : 'aus: fertige Folgen bleiben stehen',
|
||
action: () => { N.podcastSetPref('remove_played', !on); toast(on ? 'Gehörte Folgen bleiben jetzt stehen' : 'Gehörte Folgen verlassen jetzt die Warteschlange'); }};
|
||
}
|
||
|
||
// ---- sorting, locking, choosing several ----------------------------------------------
|
||
|
||
const SORTS = [
|
||
{id: 'new', label: 'Neueste zuerst', key: e => -(Date.parse(e.date) || 0)},
|
||
{id: 'old', label: 'Älteste zuerst', key: e => Date.parse(e.date) || 0},
|
||
{id: 'short', label: 'Kürzeste zuerst', key: e => Math.max(0, (e.duration || 0) - (e.pos || 0))},
|
||
{id: 'long', label: 'Längste zuerst', key: e => -Math.max(0, (e.duration || 0) - (e.pos || 0))},
|
||
{id: 'feed', label: 'Nach Podcast', key: e => (e.feed || '').toLowerCase() + ' ' + String(9e15 - (Date.parse(e.date) || 0))},
|
||
];
|
||
|
||
// The episode that plays stays on top; everything else is sorted.
|
||
function sortQueue(sort) {
|
||
const items = queue().map(ep);
|
||
const now = items.filter(e => e.id === currentId());
|
||
const rest = items.filter(e => e.id !== currentId());
|
||
if (sort === 'shuffle') {
|
||
for (let i = rest.length - 1; i > 0; i--) {
|
||
const j = Math.floor(Math.random() * (i + 1));
|
||
[rest[i], rest[j]] = [rest[j], rest[i]];
|
||
}
|
||
} else {
|
||
const s = SORTS.find(x => x.id === sort);
|
||
rest.sort((a, b) => {
|
||
const ka = s.key(a), kb = s.key(b);
|
||
return ka < kb ? -1 : ka > kb ? 1 : 0;
|
||
});
|
||
}
|
||
N.podcastReorder(JSON.stringify(now.concat(rest).map(e => e.id)));
|
||
}
|
||
|
||
const keepSorted = () => settings().keep_sorted || '';
|
||
|
||
function sortMenu() {
|
||
const kept = keepSorted();
|
||
const keptLabel = kept ? (SORTS.find(x => x.id === kept) || {}).label : '';
|
||
openSheet('Warteschlange sortieren', SORTS.map(x => ({icon: '⇅', label: x.label, action: () => sortQueue(x.id)}))
|
||
.concat([{icon: '⤨', label: 'Mischen', action: () => sortQueue('shuffle')}])
|
||
.concat('keep_sorted' in settings() ? [{icon: kept ? '☑' : '☐', label: 'Immer sortiert halten',
|
||
hint: kept ? `an: ${keptLabel}` : 'aus: Reihenfolge bestimmst du',
|
||
action: () => openSheet('Immer sortiert halten', [{id: '', label: 'Aus'}].concat(SORTS).map(x => ({
|
||
icon: kept === x.id ? '●' : '○', label: x.label, hint: x.id ? 'auch neu eingereihte Folgen' : null,
|
||
action: () => N.podcastSettings(JSON.stringify({keep_sorted: x.id})),
|
||
})))}] : []));
|
||
}
|
||
|
||
function toggleLock() {
|
||
locked = !locked;
|
||
try { localStorage.setItem('nui_pod_locked', locked ? '1' : '0'); } catch (e) {}
|
||
toast(locked ? 'Warteschlange gesperrt' : 'Warteschlange entsperrt');
|
||
render();
|
||
}
|
||
|
||
function startSelecting(id) {
|
||
selected = new Set(id ? [id] : []);
|
||
render();
|
||
}
|
||
|
||
function stopSelecting() {
|
||
if (!selected) return false;
|
||
selected = null;
|
||
render();
|
||
return true;
|
||
}
|
||
|
||
function toggleSelected(id) {
|
||
if (selected.has(id)) selected.delete(id); else selected.add(id);
|
||
render();
|
||
}
|
||
|
||
// The bar while choosing: count, all/none, and what to do with the chosen ones.
|
||
function selectionBar(items, actions) {
|
||
const bar = el('div', 'nui-pod-selbar');
|
||
const all = items.length > 0 && items.every(e => selected.has(e.id));
|
||
bar.append(
|
||
button('✕', 'Auswahl beenden', stopSelecting),
|
||
el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : 'Folgen antippen'),
|
||
button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => {
|
||
selected = new Set(all ? [] : items.map(e => e.id));
|
||
render();
|
||
}),
|
||
);
|
||
const acts = el('div', 'nui-pod-selactions');
|
||
for (const a of actions) {
|
||
const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
|
||
b.type = 'button';
|
||
b.disabled = !selected.size;
|
||
b.addEventListener('click', () => {
|
||
const ids = items.filter(e => selected.has(e.id)).map(e => e.id);
|
||
if (ids.length) a.run(ids);
|
||
});
|
||
acts.append(b);
|
||
}
|
||
bar.append(acts);
|
||
return bar;
|
||
}
|
||
|
||
function listTools(buttons) {
|
||
const t = el('div', 'nui-pod-tools');
|
||
for (const b of buttons) if (b) t.append(b);
|
||
return t;
|
||
}
|
||
|
||
function refresh(fetchFeeds) {
|
||
refreshing = true;
|
||
render();
|
||
N.podcastRefresh(!!fetchFeeds);
|
||
}
|
||
|
||
// ---- rendering ---------------------------------------------------------------------------
|
||
|
||
let root = null;
|
||
|
||
// The page scrolls inside .main-content (app.css), not the window.
|
||
function scroller() {
|
||
for (let e = root && root.parentElement; e && e !== document.body; e = e.parentElement) {
|
||
const o = getComputedStyle(e).overflowY;
|
||
if (o === 'auto' || o === 'scroll') return e;
|
||
}
|
||
return document.scrollingElement || document.documentElement;
|
||
}
|
||
|
||
function scrollTop(y) {
|
||
const s = scroller();
|
||
if (y == null) return s.scrollTop;
|
||
s.scrollTop = y;
|
||
return y;
|
||
}
|
||
|
||
function build() {
|
||
const tab = $id('tab-podcasts');
|
||
if (!tab || $id('nui-pod')) return false;
|
||
root = el('div');
|
||
root.id = 'nui-pod';
|
||
tab.prepend(root);
|
||
return true;
|
||
}
|
||
|
||
function render() {
|
||
if (!root) return;
|
||
const scroll = scrollTop();
|
||
root.textContent = '';
|
||
|
||
const bar = el('div', 'nui-tab-bar');
|
||
bar.id = 'nui-pod-bar';
|
||
const text = el('div', 'nui-bar-text');
|
||
text.append(el('span', 'native-brand', 'Podcasts'));
|
||
if (refreshing) text.append(el('span', 'muted nui-pod-status', 'Wird abgerufen …'));
|
||
else if (state.server_time) text.append(el('span', 'muted nui-pod-status', 'Stand ' + day(state.server_time) + ', ' +
|
||
new Date(state.server_time).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})));
|
||
bar.append(text, button('⟳', 'Alle Feeds abrufen', () => refresh(true)), button('⋮', 'Einstellungen', settingsMenu));
|
||
root.append(bar);
|
||
|
||
if (feedView) {
|
||
renderFeed();
|
||
} else {
|
||
const tabs = el('div', 'nui-pod-tabs');
|
||
for (const v of VIEWS) {
|
||
const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : ''));
|
||
b.type = 'button';
|
||
b.append(el('span', null, v.label));
|
||
const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inbox().length : 0;
|
||
if (count) b.append(el('span', 'nui-pod-count', String(count)));
|
||
b.addEventListener('click', () => {
|
||
selected = null;
|
||
view = v.id;
|
||
try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
|
||
render();
|
||
scrollTop(0);
|
||
});
|
||
tabs.append(b);
|
||
}
|
||
root.append(tabs);
|
||
if (view === 'queue') renderQueue();
|
||
else if (view === 'inbox') renderInbox();
|
||
else if (view === 'episodes') renderEpisodes();
|
||
else if (view === 'feeds') renderFeeds();
|
||
else renderSearch();
|
||
}
|
||
scrollTop(scroll);
|
||
}
|
||
|
||
function empty(text) {
|
||
root.append(el('div', 'nui-pod-empty', text));
|
||
}
|
||
|
||
function progressBar(pos, duration) {
|
||
if (!pos || !duration) return null;
|
||
const b = el('div', 'nui-pod-progress');
|
||
const f = el('span');
|
||
f.style.width = Math.min(100, Math.round(pos / duration * 100)) + '%';
|
||
b.append(f);
|
||
return b;
|
||
}
|
||
|
||
function episodeRow(e, opts) {
|
||
const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : ''));
|
||
row.dataset.id = e.id;
|
||
row.append(art(e.art));
|
||
const main = el('div', 'nui-pod-main');
|
||
main.append(el('div', 'nui-pod-title', e.title));
|
||
// Length first: podcast names are long and get cut off.
|
||
const meta = [];
|
||
// A played episode, or a place past the feed's (often wrong) length: just the length.
|
||
const started = e.pos > 30 && !e.played && e.pos < e.duration - 30;
|
||
if (e.duration) meta.push(started ? minutes(e.duration - e.pos) + ' übrig' : minutes(e.duration));
|
||
if (opts.showDate && e.date) meta.push(day(e.date));
|
||
if (opts.showFeed && e.feed) meta.push(e.feed);
|
||
main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
|
||
const p = e.played ? null : progressBar(e.pos, e.duration);
|
||
if (p) main.append(p);
|
||
row.append(main);
|
||
if (selected) {
|
||
row.classList.add('nui-pod-selecting');
|
||
const box = el('span', 'nui-pod-check' + (selected.has(e.id) ? ' on' : ''), selected.has(e.id) ? '☑' : '☐');
|
||
row.prepend(box);
|
||
row.addEventListener('click', () => toggleSelected(e.id));
|
||
} else {
|
||
dlAppend(row, e);
|
||
row.addEventListener('click', () => play(e));
|
||
}
|
||
if (!selected && !opts.noSelect) {
|
||
// Long press starts choosing several, like in AntennaPod.
|
||
let timer = 0;
|
||
row.addEventListener('pointerdown', ev => {
|
||
if (ev.target.closest('button, .nui-pod-handle')) return;
|
||
timer = setTimeout(() => { timer = 0; startSelecting(e.id); }, 550);
|
||
});
|
||
const cancel = () => { if (timer) clearTimeout(timer); timer = 0; };
|
||
row.addEventListener('pointerup', cancel);
|
||
row.addEventListener('pointerleave', cancel);
|
||
row.addEventListener('pointercancel', cancel);
|
||
row.addEventListener('contextmenu', ev => ev.preventDefault());
|
||
}
|
||
return row;
|
||
}
|
||
|
||
function renderQueue() {
|
||
const items = queue().map(ep);
|
||
if (!items.length) {
|
||
empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
|
||
return;
|
||
}
|
||
const total = items.reduce((n, e) => n + Math.max(0, (e.duration || 0) - (e.pos || 0)), 0);
|
||
if (selected) {
|
||
root.append(selectionBar(items, [
|
||
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
|
||
{label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
|
||
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
|
||
{label: 'Entfernen', danger: true, run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
|
||
]));
|
||
} else {
|
||
const head = el('div', 'nui-pod-head');
|
||
const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : '';
|
||
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}`),
|
||
listTools([
|
||
button('⇅', 'Sortieren oder mischen', sortMenu),
|
||
button('☐', 'Mehrere auswählen', () => startSelecting()),
|
||
button(locked ? '🔒' : '🔓', locked ? 'Warteschlange entsperren' : 'Warteschlange sperren', toggleLock,
|
||
locked ? 'nui-pod-lock on' : 'nui-pod-lock'),
|
||
]));
|
||
root.append(head);
|
||
}
|
||
const list = el('div', 'nui-pod-list nui-pod-queue');
|
||
for (const e of items) {
|
||
const row = episodeRow(e, {showFeed: true});
|
||
if (!selected) {
|
||
row.append(button('⋯', 'Mehr', () => queueMenu(e)));
|
||
if (!locked && !keepSorted()) {
|
||
const handle = el('div', 'nui-pod-handle', '≡');
|
||
handle.title = 'Ziehen zum Umsortieren';
|
||
handle.addEventListener('click', ev => ev.stopPropagation());
|
||
dragSort(handle, row, list);
|
||
row.append(handle);
|
||
}
|
||
}
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
}
|
||
|
||
function renderInbox() {
|
||
const items = inbox().map(ep);
|
||
if (!items.length) {
|
||
empty(feeds().length
|
||
? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.'
|
||
: 'Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
||
return;
|
||
}
|
||
if (selected) {
|
||
const chosen = ids => items.filter(e => ids.includes(e.id));
|
||
const add = (ids, where) => {
|
||
N.podcastEnqueue(JSON.stringify(chosen(ids).map(e => e.raw)), where, currentId());
|
||
toast(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} eingereiht`);
|
||
stopSelecting();
|
||
};
|
||
root.append(selectionBar(items, [
|
||
{label: 'Einreihen', run: ids => openSheet(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} einreihen`, [
|
||
{icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label,
|
||
action: () => add(ids, '')},
|
||
{icon: '⤴', label: 'Als Nächstes', action: () => add(ids, 'after_current')},
|
||
{icon: '⇈', label: 'An den Anfang', action: () => add(ids, 'front')},
|
||
{icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')},
|
||
])},
|
||
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
|
||
{label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
|
||
]));
|
||
} else {
|
||
const head = el('div', 'nui-pod-head');
|
||
head.append(el('div', 'nui-pod-summary', `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'}`),
|
||
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
|
||
root.append(head);
|
||
}
|
||
const list = el('div', 'nui-pod-list');
|
||
for (const e of items) {
|
||
const row = episodeRow(e, {showFeed: true, showDate: true});
|
||
if (!selected) {
|
||
row.append(
|
||
e.inQueue || inQueue(e.id)
|
||
? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
|
||
: button('+', 'Einreihen', () => enqueue(e)),
|
||
button('⋯', 'Mehr', () => episodeMenu(e)));
|
||
}
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
}
|
||
|
||
function renderFeeds() {
|
||
const items = feeds().slice().sort((a, b) => String(b.latest_episode_at || '').localeCompare(String(a.latest_episode_at || '')));
|
||
if (!items.length) {
|
||
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
||
return;
|
||
}
|
||
const list = el('div', 'nui-pod-list');
|
||
for (const f of items) {
|
||
const row = el('div', 'nui-pod-row');
|
||
row.append(art(f.artwork_url));
|
||
const main = el('div', 'nui-pod-main');
|
||
main.append(el('div', 'nui-pod-title', plain(f.title)));
|
||
const meta = [];
|
||
if (f.author) meta.push(f.author);
|
||
if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at));
|
||
main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
|
||
if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error));
|
||
row.append(main);
|
||
if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still'));
|
||
else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto'));
|
||
row.addEventListener('click', () => openFeed(f));
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
}
|
||
|
||
async function openFeed(f) {
|
||
feedView = {feed: f, episodes: null};
|
||
render();
|
||
scrollTop(0);
|
||
try {
|
||
const r = await fetch(`/podcasts/feeds/${f.id}/episodes/`);
|
||
if (!r.ok) throw new Error(r.status);
|
||
const data = await r.json();
|
||
if (feedView && feedView.feed.id === f.id) {
|
||
feedView = {feed: Object.assign({}, f, data.feed), episodes: data.episodes};
|
||
render();
|
||
}
|
||
} catch (e) {
|
||
if (feedView && feedView.feed.id === f.id) {
|
||
feedView.episodes = [];
|
||
feedView.error = true;
|
||
render();
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderFeed() {
|
||
const f = feedView.feed;
|
||
const head = el('div', 'nui-pod-feedhead');
|
||
head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; render(); }), art(f.artwork_url, 'big'));
|
||
const info = el('div', 'nui-pod-main');
|
||
info.append(el('div', 'nui-pod-title', f.title));
|
||
if (f.author) info.append(el('div', 'nui-pod-meta', f.author));
|
||
head.append(info);
|
||
root.append(head);
|
||
|
||
const live = feeds().find(x => x.id === f.id) || f;
|
||
if (live.last_error) {
|
||
root.append(el('div', 'nui-pod-feederr nui-pod-feederr-box',
|
||
`⚠ Der Feed ließ sich zuletzt nicht laden (${live.last_error}${live.last_error_at ? ', ' + day(live.last_error_at) : ''}). ` +
|
||
'Der Server versucht es weiter, bei wiederholten Fehlern seltener.'));
|
||
}
|
||
if ('new_episodes' in live && N.podcastFeedSettings) {
|
||
root.append(newEpisodesRow(live));
|
||
if (N.podcastFeedPrefs) root.append(feedSettings(f));
|
||
root.append(unsubscribeBox(f));
|
||
return renderFeedEpisodes(f);
|
||
}
|
||
const auto = live.auto_queue;
|
||
const toggle = el('label', 'nui-pod-switch');
|
||
const box = el('input');
|
||
box.type = 'checkbox';
|
||
box.checked = !!auto;
|
||
box.addEventListener('change', () => N.podcastAutoQueue(f.id, box.checked));
|
||
const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
|
||
toggle.append(box, el('span', null, 'Neue Folgen automatisch einreihen'), el('span', 'nui-pod-meta', pos));
|
||
root.append(toggle);
|
||
if (N.podcastFeedPrefs) root.append(feedSettings(f));
|
||
root.append(unsubscribeBox(f));
|
||
renderFeedEpisodes(f);
|
||
}
|
||
|
||
// Where this podcast's new episodes go, and which ones count at all.
|
||
function newEpisodesRow(live) {
|
||
const row = el('div', 'nui-pod-feedprefs');
|
||
const chip = (label, value, on, onClick) => {
|
||
const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''));
|
||
b.type = 'button';
|
||
b.append(el('span', null, label), el('strong', null, value));
|
||
b.addEventListener('click', onClick);
|
||
return b;
|
||
};
|
||
const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); };
|
||
const where = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
|
||
const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
|
||
row.append(chip('Neue Folgen', where[live.new_episodes] || where.inbox, live.new_episodes !== 'inbox', () =>
|
||
openSheet(`Neue Folgen von „${plain(live.title)}“ …`, [
|
||
{icon: live.new_episodes === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', action: () => save({new_episodes: 'inbox'})},
|
||
{icon: live.new_episodes === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, action: () => save({new_episodes: 'queue'})},
|
||
{icon: live.new_episodes === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', action: () => save({new_episodes: 'ignore'})},
|
||
])));
|
||
const words = [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`,
|
||
live.min_duration && `ab ${live.min_duration} min`].filter(Boolean).join(', ');
|
||
const ask = async (key, title, now) => {
|
||
const v = typeof customPrompt === 'function' ? await customPrompt(title, now || '') : null;
|
||
if (v != null) save({[key]: v.trim()});
|
||
};
|
||
row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [
|
||
{icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle',
|
||
action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)},
|
||
{icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine',
|
||
action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)},
|
||
{icon: '⏱', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine',
|
||
action: () => openSheet('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({
|
||
icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge',
|
||
action: () => save({min_duration: m}),
|
||
})))},
|
||
])));
|
||
return row;
|
||
}
|
||
|
||
function renderFeedEpisodes(f) {
|
||
if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; }
|
||
if (feedView.error) { empty('Die Folgen lassen sich gerade nicht laden. Das braucht eine Verbindung.'); return; }
|
||
const list = el('div', 'nui-pod-list');
|
||
for (const o of feedView.episodes) {
|
||
// Feed episodes don't name their podcast; queue entries need it.
|
||
o.feed__id = f.id;
|
||
o.feed__title = f.title;
|
||
o.feed__artwork_url = f.artwork_url;
|
||
const e = ep(o);
|
||
const row = episodeRow(e, {showDate: true, noSelect: true});
|
||
row.append(
|
||
inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
|
||
: button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }),
|
||
button('⋯', 'Mehr', () => episodeMenu(e)));
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
}
|
||
|
||
function volumeLabel(v) {
|
||
if (!v || v === 1) return '';
|
||
return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || '';
|
||
}
|
||
|
||
// This podcast's own playback: speed, intro and outro to skip.
|
||
function feedSettings(f) {
|
||
const live = feeds().find(x => x.id === f.id) || f;
|
||
const own = 'skip_intro' in live
|
||
? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1}
|
||
: (devicePrefs().feeds || {})[String(f.id)] || {};
|
||
const row = el('div', 'nui-pod-feedprefs');
|
||
const chip = (label, value, onClick) => {
|
||
const b = el('button', 'nui-pod-chip' + (value ? ' on' : ''));
|
||
b.type = 'button';
|
||
b.append(el('span', null, label), el('strong', null, value || 'aus'));
|
||
b.addEventListener('click', onClick);
|
||
return b;
|
||
};
|
||
const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); };
|
||
const seconds = (key, title) => openSheet(title, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({
|
||
icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', action: () => set(key, v),
|
||
})));
|
||
row.append(
|
||
chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`,
|
||
SPEEDS.map(v => ({icon: own.speed === v ? '●' : '○', label: speedLabel(v), action: () => set('speed', v)}))
|
||
.concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))),
|
||
chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')),
|
||
chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')),
|
||
chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`,
|
||
[[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']]
|
||
.map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))),
|
||
);
|
||
return row;
|
||
}
|
||
|
||
// Unsubscribing (server: POST /podcasts/feeds/<id>/remove/). Asked once more
|
||
// right here; then the server forgets the podcast with its episodes, and the
|
||
// app deletes their downloads (not those of favourites).
|
||
function unsubscribeBox(f) {
|
||
const box = el('div', 'nui-pod-unsub');
|
||
if (!feedView.confirmRemove) {
|
||
const b = el('button', 'nui-pod-unsub-btn', 'Abo beenden');
|
||
b.type = 'button';
|
||
b.addEventListener('click', () => {
|
||
if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
|
||
feedView.confirmRemove = true;
|
||
render();
|
||
});
|
||
box.append(b);
|
||
return box;
|
||
}
|
||
box.classList.add('nui-pod-unsub-ask');
|
||
box.append(el('div', 'nui-pod-title', `„${plain(f.title)}“ nicht mehr abonnieren?`),
|
||
el('div', 'nui-pod-meta', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt und Favoriten, ' +
|
||
'auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : '')));
|
||
const actions = el('div', 'nui-pod-unsub-actions');
|
||
const yes = el('button', 'btn nui-pod-unsub-yes', feedView.removing ? 'Wird beendet …' : 'Abo beenden');
|
||
yes.type = 'button';
|
||
yes.disabled = !!feedView.removing;
|
||
yes.addEventListener('click', () => unsubscribe(f));
|
||
const no = el('button', 'btn', 'Abbrechen');
|
||
no.type = 'button';
|
||
no.disabled = !!feedView.removing;
|
||
no.addEventListener('click', () => { feedView.confirmRemove = false; render(); });
|
||
actions.append(yes, no);
|
||
box.append(actions);
|
||
return box;
|
||
}
|
||
|
||
async function unsubscribe(f) {
|
||
if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
|
||
feedView.removing = true;
|
||
render();
|
||
try {
|
||
const r = await fetch(`/podcasts/feeds/${f.id}/remove/`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}',
|
||
});
|
||
// 404: gone already (removed elsewhere) — the same result.
|
||
if (!r.ok && r.status !== 404) throw new Error(r.status);
|
||
} catch (e) {
|
||
if (feedView && feedView.feed.id === f.id) { feedView.removing = false; render(); }
|
||
toast(navigator.onLine ? 'Abo beenden hat nicht geklappt.' : 'Abo beenden braucht eine Verbindung.');
|
||
return;
|
||
}
|
||
// The episodes the page knows of it: downloads from before the app kept their podcast match by id.
|
||
const ids = ((feedView && feedView.feed.id === f.id && feedView.episodes) || []).map(o => o.id)
|
||
.concat(queue().filter(q => q.episode__feed__id === f.id).map(q => q.episode__id))
|
||
.concat(inbox().filter(o => o.feed__id === f.id).map(o => o.id));
|
||
state.feeds = feeds().filter(x => x.id !== f.id);
|
||
if (feedView && feedView.feed.id === f.id) feedView = null;
|
||
refreshing = true;
|
||
render();
|
||
toast(`„${plain(f.title)}“ abbestellt`);
|
||
// The app deletes the downloads and fetches the state again.
|
||
if (N.podcastFeedRemoved) N.podcastFeedRemoved(f.id, JSON.stringify(ids));
|
||
else N.podcastRefresh(false);
|
||
}
|
||
|
||
// ---- all episodes, filtered (server: GET /podcasts/episodes/) ----------------------
|
||
|
||
const FILTERS = [
|
||
{id: 'unplayed', label: 'Ungehört'},
|
||
{id: 'in_progress', label: 'Angefangen'},
|
||
{id: 'favorites', label: 'Favoriten'},
|
||
{id: 'history', label: 'Verlauf'},
|
||
{id: 'played', label: 'Gehört'},
|
||
{id: 'all', label: 'Alle'},
|
||
];
|
||
let epFilter = 'unplayed';
|
||
try { epFilter = localStorage.getItem('nui_pod_filter') || 'unplayed'; } catch (e) {}
|
||
let epList = null; // {filter, items, total, loading, error}
|
||
|
||
async function loadEpisodes(filter, append) {
|
||
const offset = append && epList && epList.filter === filter ? epList.items.length : 0;
|
||
if (!append) epList = {filter, items: [], total: 0, loading: true};
|
||
else epList.loading = true;
|
||
render();
|
||
try {
|
||
const r = await fetch(`/podcasts/episodes/?filter=${filter}&limit=100&offset=${offset}`);
|
||
if (!r.ok) throw new Error(r.status);
|
||
const d = await r.json();
|
||
if (!epList || epList.filter !== filter) return;
|
||
epList = {filter, items: (append ? epList.items : []).concat(d.episodes || []), total: d.total || 0};
|
||
} catch (e) {
|
||
if (!epList || epList.filter !== filter) return;
|
||
epList.loading = false;
|
||
epList.error = 'Die Folgenliste braucht eine Verbindung.';
|
||
}
|
||
render();
|
||
}
|
||
|
||
function renderEpisodes() {
|
||
const chips = el('div', 'nui-pod-filters');
|
||
for (const f of FILTERS) {
|
||
const b = el('button', 'nui-pod-chip' + (f.id === epFilter ? ' on' : ''), f.label);
|
||
b.type = 'button';
|
||
b.addEventListener('click', () => {
|
||
epFilter = f.id;
|
||
try { localStorage.setItem('nui_pod_filter', epFilter); } catch (e) {}
|
||
loadEpisodes(epFilter, false);
|
||
});
|
||
chips.append(b);
|
||
}
|
||
root.append(chips);
|
||
if (!epList || epList.filter !== epFilter) { loadEpisodes(epFilter, false); empty('Wird geladen …'); return; }
|
||
if (epList.error && !epList.items.length) { empty(epList.error); return; }
|
||
if (!epList.items.length) {
|
||
empty(epList.loading ? 'Wird geladen …' : {
|
||
unplayed: 'Keine ungehörten Folgen.',
|
||
in_progress: 'Keine angefangenen Folgen.',
|
||
favorites: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.',
|
||
history: 'Noch nichts gehört.',
|
||
played: 'Noch keine gehörten Folgen.',
|
||
all: 'Keine Folgen.',
|
||
}[epFilter]);
|
||
return;
|
||
}
|
||
root.append(el('div', 'nui-pod-summary', `${epList.total} ${epList.total === 1 ? 'Folge' : 'Folgen'}`));
|
||
const list = el('div', 'nui-pod-list');
|
||
for (const o of epList.items) {
|
||
const e = ep(o);
|
||
const row = episodeRow(e, {showFeed: true, showDate: epFilter !== 'history', noSelect: true});
|
||
if (epFilter === 'history' && o.last_played_at) {
|
||
row.querySelector('.nui-pod-meta').textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || '');
|
||
}
|
||
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
|
||
row.append(
|
||
inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
|
||
: button('+', 'Einreihen', () => enqueue(e)),
|
||
button('⋯', 'Mehr', () => episodeMenu(e)));
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
if (epList.items.length < epList.total) {
|
||
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');
|
||
more.type = 'button';
|
||
more.disabled = !!epList.loading;
|
||
more.addEventListener('click', () => loadEpisodes(epFilter, true));
|
||
root.append(more);
|
||
}
|
||
}
|
||
|
||
// ---- favourites -------------------------------------------------------------------------
|
||
|
||
function favoriteItem(e) {
|
||
const o = e.raw || {};
|
||
const known = typeof o.favorite === 'boolean';
|
||
return {icon: o.favorite ? '★' : '☆', label: known ? (o.favorite ? 'Kein Favorit mehr' : 'Als Favorit markieren') : 'Favorit an/aus',
|
||
action: () => toggleFavorite(e, star => { if (e.raw) e.raw.favorite = star; })};
|
||
}
|
||
|
||
async function toggleFavorite(e, done) {
|
||
const o = e.raw || {};
|
||
const body = typeof o.favorite === 'boolean' ? {favorite: !o.favorite} : {};
|
||
try {
|
||
const r = await fetch(`/podcasts/episodes/${e.id}/favorite/`, {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) throw new Error(r.status);
|
||
const d = await r.json();
|
||
toast(d.favorite ? '★ Als Favorit markiert' : 'Kein Favorit mehr');
|
||
// Favourites keep their download (Downloads.kt).
|
||
if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, !!d.favorite);
|
||
const cached = storedDetails(e.id);
|
||
if (cached) keepDetails(e.id, Object.assign({}, cached, {favorite: d.favorite}));
|
||
if (done) done(d.favorite);
|
||
if (view === 'episodes' && epList) {
|
||
const item = epList.items.find(x => x.id === e.id);
|
||
if (item) item.favorite = d.favorite;
|
||
render();
|
||
}
|
||
} catch (err) {
|
||
toast(navigator.onLine ? 'Favorit ließ sich nicht setzen' : 'Favoriten brauchen eine Verbindung');
|
||
}
|
||
}
|
||
|
||
// ---- listening statistics (server: GET /podcasts/stats/) -----------------------------
|
||
|
||
let statsSheet = null;
|
||
|
||
function closeStats() {
|
||
if (!statsSheet) return false;
|
||
statsSheet.remove();
|
||
statsSheet = null;
|
||
return true;
|
||
}
|
||
|
||
function hours(seconds) {
|
||
const h = Math.floor(seconds / 3600);
|
||
const m = Math.round((seconds % 3600) / 60);
|
||
return h ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`;
|
||
}
|
||
|
||
function openStats() {
|
||
closeStats();
|
||
statsSheet = el('div');
|
||
statsSheet.id = 'nui-pod-player';
|
||
statsSheet.className = 'nui-pod-notes-sheet';
|
||
const panel = el('div', 'nui-pod-player-panel');
|
||
const top = el('div', 'nui-pod-player-top');
|
||
top.append(button('⌄', 'Schließen', closeStats));
|
||
const tabs = el('div', 'nui-pod-subtabs');
|
||
const body = el('div', 'nui-pod-stats');
|
||
panel.append(top, el('div', 'nui-pod-player-title', 'Hörstatistik'), tabs, body);
|
||
statsSheet.append(panel);
|
||
statsSheet.addEventListener('click', ev => { if (ev.target === statsSheet) closeStats(); });
|
||
document.body.append(statsSheet);
|
||
const ranges = [{days: 30, label: '30 Tage'}, {days: 365, label: 'Jahr'}, {days: 0, label: 'Gesamt'}];
|
||
const show = async range => {
|
||
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.days === String(range.days)));
|
||
body.textContent = '';
|
||
body.append(el('div', 'nui-pod-meta', 'Wird geladen …'));
|
||
let d = null;
|
||
try {
|
||
const r = await fetch('/podcasts/stats/' + (range.days ? `?days=${range.days}` : ''));
|
||
if (!r.ok) throw new Error(r.status);
|
||
d = await r.json();
|
||
} catch (e) {
|
||
body.textContent = '';
|
||
body.append(el('div', 'nui-pod-meta', 'Die Statistik braucht eine Verbindung.'));
|
||
return;
|
||
}
|
||
body.textContent = '';
|
||
const big = el('div', 'nui-pod-stat-total');
|
||
big.append(el('strong', null, hours(d.total_seconds || 0)), el('span', null,
|
||
`${d.episodes_played || 0} Folgen gehört · ${d.episodes_started || 0} angefangen`));
|
||
body.append(big);
|
||
const feedsList = d.feeds || [];
|
||
if (!feedsList.length) { body.append(el('div', 'nui-pod-meta', 'In diesem Zeitraum nichts gehört.')); return; }
|
||
const max = Math.max(...feedsList.map(f => f.seconds || 0), 1);
|
||
for (const f of feedsList) {
|
||
const row = el('div', 'nui-pod-stat-row');
|
||
row.append(art(f.artwork_url));
|
||
const main = el('div', 'nui-pod-main');
|
||
main.append(el('div', 'nui-pod-title', plain(f.title)),
|
||
el('div', 'nui-pod-meta', `${hours(f.seconds || 0)} · ${f.episodes_played || 0} Folgen`));
|
||
const bar = el('div', 'nui-pod-progress nui-pod-stat-bar');
|
||
const fill = el('span');
|
||
fill.style.width = Math.max(2, Math.round((f.seconds || 0) / max * 100)) + '%';
|
||
bar.append(fill);
|
||
main.append(bar);
|
||
row.append(main);
|
||
body.append(row);
|
||
}
|
||
};
|
||
for (const r of ranges) {
|
||
const b = el('button', 'nui-pod-subtab', r.label);
|
||
b.type = 'button';
|
||
b.dataset.days = String(r.days);
|
||
b.addEventListener('click', () => show(r));
|
||
tabs.append(b);
|
||
}
|
||
show(ranges[0]);
|
||
}
|
||
|
||
// ---- OPML: take the subscriptions to another app, or bring them in -------------------
|
||
|
||
async function exportOpml() {
|
||
try {
|
||
const r = await fetch('/podcasts/feeds/export.opml');
|
||
if (!r.ok) throw new Error(r.status);
|
||
const blob = new Blob([await r.text()], {type: 'text/x-opml'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = 'diora-podcasts.opml';
|
||
a.click(); // the app offers to share or save it (native.js)
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 60000);
|
||
} catch (e) {
|
||
toast(navigator.onLine ? 'Export hat nicht geklappt' : 'Der Export braucht eine Verbindung');
|
||
}
|
||
}
|
||
|
||
function importOpml() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.opml,.xml,text/x-opml,text/xml,application/xml';
|
||
input.addEventListener('change', async () => {
|
||
const file = input.files && input.files[0];
|
||
if (!file) return;
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
toast('Abos werden importiert …');
|
||
try {
|
||
const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form});
|
||
const d = await r.json();
|
||
if (!r.ok || !d.ok) throw new Error(d.error || r.status);
|
||
toast(`${d.added} neue Abos${d.skipped ? `, ${d.skipped} schon vorhanden` : ''}`);
|
||
refresh(true);
|
||
} catch (e) {
|
||
toast('Diese Datei ließ sich nicht importieren');
|
||
}
|
||
});
|
||
input.click();
|
||
}
|
||
|
||
function renderSearch() {
|
||
const form = el('form', 'nui-pod-search');
|
||
const input = el('input', 'search-input');
|
||
input.type = 'search';
|
||
input.id = 'nui-pod-q';
|
||
input.placeholder = 'Podcast suchen oder Feed-Adresse einfügen …';
|
||
input.setAttribute('enterkeyhint', 'search');
|
||
input.value = searchResults ? searchResults.q : '';
|
||
const go = el('button', 'btn', 'Suchen');
|
||
go.type = 'submit';
|
||
form.append(input, go);
|
||
form.addEventListener('submit', ev => {
|
||
ev.preventDefault();
|
||
search(input.value.trim());
|
||
});
|
||
root.append(form);
|
||
if (!searchResults) {
|
||
empty('Sucht im Podcast-Verzeichnis von Apple. Eine Feed-Adresse (https://…) abonnierst du direkt.');
|
||
return;
|
||
}
|
||
if (searchResults.loading) { empty('Suche läuft …'); return; }
|
||
if (searchResults.error) { empty(searchResults.error); return; }
|
||
if (!searchResults.items.length) { empty('Nichts gefunden.'); return; }
|
||
const subscribed = new Set(feeds().map(f => f.rss_url));
|
||
const list = el('div', 'nui-pod-list');
|
||
for (const r of searchResults.items) {
|
||
const row = el('div', 'nui-pod-row');
|
||
row.append(art(r.artwork_url));
|
||
const main = el('div', 'nui-pod-main');
|
||
main.append(el('div', 'nui-pod-title', r.title), el('div', 'nui-pod-meta', [r.author, r.genre].filter(Boolean).join(' · ')));
|
||
row.append(main);
|
||
if (!r.rss_url) row.append(el('span', 'nui-pod-meta', 'kein Feed'));
|
||
else if (subscribed.has(r.rss_url)) row.append(el('span', 'nui-pod-badge', 'abonniert'));
|
||
else row.append(button('+', 'Abonnieren', () => subscribe(r.rss_url, r.title)));
|
||
list.append(row);
|
||
}
|
||
root.append(list);
|
||
}
|
||
|
||
async function search(q) {
|
||
if (!q) return;
|
||
if (/^https?:\/\//i.test(q)) { subscribe(q, q); return; }
|
||
searchResults = {q, loading: true};
|
||
render();
|
||
try {
|
||
const r = await fetch('/podcasts/search/?q=' + encodeURIComponent(q));
|
||
const data = await r.json();
|
||
searchResults = {q, items: data.results || [], error: data.error ? 'Die Suche ist gerade nicht erreichbar.' : null};
|
||
} catch (e) {
|
||
searchResults = {q, items: [], error: 'Die Suche braucht eine Verbindung.'};
|
||
}
|
||
render();
|
||
}
|
||
|
||
async function subscribe(rssUrl, title) {
|
||
toast('Wird abonniert …');
|
||
try {
|
||
const r = await fetch('/podcasts/feeds/add/', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({rss_url: rssUrl, title}),
|
||
});
|
||
const data = await r.json();
|
||
if (!r.ok || !data.ok) throw new Error(data.error || r.status);
|
||
toast(`„${data.title || title}“ abonniert`);
|
||
refresh(false);
|
||
} catch (e) {
|
||
toast(navigator.onLine ? 'Dieser Feed lässt sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
|
||
}
|
||
}
|
||
|
||
// ---- drag to reorder (touch) -----------------------------------------------------------
|
||
|
||
function dragSort(handle, row, list) {
|
||
handle.addEventListener('pointerdown', ev => {
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
handle.setPointerCapture(ev.pointerId);
|
||
const rows = [...list.children];
|
||
const from = rows.indexOf(row);
|
||
const rects = rows.map(r => r.getBoundingClientRect());
|
||
const startY = ev.clientY;
|
||
let to = from;
|
||
row.classList.add('nui-pod-dragging');
|
||
const move = e => {
|
||
const dy = e.clientY - startY;
|
||
row.style.transform = `translateY(${dy}px)`;
|
||
const mid = rects[from].top + rects[from].height / 2 + dy;
|
||
to = rects.findIndex(r => mid < r.top + r.height / 2);
|
||
if (to < 0) to = rects.length - 1;
|
||
rows.forEach((r, i) => {
|
||
if (r === row) return;
|
||
let shift = 0;
|
||
if (from < to && i > from && i <= to) shift = -rects[from].height;
|
||
if (from > to && i >= to && i < from) shift = rects[from].height;
|
||
r.style.transform = shift ? `translateY(${shift}px)` : '';
|
||
});
|
||
};
|
||
const end = () => {
|
||
handle.removeEventListener('pointermove', move);
|
||
handle.removeEventListener('pointerup', end);
|
||
handle.removeEventListener('pointercancel', end);
|
||
rows.forEach(r => { r.style.transform = ''; });
|
||
row.classList.remove('nui-pod-dragging');
|
||
if (to !== from) {
|
||
// Show the new order at once; the app's state follows right after.
|
||
list.insertBefore(row, to > from ? rows[to].nextSibling : rows[to]);
|
||
N.podcastMove(parseInt(row.dataset.id, 10), to);
|
||
}
|
||
};
|
||
handle.addEventListener('pointermove', move);
|
||
handle.addEventListener('pointerup', end);
|
||
handle.addEventListener('pointercancel', end);
|
||
});
|
||
}
|
||
|
||
// ---- mini player and full player -----------------------------------------------------------
|
||
|
||
let mini = null;
|
||
|
||
function buildMini() {
|
||
const dock = $id('nui-dock');
|
||
if (!dock || $id('nui-pod-mini')) return;
|
||
mini = el('div');
|
||
mini.id = 'nui-pod-mini';
|
||
mini.hidden = true;
|
||
mini.innerHTML = '<div class="nui-pod-mini-bar"><span></span></div>';
|
||
const text = el('button', 'nui-mini-text');
|
||
text.type = 'button';
|
||
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
|
||
text.addEventListener('click', openPlayer);
|
||
mini.append(art('', 'mini'), text,
|
||
button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'),
|
||
button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-mini-toggle'));
|
||
dock.prepend(mini);
|
||
|
||
// A "Podcasts" button in the bottom bar, between books and radio.
|
||
const tabbar = $id('nui-tabbar');
|
||
if (tabbar && !tabbar.querySelector('[data-tab="podcasts"]')) {
|
||
const b = el('button');
|
||
b.type = 'button';
|
||
b.dataset.tab = 'podcasts';
|
||
b.append(el('span', 'nui-tab-icon', '◎'), document.createTextNode('Podcasts'));
|
||
b.addEventListener('click', () => { showTab('podcasts'); scrollTop(0); });
|
||
tabbar.insertBefore(b, tabbar.querySelector('[data-tab="radio"]'));
|
||
}
|
||
}
|
||
|
||
function renderMini() {
|
||
const isPod = player.kind === 'podcast';
|
||
document.documentElement.classList.toggle('nui-pod-playing', isPod);
|
||
if (!mini) return;
|
||
mini.hidden = !isPod;
|
||
document.documentElement.classList.toggle('nui-pod-mini-open', isPod);
|
||
if (!isPod) return;
|
||
mini.querySelector('.nui-mini-station').textContent = player.title || '';
|
||
// The back button names the set skip length (checked now and then, it rarely changes).
|
||
if (!mini.skipChecked || Date.now() - mini.skipChecked > 5000) {
|
||
mini.skipChecked = Date.now();
|
||
const sk = mini.querySelector('.nui-pod-skip');
|
||
sk.title = `${devicePrefs().skip_back || 15} s zurück`;
|
||
sk.setAttribute('aria-label', sk.title);
|
||
}
|
||
mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …'
|
||
: (player.feed || '') + (player.dur ? ' · ' + clock((player.dur - player.pos) / 1000) + ' übrig' : '');
|
||
const t = mini.querySelector('.nui-mini-toggle');
|
||
t.textContent = player.playing ? '❚❚' : '▶';
|
||
t.title = player.playing ? 'Pause' : 'Abspielen';
|
||
const a = mini.querySelector('.nui-pod-art img');
|
||
const box = mini.querySelector('.nui-pod-art');
|
||
const src = (player.artwork || '').replace(/^http:/, 'https:');
|
||
if (!a && src) { const img = el('img'); img.alt = ''; img.src = src; box.append(img); }
|
||
else if (a && a.getAttribute('src') !== src) { if (src) a.src = src; else a.remove(); }
|
||
mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0';
|
||
}
|
||
|
||
let sheet = null;
|
||
let sheetEpisode = 0;
|
||
let seeking = false;
|
||
|
||
// ---- chapters and shownotes (server: /podcasts/episodes/<id>/) ------------------------
|
||
// Fetched per episode when wanted and kept for offline use (the last 40).
|
||
|
||
const details = new Map();
|
||
|
||
function storedDetails(id) {
|
||
if (details.has(id)) return details.get(id);
|
||
try {
|
||
const d = JSON.parse(localStorage.getItem('nui_pod_ep_' + id) || 'null');
|
||
if (d) details.set(id, d);
|
||
return d;
|
||
} catch (e) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function keepDetails(id, d) {
|
||
details.set(id, d);
|
||
try {
|
||
const ids = JSON.parse(localStorage.getItem('nui_pod_ep_ids') || '[]').filter(x => x !== id);
|
||
ids.push(id);
|
||
while (ids.length > 40) localStorage.removeItem('nui_pod_ep_' + ids.shift());
|
||
localStorage.setItem('nui_pod_ep_' + id, JSON.stringify(d));
|
||
localStorage.setItem('nui_pod_ep_ids', JSON.stringify(ids));
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function loadDetails(id) {
|
||
try {
|
||
const r = await fetch(`/podcasts/episodes/${id}/`);
|
||
if (!r.ok) throw new Error(r.status);
|
||
const d = await r.json();
|
||
keepDetails(id, d);
|
||
return d;
|
||
} catch (e) {
|
||
return storedDetails(id);
|
||
}
|
||
}
|
||
|
||
// Timestamps in shownotes ("12:34", "1:02:03") become links that jump there.
|
||
const STAMP = /\b(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\b/g;
|
||
|
||
function linkStamps(root) {
|
||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||
const nodes = [];
|
||
while (walker.nextNode()) {
|
||
const n = walker.currentNode;
|
||
if (!n.parentElement.closest('a') && STAMP.test(n.nodeValue)) nodes.push(n);
|
||
STAMP.lastIndex = 0;
|
||
}
|
||
for (const n of nodes) {
|
||
const frag = document.createDocumentFragment();
|
||
let last = 0;
|
||
n.nodeValue.replace(STAMP, (m, h, mi, se, at) => {
|
||
const secs = (parseInt(h || '0', 10) * 3600) + parseInt(mi, 10) * 60 + parseInt(se, 10);
|
||
if (parseInt(se, 10) > 59) return m;
|
||
frag.append(document.createTextNode(n.nodeValue.slice(last, at)));
|
||
const a = el('a', 'nui-pod-stamp', m);
|
||
a.href = '#';
|
||
a.dataset.t = String(secs);
|
||
frag.append(a);
|
||
last = at + m.length;
|
||
return m;
|
||
});
|
||
frag.append(document.createTextNode(n.nodeValue.slice(last)));
|
||
n.replaceWith(frag);
|
||
}
|
||
}
|
||
|
||
// Chapters and shownotes into a box; with `live`, taps jump in the playing episode.
|
||
async function showDetails(box, id, live) {
|
||
box.textContent = '';
|
||
box.dataset.episode = String(id);
|
||
let d = storedDetails(id);
|
||
if (!d) box.append(el('div', 'nui-pod-meta', 'Shownotes werden geladen …'));
|
||
const fresh = await loadDetails(id);
|
||
if (box.dataset.episode !== String(id)) return;
|
||
d = fresh || d;
|
||
box.textContent = '';
|
||
if (!d) {
|
||
box.append(el('div', 'nui-pod-meta', 'Shownotes brauchen beim ersten Mal eine Verbindung.'));
|
||
return;
|
||
}
|
||
if (live && sheet) {
|
||
const fav = sheet.querySelector('.nui-pod-fav');
|
||
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
|
||
}
|
||
const chapters = d.chapters || [];
|
||
if (live && chapters.length > 1) {
|
||
// Previous / next chapter, like AntennaPod's chapter buttons.
|
||
const nav = el('div', 'nui-pod-chapnav');
|
||
const jump = dir => {
|
||
const t = (player.pos || 0) / 1000;
|
||
let i = chapters.length - 1;
|
||
while (i > 0 && chapters[i].start > t + 0.5) i--;
|
||
// "Back" within the first seconds of a chapter goes to the one before.
|
||
const target = dir > 0 ? i + 1 : (t - chapters[i].start < 3 ? i - 1 : i);
|
||
if (target < 0 || target >= chapters.length) return;
|
||
N.podcastSeek(Math.round(chapters[target].start * 1000));
|
||
};
|
||
nav.append(button('⏮', 'Voriges Kapitel', () => jump(-1)), el('span', 'nui-pod-chapnow'), button('⏭', 'Nächstes Kapitel', () => jump(1)));
|
||
box.append(nav);
|
||
}
|
||
const tabs = el('div', 'nui-pod-subtabs');
|
||
const body = el('div', 'nui-pod-notesbody');
|
||
const show = which => {
|
||
tabs.querySelectorAll('button').forEach(b => b.classList.toggle('active', b.dataset.which === which));
|
||
body.textContent = '';
|
||
if (which === 'chapters') {
|
||
const ol = el('ol', 'nui-pod-chapters');
|
||
chapters.forEach((c, i) => {
|
||
const li = el('li', 'nui-pod-chapter');
|
||
li.dataset.start = String(c.start || 0);
|
||
li.dataset.index = String(i);
|
||
li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`)));
|
||
if (live) li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000)));
|
||
ol.append(li);
|
||
});
|
||
body.append(ol);
|
||
markChapter(box, (player.pos || 0) / 1000);
|
||
} else if (which === 'transcript') {
|
||
showTranscript(body, id, live);
|
||
} else {
|
||
const notes = el('div', 'nui-pod-notes');
|
||
notes.innerHTML = d.description || '<p>Keine Shownotes.</p>';
|
||
notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank'));
|
||
if (live) {
|
||
linkStamps(notes);
|
||
notes.addEventListener('click', ev => {
|
||
const a = ev.target.closest('a.nui-pod-stamp');
|
||
if (!a) return;
|
||
ev.preventDefault();
|
||
N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
|
||
});
|
||
}
|
||
body.append(notes);
|
||
}
|
||
};
|
||
const tab = (which, label) => {
|
||
const b = el('button', 'nui-pod-subtab', label);
|
||
b.type = 'button';
|
||
b.dataset.which = which;
|
||
b.addEventListener('click', () => show(which));
|
||
tabs.append(b);
|
||
};
|
||
if (chapters.length) tab('chapters', `Kapitel (${chapters.length})`);
|
||
tab('notes', 'Shownotes');
|
||
if (d.has_transcript) tab('transcript', 'Transkript');
|
||
box.append(tabs, body);
|
||
show(chapters.length ? 'chapters' : 'notes');
|
||
}
|
||
|
||
function markChapter(scope, seconds) {
|
||
const items = scope.querySelectorAll('.nui-pod-chapter');
|
||
let current = -1;
|
||
items.forEach((li, i) => { if (parseFloat(li.dataset.start) <= seconds + 0.5) current = i; });
|
||
items.forEach((li, i) => li.classList.toggle('current', i === current));
|
||
const now = scope.querySelector('.nui-pod-chapnow');
|
||
if (now) {
|
||
const d = storedDetails(parseInt((scope.querySelector('.nui-pod-notesbox') || scope).dataset.episode, 10));
|
||
const ch = d && d.chapters ? d.chapters.filter(c => c.start <= seconds + 0.5).pop() : null;
|
||
now.textContent = ch ? plain(ch.title || '') : '';
|
||
}
|
||
markTranscript(scope, seconds);
|
||
}
|
||
|
||
// ---- transcript: follows the playback, a tap jumps there --------------------------------
|
||
|
||
const transcripts = new Map();
|
||
let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
|
||
|
||
async function showTranscript(body, id, live) {
|
||
body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
|
||
let t = transcripts.get(id);
|
||
if (!t) {
|
||
try {
|
||
const r = await fetch(`/podcasts/episodes/${id}/transcript/`);
|
||
if (!r.ok) throw new Error(r.status);
|
||
t = await r.json();
|
||
transcripts.set(id, t);
|
||
} catch (e) {
|
||
body.textContent = '';
|
||
body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Für diese Folge gibt es kein lesbares Transkript.' : 'Das Transkript braucht eine Verbindung.'));
|
||
return;
|
||
}
|
||
}
|
||
body.textContent = '';
|
||
const list = el('div', 'nui-pod-transcript');
|
||
for (const seg of t.segments || []) {
|
||
const p = el('p', 'nui-pod-seg');
|
||
p.dataset.start = String(seg.start || 0);
|
||
if (seg.speaker) p.append(el('strong', 'nui-pod-speaker', seg.speaker + ': '));
|
||
p.append(document.createTextNode(seg.text || ''));
|
||
if (live) p.addEventListener('click', () => N.podcastSeek(Math.round((seg.start || 0) * 1000)));
|
||
list.append(p);
|
||
}
|
||
list.addEventListener('touchstart', () => { transcriptTouched = Date.now(); }, {passive: true});
|
||
body.append(list);
|
||
markTranscript(body, (player.pos || 0) / 1000);
|
||
}
|
||
|
||
function markTranscript(scope, seconds) {
|
||
const segs = scope.querySelectorAll('.nui-pod-seg');
|
||
if (!segs.length) return;
|
||
let current = null;
|
||
segs.forEach(p => { if (parseFloat(p.dataset.start) <= seconds + 0.3) current = p; });
|
||
segs.forEach(p => p.classList.toggle('current', p === current));
|
||
if (current && current.dataset.shown !== '1' && Date.now() - transcriptTouched > 8000) {
|
||
segs.forEach(p => { p.dataset.shown = ''; });
|
||
current.dataset.shown = '1';
|
||
current.scrollIntoView({block: 'center', behavior: 'smooth'});
|
||
}
|
||
}
|
||
|
||
// Shownotes of an episode that isn't playing: its own sheet.
|
||
let notesSheet = null;
|
||
|
||
function openNotes(e) {
|
||
closeNotes();
|
||
notesSheet = el('div');
|
||
notesSheet.id = 'nui-pod-player';
|
||
notesSheet.className = 'nui-pod-notes-sheet';
|
||
const panel = el('div', 'nui-pod-player-panel');
|
||
const top = el('div', 'nui-pod-player-top');
|
||
top.append(button('⌄', 'Schließen', closeNotes));
|
||
panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || ''));
|
||
const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen');
|
||
play1.type = 'button';
|
||
play1.addEventListener('click', () => { closeNotes(); play(e); });
|
||
panel.append(play1);
|
||
const box = el('div', 'nui-pod-notesbox');
|
||
panel.append(box);
|
||
notesSheet.append(panel);
|
||
notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
|
||
document.body.append(notesSheet);
|
||
showDetails(box, e.id, false);
|
||
}
|
||
|
||
function closeNotes() {
|
||
if (!notesSheet) return false;
|
||
notesSheet.remove();
|
||
notesSheet = null;
|
||
return true;
|
||
}
|
||
|
||
function openPlayer() {
|
||
if (player.kind !== 'podcast') return;
|
||
closePlayer();
|
||
sheet = el('div');
|
||
sheet.id = 'nui-pod-player';
|
||
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
|
||
const panel = sheet.firstChild;
|
||
const top = el('div', 'nui-pod-player-top');
|
||
top.append(button('⌄', 'Player schließen', closePlayer));
|
||
panel.append(top, art(player.artwork, 'player'));
|
||
panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'));
|
||
const range = el('input', 'nui-pod-seek');
|
||
range.type = 'range';
|
||
range.min = 0;
|
||
range.step = 1;
|
||
range.id = 'nui-pod-seek';
|
||
range.addEventListener('input', () => {
|
||
seeking = true;
|
||
sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value);
|
||
});
|
||
range.addEventListener('change', () => {
|
||
N.podcastSeek(range.value * 1000);
|
||
seeking = false;
|
||
});
|
||
const times = el('div', 'nui-pod-times');
|
||
times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left'));
|
||
panel.append(range, times);
|
||
const prefs = devicePrefs();
|
||
const back = prefs.skip_back || 15;
|
||
const fwd = prefs.skip_forward || 30;
|
||
const controls = el('div', 'nui-pod-controls');
|
||
controls.append(
|
||
button(String(back), `${back} Sekunden zurück`, () => N.podcastSkip(false), 'nui-pod-back'),
|
||
button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-pod-play'),
|
||
button(String(fwd), `${fwd} Sekunden vor`, () => N.podcastSkip(true), 'nui-pod-fwd'),
|
||
);
|
||
panel.append(controls);
|
||
const extra = el('div', 'nui-pod-extra');
|
||
extra.append(
|
||
button('1×', 'Geschwindigkeit', speedMenu, 'nui-pod-speed'),
|
||
button('☾', 'Sleep-Timer', sleepMenu, 'nui-pod-sleep'),
|
||
button('☆', 'Favorit', () => toggleFavorite({id: player.episodeId}, star => {
|
||
const b = sheet && sheet.querySelector('.nui-pod-fav');
|
||
if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); }
|
||
}), 'nui-pod-fav'),
|
||
button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'),
|
||
button('≡', 'Warteschlange', () => { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); }),
|
||
);
|
||
panel.append(extra);
|
||
const notes = el('div', 'nui-pod-notesbox');
|
||
panel.append(notes);
|
||
sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
|
||
document.body.append(sheet);
|
||
sheetEpisode = player.episodeId;
|
||
showDetails(notes, player.episodeId, true);
|
||
renderPlayer();
|
||
}
|
||
|
||
function closePlayer() {
|
||
if (!sheet) return false;
|
||
sheet.remove();
|
||
sheet = null;
|
||
return true;
|
||
}
|
||
|
||
function renderPlayer() {
|
||
if (!sheet) return;
|
||
if (player.kind !== 'podcast') { closePlayer(); return; }
|
||
if (sheetEpisode !== player.episodeId) {
|
||
// The next episode started while the player was open.
|
||
sheetEpisode = player.episodeId;
|
||
showDetails(sheet.querySelector('.nui-pod-notesbox'), player.episodeId, true);
|
||
}
|
||
markChapter(sheet, (player.pos || 0) / 1000);
|
||
sheet.querySelector('.nui-pod-player-title').textContent = player.title || '';
|
||
sheet.querySelector('.nui-pod-player-feed').textContent = player.feed || '';
|
||
const range = sheet.querySelector('.nui-pod-seek');
|
||
const dur = Math.round((player.dur || 0) / 1000);
|
||
const pos = Math.round((player.pos || 0) / 1000);
|
||
range.max = dur || 1;
|
||
if (!seeking) {
|
||
range.value = pos;
|
||
sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos);
|
||
}
|
||
sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : '';
|
||
const p = sheet.querySelector('.nui-pod-play');
|
||
p.textContent = player.playing ? '❚❚' : '▶';
|
||
p.title = player.playing ? 'Pause' : 'Abspielen';
|
||
const sp = sheet.querySelector('.nui-pod-speed');
|
||
sp.textContent = speedLabel(player.speed || 1) + (player.podcastSpeed ? '•' : '');
|
||
sp.title = player.podcastSpeed ? 'Geschwindigkeit (eigene für diesen Podcast)' : 'Geschwindigkeit';
|
||
const sl = sheet.querySelector('.nui-pod-sleep');
|
||
sl.textContent = sleepLabel() || '☾';
|
||
sl.classList.toggle('on', !!sleepLabel());
|
||
const img = sheet.querySelector('.nui-pod-art-player img');
|
||
const src = (player.artwork || '').replace(/^http:/, 'https:');
|
||
if (img && src && img.getAttribute('src') !== src) img.src = src;
|
||
}
|
||
|
||
function speedLabel(s) {
|
||
return String(Math.round(s * 100) / 100).replace('.', ',') + '×';
|
||
}
|
||
|
||
// Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast.
|
||
function speedMenu() {
|
||
const own = !!player.podcastSpeed;
|
||
const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts',
|
||
SPEEDS.map(s => ({
|
||
icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○',
|
||
label: speedLabel(s),
|
||
action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)),
|
||
})).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []));
|
||
if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; }
|
||
openSheet('Geschwindigkeit', [
|
||
{icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)},
|
||
{icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene',
|
||
action: () => pick(true)},
|
||
]);
|
||
}
|
||
|
||
// ---- sleep timer (runs in the app, also with the screen off) ----
|
||
|
||
function sleepLabel() {
|
||
const t = player.sleep || {};
|
||
if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`;
|
||
if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`;
|
||
return '';
|
||
}
|
||
|
||
// Minutes count only while playing; shaking the phone starts them over.
|
||
function sleepMenu() {
|
||
if (!N.podcastSleep) return;
|
||
const t = player.sleep || {};
|
||
const on = !!sleepLabel();
|
||
const items = [];
|
||
if (on) {
|
||
items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)});
|
||
if (t.leftMs > 0 && N.podcastSleepExtend) {
|
||
items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)});
|
||
items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)});
|
||
}
|
||
}
|
||
for (const m of [5, 10, 15, 30, 45, 60, 90]) {
|
||
items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
|
||
}
|
||
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
|
||
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
|
||
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
|
||
if (N.podcastSleepAuto) {
|
||
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)',
|
||
hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
|
||
action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }});
|
||
}
|
||
openSheet('Sleep-Timer', items);
|
||
}
|
||
|
||
function finishAndNext() {
|
||
const id = currentId();
|
||
if (!id) return;
|
||
N.podcastNext();
|
||
N.podcastMarkPlayed(id, true);
|
||
}
|
||
|
||
// ---- downloads (Downloads.kt) ------------------------------------------------------------
|
||
// Episodes kept on the phone to play without a connection. Each episode row
|
||
// gets a mark (↓ fetch it, a ring while it loads, ✓ when it's here), the ⋯
|
||
// menus and the settings sheet get their entries. The app downloads the
|
||
// first episodes of the queue by itself and tells us via __dioraDownloads.
|
||
|
||
const DL = !!N.podcastDownloads;
|
||
let downloads = DL ? parse(N.podcastDownloads(), {}) : {};
|
||
let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {};
|
||
const DL_COUNTS = [1, 2, 3, 5, 10];
|
||
// The limit and the clean-up the AntennaPod way (Downloads.kt, DownloadPlan.toClean).
|
||
const DL_LIMITS = [5, 10, 25, 50, 100, 500, 0];
|
||
const DL_CLEANUPS = [
|
||
{value: 'queue', label: 'Wenn nicht mehr in der Warteschlange',
|
||
hint: 'automatische Downloads, sobald ihre Folge dort fehlt; beim Limit auch gehörte'},
|
||
{value: 'never', label: 'Nie', hint: 'beim Limit lädt die App nichts mehr von selbst'},
|
||
];
|
||
const dlLimitLabel = n => (n ? n + ' Folgen' : 'Unbegrenzt');
|
||
|
||
function size(bytes) {
|
||
if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB';
|
||
return Math.max(1, Math.round((bytes || 0) / 1048576)) + ' MB';
|
||
}
|
||
|
||
function dlStart(e) {
|
||
N.podcastDownload(JSON.stringify(e.raw));
|
||
window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0}}));
|
||
toast('Wird heruntergeladen');
|
||
}
|
||
|
||
function dlDelete(e, running) {
|
||
N.podcastDeleteDownload(e.id);
|
||
toast(running ? 'Download abgebrochen' : 'Download gelöscht');
|
||
}
|
||
|
||
function dlAppend(row, e) {
|
||
if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return;
|
||
const d = downloads[e.id];
|
||
const state = d ? d.state : null;
|
||
let b;
|
||
if (state === 'done') {
|
||
b = button('✓', 'Heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-done');
|
||
} else if (state === 'queued' || state === 'downloading') {
|
||
b = button('', state === 'queued' ? 'Wartet auf den Download' : 'Wird heruntergeladen',
|
||
() => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-ring');
|
||
b.append(el('span'));
|
||
} else {
|
||
b = button('↓', state === 'failed' ? 'Download hat nicht geklappt — nochmal' : 'Herunterladen', () => dlStart(e),
|
||
'nui-pod-dl' + (state === 'failed' ? ' nui-pod-dl-failed' : ''));
|
||
}
|
||
b.dataset.dl = e.id;
|
||
dlRing(b);
|
||
row.append(b);
|
||
}
|
||
|
||
function dlRing(b) {
|
||
const d = downloads[b.dataset.dl];
|
||
if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return;
|
||
b.style.setProperty('--p', (d.progress || 0) + '%');
|
||
b.firstChild.textContent = d.state === 'queued' ? '…' : d.progress + '';
|
||
}
|
||
|
||
function dlMenuItems(e) {
|
||
if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return [];
|
||
const d = downloads[e.id];
|
||
if (!d || d.state === 'failed') {
|
||
return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung',
|
||
action: () => dlStart(e)}];
|
||
}
|
||
if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}];
|
||
return [{icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? 'wartet noch' : d.progress + ' %',
|
||
action: () => dlDelete(e, true)}];
|
||
}
|
||
|
||
function dlSet(changes) {
|
||
Object.assign(dlSettings, changes);
|
||
N.podcastSetDownloadSettings(JSON.stringify(changes));
|
||
settingsMenu();
|
||
}
|
||
|
||
function dlSettingsItems() {
|
||
if (!DL) return [];
|
||
const s = dlSettings = parse(N.podcastDownloadSettings(), dlSettings);
|
||
const done = Object.values(downloads).filter(d => d.state === 'done');
|
||
const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0);
|
||
return [
|
||
{icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen',
|
||
hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})},
|
||
s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen',
|
||
DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n,
|
||
action: () => dlSet({count: n})})))},
|
||
s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN',
|
||
action: () => dlSet({mobile: !s.mobile})},
|
||
{icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen',
|
||
hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten',
|
||
action: () => dlSet({deletePlayed: !s.deletePlayed})},
|
||
s.limit != null && {icon: '▦', label: 'Höchstens behalten',
|
||
hint: dlLimitLabel(s.limit) + ` · jetzt ${Object.values(downloads).filter(d => d.state !== 'failed').length}`,
|
||
action: () => openSheet('Höchstens so viele Folgen auf dem Telefon', DL_LIMITS.map(n => ({
|
||
icon: s.limit === n ? '●' : '○', label: dlLimitLabel(n), active: s.limit === n,
|
||
hint: n === 25 ? 'Standard' : null, action: () => dlSet({limit: n})})))},
|
||
s.cleanup && {icon: '♻', label: 'Aufräumen', hint: (DL_CLEANUPS.find(c => c.value === s.cleanup) || DL_CLEANUPS[0]).label,
|
||
action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({
|
||
icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value,
|
||
action: () => dlSet({cleanup: c.value})})))},
|
||
done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, danger: true,
|
||
hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
|
||
action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [
|
||
{icon: '✕', label: 'Löschen', hint: size(bytes), danger: true,
|
||
action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }},
|
||
{icon: '‹', label: 'Abbrechen', action: () => {}},
|
||
])},
|
||
];
|
||
}
|
||
|
||
window.__dioraDownloads = function (map) {
|
||
const before = downloads;
|
||
downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {};
|
||
const keys = Object.keys(downloads);
|
||
const same = keys.length === Object.keys(before).length &&
|
||
keys.every(id => before[id] && before[id].state === downloads[id].state);
|
||
// Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going).
|
||
if (same) document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing);
|
||
else render();
|
||
};
|
||
|
||
// ---- hooks from the app ---------------------------------------------------------------
|
||
|
||
window.__dioraPodcasts = function (json) {
|
||
state = typeof json === 'string' ? parse(json, state) : json;
|
||
render();
|
||
};
|
||
|
||
window.__dioraPodcastsRefreshed = function (ok) {
|
||
refreshing = false;
|
||
if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand');
|
||
render();
|
||
};
|
||
|
||
window.__dioraPlayer = function (p) {
|
||
const before = player;
|
||
player = p || {kind: null};
|
||
renderMini();
|
||
renderPlayer();
|
||
// The current episode changed: the queue view marks it.
|
||
if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
|
||
};
|
||
|
||
// The app loads this in <head>; the browser may load it after the page is parsed.
|
||
function start(fn) {
|
||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
|
||
else fn();
|
||
}
|
||
|
||
start(() => {
|
||
if (typeof showTab !== 'function') return;
|
||
// The web's podcast tab loads its own lists; the app has them already.
|
||
window.loadPodcastTab = function () {};
|
||
window.refreshAllFeeds = async function () {};
|
||
build();
|
||
buildMini();
|
||
player = parse(N.playerState(), {kind: null});
|
||
render();
|
||
renderMini();
|
||
|
||
const back = window.__dioraBack;
|
||
window.__dioraBack = function () {
|
||
if (closeStats()) return true;
|
||
if (closeNotes()) return true;
|
||
if (closePlayer()) return true;
|
||
if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true;
|
||
if (back && back()) return true;
|
||
if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts') {
|
||
if (feedView) { feedView = null; render(); return true; }
|
||
showTab('books');
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Pull the latest state once per start; the server has done the fetching.
|
||
N.podcastRefresh(false);
|
||
});
|
||
})();
|