6462 lines
295 KiB
JavaScript
6462 lines
295 KiB
JavaScript
// 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 (&).
|
||
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)}¬es=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();
|
||
});
|
||
})();
|