diora-web/static/podcasts/native-podcast.js

2284 lines
100 KiB
JavaScript
Raw Normal View History

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