diff --git a/radio/tests.py b/radio/tests.py
new file mode 100644
index 0000000..e8c52fd
--- /dev/null
+++ b/radio/tests.py
@@ -0,0 +1,40 @@
+from pathlib import Path
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.test import TestCase
+
+
+class PlayerPagePodcastsTests(TestCase):
+ """The podcast tab is the app's podcast page (static/podcasts/, copied from
+ diora-android) on a browser host (static/js/podcasts-web.js)."""
+
+ def test_logged_in_page_loads_the_podcast_host(self):
+ user = User.objects.create_user('pod', password='pw-for-tests-only')
+ self.client.force_login(user)
+ html = self.client.get('/').content.decode()
+ self.assertIn('id="tab-podcasts"', html)
+ self.assertIn('', html)
+ self.assertIn('dpw-loading', html)
+ # The old podcast tab and its player bar are gone.
+ self.assertNotIn('podcast-toolbar', html)
+ self.assertNotIn('podcast-seek-bar', html)
+ self.assertNotIn('INITIAL_PODCAST_FEEDS', html)
+
+ def test_logged_out_page_asks_to_log_in(self):
+ html = self.client.get('/').content.decode()
+ self.assertIn('to subscribe to podcasts.', html)
+ self.assertNotIn('dpw-loading', html)
+
+ def test_podcast_ui_files_are_there(self):
+ static = Path(settings.BASE_DIR) / 'static'
+ host = (static / 'js' / 'podcasts-web.js').read_text(encoding='utf-8')
+ for name in ('native-podcast.js', 'podcast-ui.css', 'podcasts-web.css'):
+ path = static / 'podcasts' / name
+ self.assertTrue(path.exists(), name)
+ if name != 'podcasts-web.css':
+ self.assertIn('Copied from diora-android', path.read_text(encoding='utf-8')[:300])
+ self.assertIn('/static/podcasts/' + name, host)
+ sw = (static / 'js' / 'sw.js').read_text(encoding='utf-8')
+ for path in ('/static/js/podcasts-web.js', '/static/podcasts/native-podcast.js'):
+ self.assertIn(path, sw)
diff --git a/radio/views.py b/radio/views.py
index 12b26c3..408297e 100644
--- a/radio/views.py
+++ b/radio/views.py
@@ -63,13 +63,6 @@ def index(request):
)
)
- initial_podcast_feeds = []
- if request.user.is_authenticated:
- from podcasts.models import PodcastFeed
- initial_podcast_feeds = list(
- request.user.podcast_feeds.values('id', 'title', 'artwork_url', 'rss_url')
- )
-
focus_station = None # null in JS means "never configured, use default"
encrypted_bg = {}
if request.user.is_authenticated:
@@ -91,7 +84,6 @@ def index(request):
'amazon_enabled': settings.AMAZON_AFFILIATE_ENABLED,
'featured_stations': featured,
'featured_stations_json': json.dumps(featured, cls=DjangoJSONEncoder),
- 'initial_podcast_feeds': json.dumps(initial_podcast_feeds, cls=DjangoJSONEncoder),
'focus_station': focus_station,
'focus_station_json': json.dumps(focus_station, cls=DjangoJSONEncoder),
'encrypted_bg': encrypted_bg,
diff --git a/static/css/app.css b/static/css/app.css
index fd2bcc6..050ed16 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -1251,241 +1251,6 @@ body.dnd-mode .timer-display {
.volume-num::-webkit-outer-spin-button { display: none; }
.volume-num { -moz-appearance: textfield; }
-/* ===== PODCAST COMPONENTS ===== */
-
-.podcast-seek-bar {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 0 12px;
- flex: 1;
- min-width: 0;
- position: relative;
-}
-
-.skip-btn {
- font-size: 0.72rem;
- opacity: 0.8;
- white-space: nowrap;
- padding: 2px 4px;
-}
-.skip-btn:hover { opacity: 1; }
-
-.speed-btns {
- display: flex;
- gap: 2px;
- margin-left: 4px;
- flex-shrink: 0;
-}
-
-.speed-btn {
- background: none;
- border: 1px solid var(--border, #444);
- color: var(--fg, #fff);
- border-radius: 3px;
- font-size: 0.68rem;
- padding: 1px 4px;
- cursor: pointer;
- opacity: 0.6;
- white-space: nowrap;
-}
-.speed-btn:hover { opacity: 1; }
-.speed-btn.active {
- opacity: 1;
- border-color: var(--accent, #e63946);
- color: var(--accent, #e63946);
-}
-
-.seek-slider {
- flex: 1;
- accent-color: var(--accent, #e63946);
- cursor: pointer;
- height: 4px;
-}
-
-.seek-time {
- font-size: 0.75rem;
- color: var(--text-muted, #888);
- white-space: nowrap;
- font-variant-numeric: tabular-nums;
-}
-
-.podcast-toolbar {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- padding: 8px 0 12px;
- border-bottom: 1px solid var(--border, #333);
- margin-bottom: 10px;
- align-items: center;
-}
-
-.podcast-pane { }
-
-.podcast-feed-list {
- display: flex;
- flex-direction: column;
- gap: 6px;
-}
-
-.podcast-feed-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 0;
- border-bottom: 1px solid var(--border, #222);
-}
-
-.podcast-feed-info {
- flex: 1;
- min-width: 0;
-}
-
-.podcast-feed-title {
- font-weight: bold;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.podcast-feed-actions {
- display: flex;
- gap: 4px;
- flex-shrink: 0;
-}
-
-.podcast-thumb {
- width: 48px;
- height: 48px;
- object-fit: cover;
- border-radius: 4px;
- flex-shrink: 0;
-}
-
-.podcast-thumb-lg {
- width: 80px;
- height: 80px;
- object-fit: cover;
- border-radius: 6px;
- flex-shrink: 0;
-}
-
-.podcast-thumb-placeholder {
- width: 48px;
- height: 48px;
- background: var(--surface, #1e1e2e);
- border-radius: 4px;
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.podcast-unplayed-badge {
- background: var(--accent, #e63946);
- color: #fff;
- border-radius: 99px;
- padding: 1px 7px;
- font-size: 0.7rem;
- margin-left: 6px;
-}
-
-.episode-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.episode-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 7px 0;
- border-bottom: 1px solid var(--border, #222);
-}
-
-.episode-item.episode-played {
- opacity: 0.5;
-}
-
-.episode-info {
- flex: 1;
- min-width: 0;
-}
-
-.episode-title {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- font-size: 0.9rem;
-}
-
-.episode-actions {
- display: flex;
- gap: 4px;
- flex-shrink: 0;
-}
-
-.podcast-search-list {
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 10px;
-}
-
-.podcast-search-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 6px 0;
- border-bottom: 1px solid var(--border, #222);
-}
-
-.podcast-search-info {
- flex: 1;
- min-width: 0;
-}
-
-.podcast-queue-ol {
- list-style: none;
- padding: 0;
- margin: 0;
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.podcast-feed-header {
- margin-bottom: 12px;
-}
-
-.podcast-feed-header-inner {
- display: flex;
- align-items: center;
- gap: 14px;
-}
-.feed-refresh-btn { margin-left: auto; flex-shrink: 0; }
-
-/* Clickable episode title (episode list) */
-.ep-clickable {
- cursor: pointer;
-}
-.ep-clickable:hover {
- color: var(--accent, #e63946);
- text-decoration: underline;
-}
-
-/* Clickable episode title / feed name in the now-playing bar */
-.podcast-track-link,
-.podcast-station-link {
- cursor: pointer;
-}
-.podcast-track-link:hover,
-.podcast-station-link:hover {
- color: var(--accent, #e63946);
- text-decoration: underline;
-}
-
/* ===== SIDEBAR ===== */
.sidebar-overlay {
@@ -1588,90 +1353,6 @@ body.dnd-mode .timer-display {
margin: 12px 0 4px;
}
-/* Inbox toolbar + checkboxes */
-.inbox-toolbar {
- display: flex; align-items: center; gap: 8px;
- padding: 0 0 8px; flex-wrap: wrap; min-height: 32px;
-}
-.inbox-select-all-label {
- display: flex; align-items: center; gap: 4px;
- font-size: 0.82rem; color: var(--text-muted, #888); cursor: pointer;
- white-space: nowrap;
-}
-.inbox-bulk-actions {
- display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
-}
-.inbox-load-more {
- display: flex; align-items: center; gap: 6px; margin-left: auto;
-}
-.inbox-checkbox-label {
- display: flex; align-items: center; flex-shrink: 0;
- padding-right: 4px; cursor: pointer;
-}
-.inbox-checkbox-label input[type="checkbox"] {
- width: 15px; height: 15px; cursor: pointer; accent-color: var(--accent, #e63946);
-}
-.episode-item.inbox-selected { background: var(--surface-alt, #2a2a3e); }
-
-/* Sleep timer */
-.sleep-timer-btn {
- font-size: 0.68rem; padding: 1px 5px; white-space: nowrap;
- border: 1px solid var(--border, #444); border-radius: 3px; opacity: 0.7;
-}
-.sleep-timer-btn:hover { opacity: 1; }
-.sleep-timer-menu {
- position: absolute; background: var(--surface, #1e1e2e);
- border: 1px solid var(--border, #444); border-radius: 6px; padding: 4px;
- display: flex; flex-direction: column; gap: 2px; z-index: 200;
- bottom: calc(100% + 4px); right: 0;
-}
-.sleep-timer-option {
- background: none; border: none; color: var(--fg, #fff);
- padding: 5px 12px; text-align: left; cursor: pointer;
- font-size: 0.85rem; border-radius: 4px;
-}
-.sleep-timer-option:hover { background: var(--accent, #e63946); color: #fff; }
-
-/* Episode filter */
-.episode-search-bar { padding: 6px 0 8px; }
-.episode-search-bar .search-input { width: 100%; box-sizing: border-box; }
-
-/* Episode meta line */
-.episode-meta {
- display: flex; gap: 8px; font-size: 0.78rem;
- color: var(--text-muted, #888); margin-top: 2px; flex-wrap: wrap;
-}
-.episode-date { font-weight: 500; color: var(--fg, #ccc); }
-.episode-feed-link { cursor: pointer; }
-.episode-feed-link:hover { text-decoration: underline; color: var(--accent, #e63946); }
-.episode-dur { color: var(--text-muted, #888); }
-
-/* Episode progress bar */
-.episode-progress-bar {
- height: 3px; background: var(--border, #333);
- border-radius: 2px; margin-top: 5px; overflow: hidden;
-}
-.episode-progress-fill {
- height: 100%; background: var(--accent, #e63946); border-radius: 2px;
-}
-
-/* Queue drag-and-drop */
-.drag-handle { cursor: grab; color: var(--text-muted, #666); padding: 0 6px; user-select: none; }
-.drag-handle:active { cursor: grabbing; }
-#podcast-queue-ol li.dragging { opacity: 0.4; border: 1px dashed var(--accent, #e63946); }
-
-/* Feed list toolbar */
-.feed-list-toolbar {
- display: flex; gap: 8px; align-items: center;
- padding: 0 0 10px; flex-wrap: wrap;
-}
-.feed-list-toolbar .search-input { flex: 1; min-width: 140px; }
-.feed-sort-select {
- background: var(--surface, #1e1e2e); color: var(--fg, #fff);
- border: 1px solid var(--border, #444); border-radius: 4px;
- padding: 4px 6px; font-size: 0.82rem; cursor: pointer;
-}
-
.tts-lang-select {
background: var(--surface, #1e1e2e); color: var(--fg, #fff);
border: 1px solid var(--border, #444); border-radius: 4px;
@@ -1682,11 +1363,6 @@ body.dnd-mode .timer-display {
@media (max-width: 600px) {
.sidebar { width: 100vw; }
- .podcast-seek-bar { padding: 0 6px; }
- .podcast-thumb { width: 40px; height: 40px; }
- .podcast-thumb-lg { width: 60px; height: 60px; }
- .podcast-feed-actions { flex-direction: column; }
- .episode-actions { flex-direction: column; }
/* Reader auf Mobile: Header kompakter, Fortschrittsfeld versteckt */
.reader-header { padding: 6px 8px; gap: 4px; }
diff --git a/static/js/app.js b/static/js/app.js
index 6d05415..15b2139 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -19,22 +19,6 @@ let sseSource = null;
let isPlaying = false;
let currentPlayId = null;
-// Podcast state
-let podcastMode = false;
-let currentEpisode = null; // {id, title, audioUrl, durationSeconds, feedId}
-let seekSaveTimer = null;
-let podcastFeeds = [];
-let podcastQueue = [];
-let podcastCurrentView = 'feeds';
-let podcastCurrentFeedId = null;
-const podcastEpCache = {}; // id → episode data, avoids encoding strings in onclick attrs
-
-let sleepTimerInterval = null;
-let sleepTimerEndSecs = 0;
-let sleepTimerEndOfEp = false;
-let _dragSrcEl = null;
-let feedSortOrder = 'alpha';
-
const audio = new Audio();
// Reconnect audio when the output device changes (e.g. Bluetooth, USB DAC)
@@ -206,24 +190,11 @@ function playStation(url, name, stationId) {
}
function stopPlayback(clearStation = true) {
- // Save podcast progress before stopping
- if (podcastMode && currentEpisode) {
- savePodcastProgress();
- }
- if (seekSaveTimer) { clearInterval(seekSaveTimer); seekSaveTimer = null; }
-
audio.pause();
audio.src = '';
- audio.ontimeupdate = null;
- audio.onended = null;
- audio.onerror = null;
isPlaying = false;
- podcastMode = false;
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'none';
- const seekBar = $('podcast-seek-bar');
- if (seekBar) seekBar.style.display = 'none';
-
if (sseSource) {
sseSource.close();
sseSource = null;
@@ -239,12 +210,7 @@ function stopPlayback(clearStation = true) {
stopPlaySession();
const stationEl = $('now-playing-station');
- stationEl.classList.remove('podcast-station-link');
- stationEl.onclick = null;
-
const trackEl = $('now-playing-track');
- trackEl.classList.remove('podcast-track-link');
- trackEl.onclick = null;
if (clearStation) {
currentStation = null;
@@ -300,12 +266,6 @@ window.addEventListener('beforeunload', () => {
if (currentPlayId) {
navigator.sendBeacon('/radio/play/stop/', JSON.stringify({play_id: currentPlayId}));
}
- if (podcastMode && currentEpisode) {
- navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify({
- episode_id: currentEpisode.id,
- position_seconds: Math.floor(audio.currentTime),
- }));
- }
// Flush cached encrypted payloads for reader data (encryption is async so we
// use pre-computed blobs stored by the debounced savers)
if (_lastProgressBeacon) navigator.sendBeacon(_lastProgressBeacon.url, _lastProgressBeacon.body);
@@ -1201,7 +1161,6 @@ async function editNotes(pk, current) {
const TOP_TABS = ['radio', 'focus', 'podcasts', 'books'];
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
-const PODCAST_ROUTABLE_VIEWS = ['search', 'feeds', 'inbox', 'queue'];
// ---------------------------------------------------------------------------
// URL hash routing — makes tabs deep-linkable/shareable and lets the browser
@@ -1217,8 +1176,6 @@ function _syncHash() {
let hash = _currentTopTab;
if (_currentTopTab === 'radio') {
hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
- } else if (_currentTopTab === 'podcasts') {
- hash += '/' + (podcastCurrentView || 'feeds');
}
if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash);
}
@@ -1228,9 +1185,6 @@ function _routeFromHash() {
if (!TOP_TABS.includes(tab)) return;
_routingFromHash = true;
try {
- if (tab === 'podcasts' && PODCAST_ROUTABLE_VIEWS.includes(sub)) {
- podcastCurrentView = sub;
- }
showTab(tab);
if (tab === 'radio' && RADIO_SUB_TABS.includes(sub)) showRadioTab(sub);
} finally {
@@ -1261,7 +1215,6 @@ function showTab(name) {
_currentTopTab = name;
_syncHash();
- if (name === 'podcasts') loadPodcastTab();
if (name === 'books') loadBookList();
}
@@ -1281,997 +1234,6 @@ function showRadioTab(name) {
if (name === 'saved') loadRecommendations();
}
-// ---------------------------------------------------------------------------
-// Podcasts
-// ---------------------------------------------------------------------------
-
-function loadPodcastTab() {
- loadFeedList().then(() => {
- showPodcastView(podcastCurrentView);
- });
-}
-
-function showPodcastView(view) {
- podcastCurrentView = view;
- const panes = ['search', 'feeds', 'inbox', 'episodes', 'queue'];
- panes.forEach(p => {
- const el = document.getElementById(`podcast-${p}-pane`);
- if (el) el.style.display = (p === view) ? '' : 'none';
- });
-
- if (PODCAST_ROUTABLE_VIEWS.includes(view)) _syncHash();
-
- if (view === 'feeds') renderFeedList();
- if (view === 'inbox') loadAndRenderInbox();
- if (view === 'queue') loadAndRenderQueue();
-}
-
-async function doPodcastSearch() {
- const q = $('podcast-search-input').value.trim();
- if (!q) return;
- const statusEl = $('podcast-search-status');
- const listEl = $('podcast-search-list');
- statusEl.textContent = 'Searching…';
- listEl.innerHTML = '';
-
- try {
- const res = await fetch('/podcasts/search/?q=' + encodeURIComponent(q));
- const data = await res.json();
- if (data.error) { statusEl.textContent = 'Error: ' + data.error; return; }
- const results = data.results || [];
- statusEl.textContent = results.length ? `${results.length} result(s)` : 'No results.';
-
- results.forEach(r => {
- const div = document.createElement('div');
- div.className = 'podcast-search-item';
- div.innerHTML = `
- ${r.artwork_url ? ` ` : '
'}
-
-
${escapeHtml(r.title)}
-
${escapeHtml(r.author)}
-
- Subscribe
- `;
- // Attach via addEventListener to avoid encoding strings in onclick attribute
- div.querySelector('.podcast-subscribe-btn').addEventListener('click', () => {
- subscribeFeed(r.rss_url, r.title);
- });
- listEl.appendChild(div);
- });
- } catch (e) {
- statusEl.textContent = 'Search failed.';
- }
-}
-
-function podcastSearchOpen() {
- showPodcastView('search');
-}
-
-async function addFeedByUrl() {
- const url = await customPrompt('RSS feed URL:');
- if (url) subscribeFeed(url.trim(), '');
-}
-
-async function subscribeFeed(rssUrl, title) {
- if (!rssUrl) return;
- const statusEl = $('podcast-search-status') || $('opml-status');
- try {
- const res = await fetch('/podcasts/feeds/add/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({rss_url: rssUrl, title: title || rssUrl}),
- });
- const data = await res.json();
- if (data.ok) {
- await loadFeedList();
- showPodcastView('feeds');
- } else if (statusEl) {
- statusEl.textContent = 'Error: ' + (data.error || 'unknown');
- }
- } catch (e) {
- if (statusEl) statusEl.textContent = 'Failed to subscribe.';
- }
-}
-
-async function loadFeedList() {
- try {
- const res = await fetch('/podcasts/feeds/');
- const data = await res.json();
- podcastFeeds = data.feeds || [];
- } catch (e) {
- podcastFeeds = [];
- }
-}
-
-function renderFeedList() {
- const container = $('podcast-feed-list');
- if (!container) return;
-
- if (!podcastFeeds.length) {
- container.innerHTML = 'No subscriptions yet. Search or import OPML to add feeds.
';
- return;
- }
-
- container.innerHTML = '';
- podcastFeeds.forEach(feed => {
- const div = document.createElement('div');
- div.className = 'podcast-feed-item';
- div.innerHTML = `
- ${feed.artwork_url
- ? ` `
- : '
'}
-
-
${escapeHtml(feed.title)}
- ${feed.author ? `
${escapeHtml(feed.author)}
` : ''}
-
-
- Episodes
- ↻
- Auto
- Remove
-
- `;
- container.appendChild(div);
- });
-
- const filterVal = ($('feed-filter-input') || {}).value || '';
- if (filterVal) filterFeeds(filterVal);
-}
-
-function filterFeeds(query) {
- const container = $('podcast-feed-list');
- if (!container) return;
- const q = query.toLowerCase().trim();
- container.querySelectorAll('.podcast-feed-item').forEach(item => {
- const title = (item.querySelector('.podcast-feed-title') || {}).textContent?.toLowerCase() || '';
- const author = (item.querySelector('.muted') || {}).textContent?.toLowerCase() || '';
- item.style.display = (!q || title.includes(q) || author.includes(q)) ? '' : 'none';
- });
-}
-
-function sortFeeds(order) {
- feedSortOrder = order;
- if (order === 'alpha') podcastFeeds.sort((a, b) => a.title.localeCompare(b.title));
- if (order === 'alpha-desc') podcastFeeds.sort((a, b) => b.title.localeCompare(a.title));
- if (order === 'added') podcastFeeds.sort((a, b) => (b.added_at || '').localeCompare(a.added_at || ''));
- if (order === 'latest_episode') podcastFeeds.sort((a, b) => (b.latest_episode_at || '').localeCompare(a.latest_episode_at || ''));
- renderFeedList();
-}
-
-async function toggleFeedAutoQueue(feedId, btn) {
- try {
- const res = await fetch(`/podcasts/feeds/${feedId}/set-auto-queue/`, {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({}),
- });
- const data = await res.json();
- if (data.ok) {
- const feed = podcastFeeds.find(f => f.id === feedId);
- if (feed) feed.auto_queue = data.auto_queue;
- btn.classList.toggle('active', data.auto_queue);
- btn.title = data.auto_queue ? 'Auto-queue ON' : 'Auto-queue new episodes';
- }
- } catch (e) {}
-}
-
-async function openFeed(feedId) {
- podcastCurrentFeedId = feedId;
- showPodcastView('episodes');
- const headerEl = $('podcast-feed-header');
- const listEl = $('podcast-episode-list');
- if (headerEl) headerEl.innerHTML = 'Loading…
';
- if (listEl) listEl.innerHTML = '';
-
- try {
- const res = await fetch(`/podcasts/feeds/${feedId}/episodes/`);
- const data = await res.json();
- const feed = data.feed;
- const episodes = data.episodes || [];
-
- if (headerEl) {
- headerEl.innerHTML = `
-
- `;
- }
-
- renderEpisodeList(episodes, feedId, listEl);
- const filterBar = $('episode-search-bar');
- if (filterBar) { filterBar.style.display = ''; $('episode-filter-input').value = ''; }
- } catch (e) {
- if (headerEl) headerEl.innerHTML = 'Failed to load episodes.
';
- }
-}
-
-function filterEpisodes(query) {
- const listEl = $('podcast-episode-list');
- if (!listEl) return;
- const q = query.toLowerCase().trim();
- listEl.querySelectorAll('.episode-item').forEach(item => {
- const text = (item.querySelector('.episode-title') || {}).textContent?.toLowerCase() || '';
- item.style.display = (!q || text.includes(q)) ? '' : 'none';
- });
-}
-
-function renderEpisodeList(episodes, feedId, container) {
- if (!container) return;
- if (!episodes.length) {
- container.innerHTML = 'No episodes found.
';
- return;
- }
-
- container.innerHTML = '';
- episodes.forEach(ep => {
- // Cache episode data by id so onclick attrs only need the id (avoids encoding
- // strings with quotes inside HTML attributes which breaks the attribute parser)
- podcastEpCache[ep.id] = {
- id: ep.id,
- title: ep.title,
- description: ep.description || '',
- audioUrl: ep.audio_url,
- durationSeconds: ep.duration_seconds,
- positionSeconds: ep.position_seconds || 0,
- feedId: feedId || 0,
- played: ep.played,
- };
-
- const div = document.createElement('div');
- div.className = 'episode-item' + (ep.played ? ' episode-played' : '');
- div.id = `episode-item-${ep.id}`;
-
- const artSrc = ep.artwork_url || (feedId ? (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '' : '');
- const dur = formatDuration(ep.duration_seconds);
- const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
- const posStr = ep.position_seconds > 0 ? formatDuration(ep.position_seconds) + ' played' : '';
- const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
- ? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
-
- div.innerHTML = `
- ${artSrc ? ` ` : '
'}
-
-
${escapeHtml(ep.title)}
-
- ${dateStr ? `${escapeHtml(dateStr)} ` : ''}
- ${dur !== '0:00' ? `${escapeHtml(dur)} ` : ''}
- ${posStr ? `${escapeHtml(posStr)} ` : ''}
-
- ${progressPct > 0 ? `
` : ''}
-
-
- ▶
- Notes
- ${ep.in_queue ? '✓Q' : '+Q'}
- ${ep.played ? '✓' : '○'}
- ⬇
-
- `;
- container.appendChild(div);
- });
-}
-
-function playEpisodeById(id) {
- const ep = podcastEpCache[id];
- if (!ep) return;
- playEpisode(ep.id, ep.title, ep.audioUrl, ep.durationSeconds, ep.positionSeconds, ep.feedId);
-}
-
-function downloadEpisodeById(id, btn) {
- const ep = podcastEpCache[id];
- if (!ep) return;
- downloadEpisode(ep.audioUrl, ep.title, btn);
-}
-
-function playEpisode(id, title, url, durationSeconds, positionSeconds, feedId) {
- // Auto-enqueue if not already in queue
- const inQueue = podcastQueue.some(q => q['episode__id'] === id);
- if (!inQueue) {
- fetch('/podcasts/queue/add/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: id}),
- }).then(() => {
- // update local queue state and any visible +Q buttons
- if (podcastCurrentView === 'queue') loadAndRenderQueue();
- const qBtn = document.querySelector(`#episode-item-${id} .episode-actions .btn-sm:nth-child(3)`);
- if (qBtn && (qBtn.textContent === '+Q' || qBtn.textContent.includes('Q'))) {
- qBtn.textContent = '✓Q'; qBtn.title = 'In queue';
- }
- }).catch(() => {});
- }
-
- stopPlayback(false);
-
- podcastMode = true;
- isPlaying = true; // fix: was missing, causing stop button to do nothing
- currentEpisode = {id, title, audioUrl: url, durationSeconds, feedId};
-
- audio.src = url;
- const volSlider = $('volume');
- if (volSlider) audio.volume = volSlider.value / 255;
-
- if (positionSeconds > 0) {
- audio.addEventListener('loadedmetadata', function onMeta() {
- audio.currentTime = positionSeconds;
- audio.removeEventListener('loadedmetadata', onMeta);
- });
- }
-
- audio.play().catch(e => console.warn('Podcast play blocked:', e));
-
- const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || 'Podcast';
- const stationEl = $('now-playing-station');
- stationEl.textContent = feedTitle;
- stationEl.classList.add('podcast-station-link');
- stationEl.onclick = () => { showTab('podcasts'); openFeed(feedId); };
-
- const trackEl = $('now-playing-track');
- trackEl.textContent = title;
- trackEl.classList.add('podcast-track-link');
- trackEl.onclick = () => openEpisodeSidebar(id);
- $('play-stop-btn').style.display = '';
- $('play-stop-btn').textContent = '⏹ Stop';
- $('play-stop-btn').classList.add('playing');
-
- const seekBar = $('podcast-seek-bar');
- if (seekBar) seekBar.style.display = '';
-
- const slider = $('seek-slider');
- if (slider && durationSeconds > 0) slider.max = durationSeconds;
-
- // Reset speed to 1× for each new episode
- setPlaybackRate(1);
-
- audio.ontimeupdate = podcastTimeUpdate;
- audio.onended = podcastOnEnded;
-
- // Media Session API — maps hardware media keys, lock-screen controls, and
- // Windows taskbar thumbnail buttons (play/pause, previous, next)
- if ('mediaSession' in navigator) {
- const feedTitle = (podcastFeeds.find(f => f.id === feedId) || {}).title || '';
- const artSrc = (podcastFeeds.find(f => f.id === feedId) || {}).artwork_url || '';
- navigator.mediaSession.metadata = new MediaMetadata({
- title,
- artist: feedTitle,
- artwork: artSrc ? [{src: artSrc, sizes: '512x512', type: 'image/jpeg'}] : [],
- });
- navigator.mediaSession.setActionHandler('play', () => { audio.play(); isPlaying = true; });
- navigator.mediaSession.setActionHandler('pause', () => { audio.pause(); isPlaying = false; });
- navigator.mediaSession.setActionHandler('stop', () => stopPlayback(true));
- navigator.mediaSession.setActionHandler('seekbackward', () => skipBack());
- navigator.mediaSession.setActionHandler('seekforward', () => skipForward());
- navigator.mediaSession.setActionHandler('nexttrack', () => podcastOnEnded());
- try { navigator.mediaSession.setActionHandler('previoustrack', () => skipBack()); } catch (_) {}
- navigator.mediaSession.playbackState = 'playing';
- }
-
- if (seekSaveTimer) clearInterval(seekSaveTimer);
- seekSaveTimer = setInterval(savePodcastProgress, 15000);
-}
-
-function podcastTimeUpdate() {
- const pos = Math.floor(audio.currentTime);
- const dur = currentEpisode ? currentEpisode.durationSeconds : 0;
-
- const curEl = $('seek-current');
- if (curEl) curEl.textContent = formatDuration(pos);
-
- const durEl = $('seek-duration');
- if (durEl) durEl.textContent = formatDuration(dur || Math.floor(audio.duration) || 0);
-
- const slider = $('seek-slider');
- if (slider) {
- if (dur > 0) {
- slider.max = dur;
- } else if (audio.duration && isFinite(audio.duration)) {
- slider.max = Math.floor(audio.duration);
- }
- slider.value = pos;
- }
-}
-
-function skipBack() {
- audio.currentTime = Math.max(0, audio.currentTime - 15);
-}
-
-function skipForward() {
- const dur = audio.duration;
- audio.currentTime = dur && isFinite(dur)
- ? Math.min(dur, audio.currentTime + 30)
- : audio.currentTime + 30;
-}
-
-function setPlaybackRate(rate) {
- audio.playbackRate = rate;
- // Keep pitch natural at non-1× speeds (supported in all modern browsers)
- audio.preservesPitch = true;
-
- document.querySelectorAll('.speed-btn').forEach(btn => {
- btn.classList.toggle('active', parseFloat(btn.textContent) === rate
- || (rate === 0.75 && btn.textContent.startsWith('¾'))
- || (rate === 1.25 && btn.textContent.startsWith('1¼'))
- || (rate === 1.5 && btn.textContent.startsWith('1½'))
- || (rate === 1.75 && btn.textContent.startsWith('1¾'))
- || (rate === 2.5 && btn.textContent.startsWith('2½')));
- });
-}
-
-async function podcastOnEnded() {
- if (!podcastMode || !currentEpisode) return;
-
- await fetch('/podcasts/progress/mark-played/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: currentEpisode.id, played: true}),
- }).catch(() => {});
-
- if (sleepTimerEndOfEp) { clearSleepTimer(); audio.pause(); return; }
-
- const finishedId = currentEpisode.id;
- try {
- const res = await fetch('/podcasts/queue/');
- const data = await res.json();
- const items = data.queue || [];
- const currentIdx = items.findIndex(item => item['episode__id'] === finishedId);
- const nextItem = currentIdx >= 0 ? items[currentIdx + 1] : null;
-
- await fetch('/podcasts/queue/remove/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: finishedId}),
- }).catch(() => {});
-
- if (nextItem) {
- const nextEpId = nextItem['episode__id'];
- const cached = podcastEpCache[nextEpId] || {};
- playEpisode(nextEpId, nextItem['episode__title'], nextItem['episode__audio_url'],
- nextItem['episode__duration_seconds'], cached.positionSeconds || 0, nextItem['episode__feed__id']);
- } else {
- stopPlayback(false);
- }
- if (podcastCurrentView === 'queue') loadAndRenderQueue();
- } catch (e) {
- stopPlayback(false);
- }
-}
-
-function openSleepTimerMenu() {
- const existing = document.getElementById('sleep-timer-menu');
- if (existing) { existing.remove(); return; }
- const options = [
- {label: 'Off', value: 0}, {label: '5m', value: 5}, {label: '10m', value: 10},
- {label: '15m', value: 15}, {label: '30m', value: 30}, {label: '45m', value: 45},
- {label: '60m', value: 60}, {label: 'End of episode', value: -1},
- ];
- const menu = document.createElement('div');
- menu.id = 'sleep-timer-menu';
- menu.className = 'sleep-timer-menu';
- options.forEach(opt => {
- const btn = document.createElement('button');
- btn.className = 'sleep-timer-option';
- btn.textContent = opt.label;
- btn.onclick = () => { setSleepTimer(opt.value); menu.remove(); };
- menu.appendChild(btn);
- });
- document.getElementById('sleep-timer-btn').insertAdjacentElement('afterend', menu);
-}
-
-function setSleepTimer(minutes) {
- clearSleepTimer();
- const btn = document.getElementById('sleep-timer-btn');
- if (minutes === 0) { if (btn) btn.textContent = 'Sleep'; return; }
- if (minutes === -1) {
- sleepTimerEndOfEp = true;
- if (btn) btn.textContent = 'Sleep:EoE';
- return;
- }
- sleepTimerEndOfEp = false;
- sleepTimerEndSecs = Math.floor(Date.now() / 1000) + minutes * 60;
- sleepTimerInterval = setInterval(() => {
- const remaining = sleepTimerEndSecs - Math.floor(Date.now() / 1000);
- if (remaining <= 0) { clearSleepTimer(); audio.pause(); isPlaying = false; return; }
- const m = Math.floor(remaining / 60);
- const s = remaining % 60;
- if (btn) btn.textContent = `${m}:${String(s).padStart(2, '0')}`;
- }, 1000);
-}
-
-function clearSleepTimer() {
- if (sleepTimerInterval) { clearInterval(sleepTimerInterval); sleepTimerInterval = null; }
- sleepTimerEndOfEp = false;
- sleepTimerEndSecs = 0;
- const btn = document.getElementById('sleep-timer-btn');
- if (btn) btn.textContent = 'Sleep';
-}
-
-async function savePodcastProgress() {
- if (!currentEpisode) return;
- const pos = Math.floor(audio.currentTime);
- try {
- await fetch('/podcasts/progress/save/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: currentEpisode.id, position_seconds: pos}),
- });
- } catch (e) {}
-}
-
-let _inboxOffset = 0;
-const _inboxPageSize = DIORA_CONFIG.podcastInboxPageSize;
-
-async function loadAndRenderInbox(append = false) {
- const listEl = $('podcast-inbox-list');
- if (!listEl) return;
-
- if (!append) {
- _inboxOffset = 0;
- listEl.innerHTML = 'Loading…
';
- inboxUpdateBulkBar();
- }
-
- try {
- const res = await fetch(`/podcasts/inbox/?limit=${_inboxPageSize}&offset=${_inboxOffset}`);
- const data = await res.json();
- const episodes = data.episodes || [];
-
- if (!append) listEl.innerHTML = '';
-
- if (!episodes.length && !append) {
- listEl.innerHTML = 'Inbox empty — all caught up!
';
- $('inbox-load-more-bar').style.display = 'none';
- return;
- }
-
- episodes.forEach(ep => {
- podcastEpCache[ep.id] = {
- id: ep.id,
- title: ep.title,
- description: ep.description || '',
- audioUrl: ep.audio_url,
- durationSeconds: ep.duration_seconds,
- positionSeconds: ep.position_seconds || 0,
- feedId: ep['feed__id'],
- played: false,
- };
-
- const progressPct = (ep.duration_seconds > 0 && ep.position_seconds > 0)
- ? Math.min(100, (ep.position_seconds / ep.duration_seconds) * 100) : 0;
- const dur = formatDuration(ep.duration_seconds);
- const dateStr = ep.pub_date ? ep.pub_date.slice(0, 10) : '';
- const inQueue = ep.in_queue;
-
- const div = document.createElement('div');
- div.className = 'episode-item';
- div.dataset.epId = ep.id;
- div.innerHTML = `
-
-
-
- ${ep['feed__artwork_url'] ? ` ` : '
'}
-
-
${escapeHtml(ep.title)}
-
- ${escapeHtml(ep['feed__title'])}
- ${dateStr ? `${escapeHtml(dateStr)} ` : ''}
- ${dur !== '0:00' ? `${escapeHtml(dur)} ` : ''}
-
- ${progressPct > 0 ? `
` : ''}
-
-
- ▶
- ${inQueue ? '✓Q' : '+Q'}
- ⬇
- ✕
-
- `;
- listEl.appendChild(div);
- });
-
- _inboxOffset += episodes.length;
- const moreBar = $('inbox-load-more-bar');
- const countLabel = $('inbox-count-label');
- if (episodes.length === _inboxPageSize) {
- moreBar.style.display = '';
- if (countLabel) countLabel.textContent = `${_inboxOffset} loaded`;
- } else {
- moreBar.style.display = 'none';
- if (countLabel) countLabel.textContent = '';
- }
- } catch (e) {
- if (!append) listEl.innerHTML = 'Failed to load inbox.
';
- }
-}
-
-function inboxLoadMore() {
- loadAndRenderInbox(true);
-}
-
-function inboxGetSelectedIds() {
- return Array.from(document.querySelectorAll('.inbox-cb:checked'))
- .map(cb => parseInt(cb.dataset.epId, 10));
-}
-
-function inboxOnCheck() {
- inboxUpdateBulkBar();
- // sync select-all state
- const all = document.querySelectorAll('.inbox-cb');
- const checked = document.querySelectorAll('.inbox-cb:checked');
- const selectAll = $('inbox-select-all');
- if (selectAll) {
- selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
- selectAll.checked = all.length > 0 && checked.length === all.length;
- }
-}
-
-function inboxSelectAll(checked) {
- document.querySelectorAll('.inbox-cb').forEach(cb => { cb.checked = checked; });
- inboxUpdateBulkBar();
-}
-
-function inboxUpdateBulkBar() {
- const ids = inboxGetSelectedIds();
- const bar = $('inbox-bulk-actions');
- const countEl = $('inbox-selection-count');
- if (!bar) return;
- if (ids.length > 0) {
- bar.style.display = '';
- if (countEl) countEl.textContent = `${ids.length} selected`;
- } else {
- bar.style.display = 'none';
- }
-}
-
-async function inboxBulkDismiss() {
- const ids = inboxGetSelectedIds();
- if (!ids.length) return;
- try {
- await fetch('/podcasts/progress/dismiss/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_ids: ids, dismissed: true}),
- });
- ids.forEach(id => {
- const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
- if (div) div.remove();
- });
- inboxUpdateBulkBar();
- const selectAll = $('inbox-select-all');
- if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
- if (!document.querySelector('.inbox-cb')) {
- const listEl = $('podcast-inbox-list');
- if (listEl && !listEl.querySelector('.episode-item')) {
- listEl.innerHTML = 'Inbox empty — all caught up!
';
- }
- }
- } catch (e) {}
-}
-
-async function inboxDismissOne(epId, btn) {
- try {
- await fetch('/podcasts/progress/dismiss/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_ids: [epId], dismissed: true}),
- });
- const div = document.querySelector(`.episode-item[data-ep-id="${epId}"]`);
- if (div) div.remove();
- if (!document.querySelector('.inbox-cb')) {
- const listEl = $('podcast-inbox-list');
- if (listEl && !listEl.querySelector('.episode-item')) {
- listEl.innerHTML = 'Inbox empty — all caught up!
';
- }
- }
- } catch (e) {}
-}
-
-async function inboxBulkQueueAdd() {
- const ids = inboxGetSelectedIds();
- for (const id of ids) {
- await queueAddEpisode(id);
- }
- // Update queue button states
- ids.forEach(id => {
- const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
- if (div) {
- const qBtn = div.querySelector('.episode-actions .btn-sm:nth-child(2)');
- if (qBtn) { qBtn.textContent = '✓Q'; qBtn.title = 'In queue'; }
- }
- });
-}
-
-async function inboxBulkMarkPlayed() {
- const ids = inboxGetSelectedIds();
- try {
- for (const id of ids) {
- await fetch('/podcasts/progress/mark-played/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: id, played: true}),
- });
- }
- ids.forEach(id => {
- const div = document.querySelector(`.episode-item[data-ep-id="${id}"]`);
- if (div) div.remove();
- });
- inboxUpdateBulkBar();
- const selectAll = $('inbox-select-all');
- if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
- } catch (e) {}
-}
-
-async function inboxBulkDownload() {
- const ids = inboxGetSelectedIds();
- for (const id of ids) {
- const ep = podcastEpCache[id];
- if (ep) await downloadEpisode(ep.audioUrl, ep.title, null);
- }
-}
-
-async function loadAndRenderQueue() {
- const ol = $('podcast-queue-ol');
- if (!ol) return;
- ol.innerHTML = 'Loading… ';
-
- try {
- const res = await fetch('/podcasts/queue/');
- const data = await res.json();
- const items = data.queue || [];
- podcastQueue = items;
-
- ol.innerHTML = '';
- if (!items.length) {
- ol.innerHTML = 'Queue is empty. ';
- return;
- }
-
- items.forEach(item => {
- const epId = item['episode__id'];
- podcastEpCache[epId] = {
- id: epId,
- title: item['episode__title'],
- audioUrl: item['episode__audio_url'],
- durationSeconds: item['episode__duration_seconds'],
- positionSeconds: item['position_seconds'] || 0,
- feedId: item['episode__feed__id'],
- played: false,
- };
-
- const progressPct = (item['episode__duration_seconds'] > 0 && item['position_seconds'] > 0)
- ? Math.min(100, (item['position_seconds'] / item['episode__duration_seconds']) * 100) : 0;
- const dur = formatDuration(item['episode__duration_seconds']);
-
- const li = document.createElement('li');
- li.className = 'episode-item';
- li.draggable = true;
- li.dataset.epId = epId;
- li.innerHTML = `
- ⠿
-
-
${escapeHtml(item['episode__title'])}
-
- ${escapeHtml(item['episode__feed__title'])}
- ${dur !== '0:00' ? `${escapeHtml(dur)} ` : ''}
-
- ${progressPct > 0 ? `
` : ''}
-
-
- ▶
- ⬇
- ✕
-
- `;
- li.addEventListener('dragstart', queueDragStart);
- li.addEventListener('dragover', queueDragOver);
- li.addEventListener('drop', queueDrop);
- li.addEventListener('dragend', queueDragEnd);
- ol.appendChild(li);
- });
- } catch (e) {
- ol.innerHTML = 'Failed to load queue. ';
- }
-}
-
-function queueDragStart(e) {
- _dragSrcEl = this;
- e.dataTransfer.effectAllowed = 'move';
- this.classList.add('dragging');
-}
-
-function queueDragOver(e) {
- e.preventDefault();
- const ol = document.getElementById('podcast-queue-ol');
- const dragging = ol.querySelector('.dragging');
- if (!dragging || dragging === this) return;
- const rect = this.getBoundingClientRect();
- ol.insertBefore(dragging, e.clientY < rect.top + rect.height / 2 ? this : this.nextSibling);
-}
-
-function queueDrop(e) { e.preventDefault(); }
-
-function queueDragEnd() {
- this.classList.remove('dragging');
- _dragSrcEl = null;
- const ol = document.getElementById('podcast-queue-ol');
- const newOrder = Array.from(ol.querySelectorAll('li[data-ep-id]'))
- .map(li => parseInt(li.dataset.epId, 10));
- fetch('/podcasts/queue/reorder/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({order: newOrder}),
- }).catch(() => {});
-}
-
-async function queueAddEpisode(id) {
- try {
- await fetch('/podcasts/queue/add/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: id}),
- });
- } catch (e) {}
-}
-
-async function queueRemoveEpisode(id) {
- try {
- await fetch('/podcasts/queue/remove/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: id}),
- });
- if (podcastCurrentView === 'queue') loadAndRenderQueue();
- } catch (e) {}
-}
-
-async function toggleMarkPlayed(id, btn) {
- const ep = podcastEpCache[id];
- const current = ep ? ep.played : btn.textContent === '✓';
- const newPlayed = !current;
- try {
- const res = await fetch('/podcasts/progress/mark-played/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({episode_id: id, played: newPlayed}),
- });
- if (res.ok) {
- if (ep) ep.played = newPlayed;
- btn.textContent = newPlayed ? '✓' : '○';
- const item = document.getElementById(`episode-item-${id}`);
- if (item) item.classList.toggle('episode-played', newPlayed);
- }
- } catch (e) {}
-}
-
-async function refreshFeed(feedId) {
- try {
- const res = await fetch('/podcasts/feeds/refresh/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({feed_id: feedId}),
- });
- const data = await res.json();
- if (data.ok && podcastCurrentFeedId === feedId) {
- openFeed(feedId);
- }
- } catch (e) {}
-}
-
-async function refreshOpenFeed(btn) {
- if (!podcastCurrentFeedId) return;
- if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
- try {
- const res = await fetch('/podcasts/feeds/refresh/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({feed_id: podcastCurrentFeedId}),
- });
- const data = await res.json();
- if (data.ok) {
- await openFeed(podcastCurrentFeedId);
- // openFeed re-renders the header, btn reference is stale — nothing to restore
- return;
- }
- } catch (e) {}
- if (btn) { btn.disabled = false; btn.textContent = '↻ Refresh'; }
-}
-
-async function refreshAllFeedsBtn(btn) {
- // One request: the server fetches every feed itself (in parallel, each
- // feed address once) and the lists are then loaded once.
- if (btn) { btn.disabled = true; btn.textContent = '↻ …'; }
- try {
- await fetch('/podcasts/feeds/refresh-all/', {
- method: 'POST',
- headers: {'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken()},
- body: JSON.stringify({max_age: 60}),
- });
- } catch (e) {}
- await reloadPodcastViews();
- if (btn) { btn.disabled = false; btn.textContent = '↻ All'; }
-}
-
-async function reloadPodcastViews() {
- await loadFeedList();
- if (podcastCurrentView === 'feeds') renderFeedList();
- if (podcastCurrentView === 'inbox') loadAndRenderInbox();
- if (podcastCurrentView === 'episodes' && podcastCurrentFeedId) openFeed(podcastCurrentFeedId);
-}
-
-async function refreshAllFeeds() {
- // The server refreshes all feeds every hour; just pick up what it found.
- if (!IS_AUTHENTICATED || !podcastFeeds.length) return;
- await reloadPodcastViews();
-}
-
-async function removeFeed(feedId) {
- if (!await customConfirm('Remove this podcast?', { danger: true })) return;
- try {
- await fetch(`/podcasts/feeds/${feedId}/remove/`, {
- method: 'POST',
- headers: {'X-CSRFToken': getCsrfToken()},
- });
- podcastFeeds = podcastFeeds.filter(f => f.id !== feedId);
- renderFeedList();
- } catch (e) {}
-}
-
-async function importOPML(input) {
- const file = input.files[0];
- if (!file) return;
- const statusEl = $('opml-status');
- if (statusEl) statusEl.textContent = 'Importing…';
-
- const form = new FormData();
- form.append('file', file);
- form.append('csrfmiddlewaretoken', getCsrfToken());
-
- try {
- const res = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form});
- const data = await res.json();
- if (data.ok) {
- if (statusEl) statusEl.textContent = `✓ ${data.added} added, ${data.skipped} already subscribed`;
- await loadFeedList();
- renderFeedList();
- } else {
- if (statusEl) statusEl.textContent = 'Error: ' + (data.error || 'unknown');
- }
- } catch (e) {
- if (statusEl) statusEl.textContent = 'Import failed.';
- }
- input.value = '';
-}
-
-async function downloadEpisode(url, title, btn) {
- if (!('caches' in window)) {
- await customAlert('Cache API not supported in this browser.');
- return;
- }
-
- const origText = btn ? btn.textContent : '⬇';
- if (btn) { btn.textContent = '…'; btn.disabled = true; }
-
- try {
- const cache = await caches.open('diora-podcast-v1');
- const existing = await cache.match(url);
- if (existing) {
- if (btn) { btn.textContent = '✓'; btn.disabled = false; }
- return;
- }
- // Use no-cors so cross-origin audio URLs don't block the fetch
- const resp = await fetch(url, {mode: 'no-cors'});
- await cache.put(url, resp);
- if (btn) { btn.textContent = '✓'; btn.disabled = false; }
- } catch (e) {
- if (btn) { btn.textContent = origText; btn.disabled = false; }
- await customAlert('Download failed: ' + e.message);
- }
-}
-
// ---------------------------------------------------------------------------
// Sidebar
// ---------------------------------------------------------------------------
@@ -2369,23 +1331,6 @@ function sanitizeSidebarHtml(html) {
return div.innerHTML;
}
-function openEpisodeSidebar(id) {
- const ep = podcastEpCache[id];
- if (!ep) return;
- openSidebar(ep.title, ep.description || '');
-}
-
-function formatDuration(seconds) {
- if (!seconds || seconds <= 0) return '0:00';
- const h = Math.floor(seconds / 3600);
- const m = Math.floor((seconds % 3600) / 60);
- const s = seconds % 60;
- if (h > 0) {
- return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
- }
- return `${m}:${String(s).padStart(2, '0')}`;
-}
-
// ---------------------------------------------------------------------------
// Service Worker
// ---------------------------------------------------------------------------
@@ -6996,19 +5941,6 @@ function openRadioSidebar() {
// But if JS-rendered saved tab were needed we'd call addSavedRow here.
}
- // Seed podcast feeds from server context
- if (typeof INITIAL_PODCAST_FEEDS !== 'undefined' && Array.isArray(INITIAL_PODCAST_FEEDS)) {
- podcastFeeds = INITIAL_PODCAST_FEEDS;
- }
-
- // Wire seek slider
- const seekSlider = $('seek-slider');
- if (seekSlider) {
- seekSlider.addEventListener('input', function () {
- if (podcastMode) audio.currentTime = parseInt(this.value, 10);
- });
- }
-
// Restore persisted volume, fall back to slider default
const volSlider = $('volume');
if (volSlider) {
@@ -7083,11 +6015,6 @@ function openRadioSidebar() {
// Show banner immediately if already offline at startup
if (!navigator.onLine) showOfflineBanner(true);
- // Hourly background feed refresh (only when authenticated)
- if (IS_AUTHENTICATED) {
- setInterval(refreshAllFeeds, 60 * 60 * 1000);
- }
-
// Footnote hover (desktop) + tap (mobile) via event delegation
const _readerContentEl = $('reader-content');
if (_readerContentEl) {
diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js
new file mode 100644
index 0000000..491ce27
--- /dev/null
+++ b/static/js/podcasts-web.js
@@ -0,0 +1,1069 @@
+/**
+ * Podcasts in the browser: the phone app's podcast page on top of a browser
+ * version of what the app does in Kotlin.
+ *
+ * The page itself is diora-android's web-native/static/native-podcast.js,
+ * copied unchanged to /static/podcasts/ by tools/sync_podcast_ui.py. It talks
+ * to its host only through a small API (window.DioraNative in the app,
+ * window.DioraPodcastHost here) and hears back through window.__dioraPodcasts,
+ * __dioraPodcastsRefreshed and __dioraPlayer. This file is that host:
+ *
+ * - the state (feeds, queue, inbox, settings) from GET /podcasts/state/,
+ * kept in localStorage for a fast, offline-capable start (Podcasts.kt);
+ * - every change applied to that copy at once and sent to the server in
+ * order through an outbox that survives reloads and going offline;
+ * - a player of its own (not app.js's radio element) whose playlist is the
+ * queue, with the app's bookkeeping (RadioService.EpisodeTracker): resume,
+ * intro/outro, progress every 10 s and on pause, "played" near the end,
+ * moving on by itself, rewinding a little after a pause, per-podcast
+ * speed and volume, and the sleep timer (SleepTimer.kt);
+ * - lock-screen and media-key controls through navigator.mediaSession;
+ * - this device's playback settings in localStorage (PlaybackPrefs.kt).
+ *
+ * Radio and podcasts never play at the same time: starting an episode stops
+ * the radio (app.js stopPlayback), starting a station puts the episode away.
+ *
+ * In the app (and the desktop app) window.DioraNative exists and this file
+ * does nothing. Written without ?. and ?? so old WebViews can still parse it.
+ */
+(function () {
+ 'use strict';
+ if (window.DioraNative) return;
+ if (typeof IS_AUTHENTICATED === 'undefined' || !IS_AUTHENTICATED) return;
+ if (!document.getElementById('tab-podcasts')) return;
+
+ const UI_SCRIPT = '/static/podcasts/native-podcast.js';
+ const UI_STYLES = ['/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css'];
+
+ // ---- storage ------------------------------------------------------------------
+
+ const USER = String(window.USER_ID || '');
+
+ function load(name, fallback) {
+ try {
+ const v = localStorage.getItem('dpw_' + name + '_' + USER);
+ return v ? JSON.parse(v) : fallback;
+ } catch (e) {
+ return fallback;
+ }
+ }
+
+ function store(name, value) {
+ try { localStorage.setItem('dpw_' + name + '_' + USER, JSON.stringify(value)); } catch (e) {}
+ }
+
+ function parse(s, fallback) {
+ try { return typeof s === 'string' ? JSON.parse(s) : s; } catch (e) { return fallback; }
+ }
+
+ function csrf() {
+ return typeof getCsrfToken === 'function' ? getCsrfToken() : '';
+ }
+
+ // ---- state and outbox (Podcasts.kt) ----------------------------------------------
+
+ const POSITIONS = ['back', 'front', 'after_current'];
+ const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed'];
+ const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume',
+ 'new_episodes', 'include_words', 'exclude_words', 'min_duration'];
+
+ function empty() {
+ return {feeds: [], queue: [], inbox: [], settings: {enqueue_position: 'back', auto_queue_position: 'front'}};
+ }
+
+ let state = load('state', null) || empty();
+ let outbox = load('outbox', []);
+ let lastRefresh = 0;
+
+ function save() {
+ store('state', state);
+ store('outbox', outbox);
+ }
+
+ function changed() {
+ if (window.__dioraPodcasts) window.__dioraPodcasts(JSON.stringify(state));
+ }
+
+ function edit(block) {
+ block(state);
+ save();
+ changed();
+ }
+
+ function queueOp(path, body, key) {
+ if (key) outbox = outbox.filter(op => op.key !== key);
+ const op = {path, body};
+ if (key) op.key = key;
+ outbox.push(op);
+ }
+
+ let flushing = null;
+
+ // Send queued changes in order. False when the server can't be reached.
+ function flush() {
+ if (flushing) return flushing;
+ flushing = sendAll().then(ok => { flushing = null; return ok; }, () => { flushing = null; return false; });
+ return flushing;
+ }
+
+ async function sendAll() {
+ while (outbox.length) {
+ const op = outbox[0];
+ let r;
+ try {
+ r = await fetch(op.path, {
+ method: 'POST',
+ credentials: 'same-origin',
+ headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
+ body: JSON.stringify(op.body),
+ });
+ } catch (e) {
+ return false;
+ }
+ if (r.status === 401 || r.status >= 500) return false;
+ // Another 4xx: the server refuses this change (episode gone …); drop it
+ // rather than block everything behind it.
+ const i = outbox.indexOf(op);
+ if (i >= 0) outbox.splice(i, 1);
+ save();
+ }
+ return true;
+ }
+
+ // Fetch the state in one request (with fetchFeeds the server fetches all
+ // feeds first). Changes not sent yet go first; while some are still
+ // pending, the local copy stays.
+ async function refresh(fetchFeeds) {
+ try {
+ if (fetchFeeds) {
+ if (!(await flush())) return false;
+ await fetch('/podcasts/feeds/refresh-all/', {
+ method: 'POST', credentials: 'same-origin',
+ headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf()},
+ body: '{"max_age":60}',
+ });
+ }
+ // Progress is saved every few seconds while an episode plays; try again
+ // when it slipped in while the state was on its way.
+ for (let attempt = 0; attempt < 3; attempt++) {
+ if (!(await flush())) return false;
+ const r = await fetch('/podcasts/state/?inbox_limit=200', {credentials: 'same-origin', cache: 'no-store'});
+ if (r.status !== 200) return false;
+ const fresh = await r.json();
+ if (outbox.length) continue;
+ state = fresh;
+ lastRefresh = Date.now();
+ save();
+ changed();
+ return true;
+ }
+ return false;
+ } catch (e) {
+ return false;
+ }
+ }
+
+ const queueOf = s => s.queue || (s.queue = []);
+ const queueIds = s => queueOf(s).map(q => q.episode__id);
+
+ function entry(id) {
+ return queueOf(state).find(q => q.episode__id === id) || null;
+ }
+
+ function feed(id) {
+ return (state.feeds || []).find(f => f.id === id) || null;
+ }
+
+ // The queue order a "keep sorted" rule gives (Podcasts.keepSortedOrder; the server sorts the same way).
+ function keepSortedOrder(entries, rule) {
+ const date = o => (o.episode__pub_date ? String(o.episode__pub_date) : null);
+ const left = o => (o.episode__duration_seconds || 0) - (o.position_seconds || 0);
+ const cmpStr = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
+ const byDate = desc => (a, b) => {
+ const da = date(a), db = date(b);
+ if (da == null && db == null) return 0;
+ if (da == null) return 1;
+ if (db == null) return -1;
+ return desc ? cmpStr(db, da) : cmpStr(da, db);
+ };
+ let cmp;
+ if (rule === 'new') cmp = byDate(true);
+ else if (rule === 'old') cmp = byDate(false);
+ else if (rule === 'short') cmp = (a, b) => left(a) - left(b);
+ else if (rule === 'long') cmp = (a, b) => left(b) - left(a);
+ else if (rule === 'feed') {
+ cmp = (a, b) => cmpStr(String(a.episode__feed__title || '').toLowerCase(), String(b.episode__feed__title || '').toLowerCase())
+ || byDate(true)(a, b);
+ } else return entries.map(o => o.episode__id);
+ return entries.slice().sort(cmp).map(o => o.episode__id); // Array.sort is stable
+ }
+
+ function setQueueOrder(s, order) {
+ const byId = new Map(queueOf(s).map(q => [q.episode__id, q]));
+ const rule = (s.settings && s.settings.keep_sorted) || '';
+ const final = rule ? keepSortedOrder(order.map(id => byId.get(id)).filter(Boolean), rule) : order;
+ s.queue = final.map(id => byId.get(id)).filter(Boolean);
+ s.queue.forEach((q, i) => { q.position = i; });
+ }
+
+ // An inbox/feed episode as a queue entry (the server's queue/ field names).
+ function asQueueEntry(ep) {
+ if (ep.episode__id != null) return ep;
+ return {
+ episode__id: ep.id,
+ episode__title: ep.title || '',
+ episode__audio_url: ep.audio_url || '',
+ episode__duration_seconds: ep.duration_seconds || 0,
+ episode__artwork_url: ep.artwork_url || '',
+ episode__pub_date: ep.pub_date || null,
+ episode__feed__id: ep.feed__id != null ? ep.feed__id : ep.feed_id,
+ episode__feed__title: ep.feed__title || ep.feed_title || '',
+ episode__feed__artwork_url: ep.feed__artwork_url || '',
+ position_seconds: ep.position_seconds || 0,
+ };
+ }
+
+ function removeFromInbox(s, ids) {
+ s.inbox = (s.inbox || []).filter(o => !ids.has(o.id));
+ }
+
+ function markInQueue(s, ids, inQueue) {
+ for (const o of s.inbox || []) if (ids.has(o.id)) o.in_queue = inQueue;
+ }
+
+ function forEpisode(s, id, block) {
+ for (const o of queueOf(s)) if (o.episode__id === id) block(o);
+ for (const o of s.inbox || []) if (o.id === id) block(o);
+ }
+
+ function enqueue(episodes, where, currentId) {
+ if (!episodes.length) return;
+ const pos = POSITIONS.indexOf(where) >= 0 ? where : (state.settings && state.settings.enqueue_position) || 'back';
+ edit(s => {
+ const order = queueIds(s);
+ const seen = new Set(order);
+ const fresh = [];
+ for (const e of episodes.map(asQueueEntry)) {
+ if (!seen.has(e.episode__id)) { seen.add(e.episode__id); fresh.push(e); }
+ }
+ if (!fresh.length) return;
+ for (const e of fresh) queueOf(s).push(e);
+ let at = order.length;
+ if (pos === 'front') at = 0;
+ else if (pos === 'after_current') {
+ const i = currentId ? order.indexOf(currentId) : -1;
+ at = i >= 0 ? i + 1 : 0;
+ }
+ const ids = fresh.map(e => e.episode__id);
+ setQueueOrder(s, order.slice(0, at).concat(ids, order.slice(at)));
+ removeFromInbox(s, new Set(ids)); // queued means no longer new
+ const body = {episode_ids: ids, where: pos};
+ if (currentId) body.current_episode_id = currentId;
+ queueOp('/podcasts/queue/add/', body);
+ });
+ }
+
+ function dequeueAll(ids) {
+ edit(s => {
+ const gone = new Set(ids);
+ setQueueOrder(s, queueIds(s).filter(id => !gone.has(id)));
+ markInQueue(s, gone, false);
+ for (const id of ids) queueOp('/podcasts/queue/remove/', {episode_id: id});
+ });
+ }
+
+ function reorder(order) {
+ edit(s => {
+ const now = queueIds(s);
+ const wanted = [];
+ for (const id of order) if (now.indexOf(id) >= 0 && wanted.indexOf(id) < 0) wanted.push(id);
+ const final = wanted.concat(now.filter(id => wanted.indexOf(id) < 0));
+ if (final.join() === now.join()) return;
+ setQueueOrder(s, final);
+ queueOp('/podcasts/queue/reorder/', {order: queueIds(s)});
+ });
+ }
+
+ function move(id, index) {
+ edit(s => {
+ const order = queueIds(s);
+ const at = order.indexOf(id);
+ if (at < 0) return;
+ order.splice(at, 1);
+ order.splice(Math.max(0, Math.min(index, order.length)), 0, id);
+ setQueueOrder(s, order);
+ queueOp('/podcasts/queue/reorder/', {order});
+ });
+ }
+
+ function saveProgress(id, seconds) {
+ forEpisode(state, id, o => { o.position_seconds = seconds; });
+ if (seconds > 0) removeFromInbox(state, new Set([id])); // started: no longer new
+ queueOp('/podcasts/progress/save/', {episode_id: id, position_seconds: seconds}, 'progress:' + id);
+ save();
+ // No re-render: progress changes every few seconds while playing.
+ }
+
+ // Finished or marked by hand: played and out of the inbox; out of the queue
+ // too unless leaveQueue is false (the user's setting).
+ function markPlayedAll(ids, played, leaveQueue) {
+ edit(s => {
+ for (const id of ids) queueOp('/podcasts/progress/mark-played/', {episode_id: id, played});
+ if (!played) return;
+ const set = new Set(ids);
+ removeFromInbox(s, set);
+ for (const id of ids) forEpisode(s, id, o => { o.position_seconds = 0; });
+ if (leaveQueue) {
+ const inQueue = queueIds(s).filter(id => set.has(id));
+ if (inQueue.length) {
+ setQueueOrder(s, queueIds(s).filter(id => !set.has(id)));
+ for (const id of inQueue) queueOp('/podcasts/queue/remove/', {episode_id: id});
+ }
+ }
+ });
+ }
+
+ function dismiss(ids) {
+ edit(s => {
+ removeFromInbox(s, new Set(ids));
+ queueOp('/podcasts/progress/dismiss/', {episode_ids: ids});
+ });
+ }
+
+ function setSettings(changes) {
+ edit(s => {
+ const settings = s.settings || (s.settings = {});
+ const body = {};
+ for (const name of ['enqueue_position', 'auto_queue_position']) {
+ if (POSITIONS.indexOf(changes[name]) >= 0) settings[name] = body[name] = changes[name];
+ }
+ if ('keep_sorted' in changes && SORT_RULES.indexOf(changes.keep_sorted) >= 0) {
+ settings.keep_sorted = body.keep_sorted = changes.keep_sorted;
+ if (changes.keep_sorted) setQueueOrder(s, queueIds(s)); // sorts at once
+ }
+ if (Object.keys(body).length) queueOp('/podcasts/settings/', body);
+ });
+ }
+
+ // A podcast's own settings, kept on the server so every device has them.
+ function setFeedSettings(feedId, changes) {
+ edit(s => {
+ const f = (s.feeds || []).find(x => x.id === feedId);
+ if (!f) return;
+ const body = {};
+ for (const key of Object.keys(changes)) {
+ if (FEED_SETTINGS.indexOf(key) < 0) continue;
+ f[key] = body[key] = changes[key];
+ }
+ if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue';
+ if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body);
+ });
+ if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); }
+ }
+
+ function setAutoQueue(feedId, on) {
+ edit(s => {
+ const f = (s.feeds || []).find(x => x.id === feedId);
+ if (f) f.auto_queue = on;
+ queueOp('/podcasts/feeds/' + feedId + '/set-auto-queue/', {auto_queue: on});
+ });
+ }
+
+ // ---- this device's playback settings (PlaybackPrefs.kt) --------------------------
+
+ const CHOICES = {
+ skip_back: [5, 10, 15, 30],
+ skip_forward: [10, 15, 30, 45, 60],
+ smart_played: [0, 15, 30, 60, 120, 300, 600],
+ };
+ const prefs = Object.assign({speed: 1, skip_back: 15, skip_forward: 30, remove_played: true, smart_played: 30,
+ sleep_last: 15, sleep_auto: false}, load('prefs', {}));
+
+ function setPref(name, value) {
+ if (name === 'speed') {
+ if (!(value >= 0.5 && value <= 3)) return;
+ prefs.speed = value;
+ } else if (CHOICES[name]) {
+ if (CHOICES[name].indexOf(Math.round(value)) < 0) return;
+ prefs[name] = Math.round(value);
+ } else if (name === 'remove_played') {
+ prefs.remove_played = !!value;
+ } else {
+ return; // skip_silence, headset_next: nothing a browser can do
+ }
+ store('prefs', prefs);
+ }
+
+ // What the page shows as settings. No skip_silence / headset_next: the page leaves those out.
+ function prefsJson() {
+ return {speed: prefs.speed, skip_back: prefs.skip_back, skip_forward: prefs.skip_forward,
+ remove_played: prefs.remove_played, smart_played: prefs.smart_played, feeds: {}};
+ }
+
+ function speedFor(feedId) {
+ const f = feed(feedId);
+ return f && f.playback_speed > 0 ? f.playback_speed : prefs.speed;
+ }
+
+ const hasOwnSpeed = feedId => { const f = feed(feedId); return !!(f && f.playback_speed > 0); };
+ const introMs = feedId => { const f = feed(feedId); return ((f && f.skip_intro) || 0) * 1000; };
+ const outroMs = feedId => { const f = feed(feedId); return ((f && f.skip_outro) || 0) * 1000; };
+ const volumeFor = feedId => { const f = feed(feedId); return f && f.volume > 0 ? f.volume : 1; };
+
+ // The page's per-podcast chips speak the app's local format: {speed, intro, outro, volume}.
+ function feedPrefs(feedId, changes) {
+ const body = {};
+ if ('speed' in changes) body.playback_speed = changes.speed > 0 ? changes.speed : null;
+ if ('intro' in changes) body.skip_intro = Math.round(changes.intro || 0);
+ if ('outro' in changes) body.skip_outro = Math.round(changes.outro || 0);
+ if ('volume' in changes) body.volume = changes.volume > 0 ? changes.volume : 1;
+ setFeedSettings(feedId, body);
+ }
+
+ // ---- sleep timer (SleepTimer.kt): minutes count only while playing ----------------
+
+ const FADE_MS = 10000;
+ const MAX_GAP_MS = 10000;
+
+ const sleep = {
+ leftMs: 0,
+ episodes: 0,
+ lastTick: 0,
+ get active() { return this.leftMs > 0 || this.episodes > 0; },
+ set(minutes, episodes) {
+ this.leftMs = minutes * 60000;
+ this.episodes = minutes > 0 ? 0 : episodes;
+ this.lastTick = 0;
+ if (minutes > 0) { prefs.sleep_last = minutes; store('prefs', prefs); }
+ setFade(1);
+ },
+ extend(minutes) {
+ if (this.leftMs <= 0) { this.set(minutes, 0); return; }
+ this.leftMs += minutes * 60000;
+ setFade(1);
+ },
+ tick(playing) {
+ const now = Date.now();
+ const delta = this.lastTick ? now - this.lastTick : 0;
+ this.lastTick = now;
+ if (this.leftMs <= 0 || !playing) return;
+ // A gap means nothing was ticking (paused, tab asleep): not playing time.
+ if (delta > 0 && delta <= MAX_GAP_MS) this.leftMs -= delta;
+ if (this.leftMs <= 0) {
+ this.leftMs = 0;
+ setFade(1);
+ pause();
+ return;
+ }
+ if (this.leftMs < FADE_MS) setFade(Math.max(this.leftMs / FADE_MS, 0.1));
+ },
+ // An episode finished: true when playing should stop here.
+ episodeFinished() {
+ if (this.episodes <= 0) return false;
+ this.episodes--;
+ return this.episodes === 0;
+ },
+ queueEnded() { this.episodes = 0; },
+ // An episode starts playing: at night, the timer may switch itself on.
+ maybeAutoEnable() {
+ if (this.active || !prefs.sleep_auto) return;
+ const hour = new Date().getHours();
+ if (hour >= 22 || hour < 6) this.set(prefs.sleep_last || 15, 0);
+ },
+ json() {
+ return {leftMs: Math.max(0, Math.round(this.leftMs)), episodes: this.episodes, auto: !!prefs.sleep_auto, last: prefs.sleep_last || 15};
+ },
+ };
+
+ // ---- the player (RadioService.EpisodeTracker) ---------------------------------------
+
+ const audio = new Audio(); // the radio keeps app.js's own element
+ audio.preload = 'auto';
+
+ let cur = null; // queue entry (a copy) of the episode in the player
+ let loaded = false; // its address is set on the audio element
+ let wantPlay = false; // play when ready (what the page shows as playing)
+ let startAt = null; // ms to start from when it gets loaded
+ let lastPos = 0; // ms
+ let lastDur = 0; // ms
+ let lastSaved = 0;
+ let pausedAt = 0;
+ let outroDone = null; // episode whose outro was skipped already
+ let fade = 1; // the sleep timer's fade-out
+ let buffering = false;
+ let error = null;
+ let sessionFor = null; // episode the media session shows
+ let mustReach = null; // ms the first seek after loading should have reached
+
+ const curId = () => (cur ? cur.episode__id : null);
+ const curFeed = () => (cur ? cur.episode__feed__id : null);
+
+ function durMs() {
+ if (loaded && isFinite(audio.duration) && audio.duration > 0) return audio.duration * 1000;
+ return lastDur || ((cur && cur.episode__duration_seconds) || 0) * 1000;
+ }
+
+ function posMs() {
+ if (mustReach != null) return mustReach; // the start place, until it's confirmed
+ if (loaded && audio.readyState > 0) return audio.currentTime * 1000;
+ return startAt != null ? startAt : lastPos;
+ }
+
+ // Where this episode picks up: its place (the queue knows it best), past the podcast's intro.
+ function resumeMs() {
+ const q = cur ? entry(cur.episode__id) : null;
+ const resume = ((q || cur || {}).position_seconds || 0) * 1000;
+ return Math.max(resume > 5000 ? resume : 0, introMs(curFeed()));
+ }
+
+ // Within the last seconds (setting, AntennaPod: 30 s) an episode counts as finished.
+ function almostEnded() {
+ const dur = durMs();
+ return dur > 0 && posMs() >= dur - Math.max(prefs.smart_played * 1000, 1000);
+ }
+
+ function nextEntry() {
+ const q = queueOf(state);
+ const at = q.findIndex(e => e.episode__id === curId());
+ if (at >= 0) return q[at + 1] || null;
+ return q.length && q[0].episode__id !== curId() ? q[0] : null;
+ }
+
+ function saveNow(id, send) {
+ lastSaved = Date.now();
+ saveProgress(id, Math.floor(posMs() / 1000));
+ if (send) flush();
+ }
+
+ // An episode is done: played, out of the queue (setting). True when the sleep timer says stop.
+ function finished(id) {
+ markPlayedAll([id], true, prefs.remove_played);
+ flush();
+ return sleep.episodeFinished();
+ }
+
+ function setFade(f) {
+ fade = f;
+ applyVolume();
+ }
+
+ function applyVolume() {
+ // Louder than 1 needs an audio graph, which episodes from other servers can't feed: 1 at most.
+ audio.volume = Math.max(0, Math.min(1, Math.min(volumeFor(curFeed()), 1) * fade));
+ }
+
+ function applyKind() {
+ const rate = speedFor(curFeed());
+ audio.defaultPlaybackRate = rate;
+ if (audio.playbackRate !== rate) audio.playbackRate = rate;
+ audio.preservesPitch = true;
+ applyVolume();
+ }
+
+ function storePlayer() {
+ store('player', cur ? {entry: cur} : null);
+ }
+
+ // Load the episode's audio, starting where it should.
+ // (startAt is applied once the metadata is there, see 'loadedmetadata'.)
+ function prepare() {
+ startAt = startAt != null ? startAt : resumeMs();
+ lastPos = startAt;
+ loaded = true;
+ error = null;
+ buffering = true;
+ audio.src = cur.episode__audio_url;
+ applyKind();
+ }
+
+ function unload() {
+ loaded = false;
+ mustReach = null;
+ audio.pause();
+ audio.removeAttribute('src');
+ try { audio.load(); } catch (e) {}
+ }
+
+ // Move to another episode (onMediaItemTransition): the one we leave counts as
+ // finished when it ended (auto) or is left within its last seconds; else it keeps its place.
+ function goTo(e, auto, play) {
+ const prev = curId();
+ let stop = false;
+ if (prev != null && prev !== outroDone && (auto || e.episode__id !== prev)) {
+ if (auto || almostEnded()) stop = finished(prev);
+ else saveNow(prev, true);
+ }
+ unload();
+ cur = Object.assign({}, e);
+ startAt = null;
+ pausedAt = 0;
+ buffering = false;
+ error = null;
+ lastDur = 0;
+ lastPos = resumeMs();
+ storePlayer();
+ if (play && !stop) resume();
+ else { wantPlay = false; report(); }
+ }
+
+ // A pause rewinds a little on the way back in, to find the thread again.
+ function catchUp() {
+ if (!pausedAt) return;
+ const paused = Date.now() - pausedAt;
+ pausedAt = 0;
+ mustReach = null;
+ const back = paused > 24 * 3600000 ? 20000 : paused > 3600000 ? 10000 : paused > 60000 ? 3000 : 800;
+ try { audio.currentTime = Math.max(0, audio.currentTime - back / 1000); } catch (e) {}
+ }
+
+ function resume() {
+ if (!cur) return;
+ stopRadio();
+ if (!loaded) prepare();
+ else catchUp();
+ wantPlay = true;
+ applyKind();
+ setSession();
+ const p = audio.play();
+ if (p && p.catch) {
+ p.catch(err => {
+ if (err && err.name === 'AbortError') return; // a newer load or pause came first
+ wantPlay = false;
+ buffering = false;
+ if (err && err.name === 'NotAllowedError') toast('Der Browser hat das Abspielen blockiert – bitte nochmal tippen');
+ report();
+ });
+ }
+ report();
+ }
+
+ function pause() {
+ wantPlay = false;
+ if (!audio.paused) audio.pause();
+ pausedAt = pausedAt || Date.now();
+ report();
+ }
+
+ function seek(ms) {
+ if (!cur) return;
+ ms = Math.max(0, ms);
+ mustReach = null;
+ if (loaded && audio.readyState > 0) {
+ try { audio.currentTime = ms / 1000; } catch (e) {}
+ } else {
+ startAt = ms;
+ }
+ lastPos = ms;
+ report();
+ }
+
+ function skip(forward) {
+ const pos = posMs();
+ if (!forward) { seek(pos - prefs.skip_back * 1000); return; }
+ const to = pos + prefs.skip_forward * 1000;
+ const dur = durMs();
+ seek(dur > 0 ? Math.min(to, dur - 1000) : to);
+ }
+
+ function next() {
+ const n = nextEntry();
+ if (n) goTo(n, false, wantPlay);
+ }
+
+ function playEpisode(id) {
+ if (curId() === id) { resume(); return; }
+ const e = entry(id);
+ if (e) goTo(e, false, true);
+ }
+
+ // The podcast's outro is skipped: the episode counts as finished there.
+ function skipOutro(id) {
+ if (outroDone === id) return;
+ const outro = outroMs(curFeed());
+ const dur = durMs();
+ if (outro <= 0 || dur <= 0 || dur - posMs() > outro) return;
+ outroDone = id;
+ const n = nextEntry(); // before finished() takes this one out of the queue
+ if (finished(id) || !n) pause();
+ else goTo(n, false, true);
+ }
+
+ function onEnded() {
+ const id = curId();
+ if (id == null) return;
+ const n = nextEntry();
+ if (n) { goTo(n, true, true); return; }
+ if (id !== outroDone) finished(id);
+ outroDone = id;
+ sleep.queueEnded();
+ wantPlay = false;
+ report();
+ }
+
+ // A browser that pauses the element while it loads (power saving) may drop
+ // that first seek: check it once the audio can play.
+ const reached = () => {
+ if (mustReach == null || audio.seeking) return;
+ if (Math.abs(audio.currentTime * 1000 - mustReach) > 3000) {
+ try { audio.currentTime = mustReach / 1000; } catch (e) {}
+ }
+ mustReach = null;
+ };
+ audio.addEventListener('canplay', reached);
+
+ audio.addEventListener('playing', () => {
+ reached();
+ buffering = false;
+ if (!wantPlay) wantPlay = true;
+ sleep.maybeAutoEnable();
+ report();
+ });
+ audio.addEventListener('waiting', () => { buffering = true; report(); });
+ audio.addEventListener('canplay', () => { if (buffering && audio.paused) { buffering = false; report(); } });
+ audio.addEventListener('pause', () => {
+ if (!loaded || audio.ended) return;
+ // Paused here, by the lock screen, or by the system (headphones out …).
+ wantPlay = false;
+ pausedAt = pausedAt || Date.now();
+ if (cur) saveNow(cur.episode__id, true);
+ report();
+ });
+ audio.addEventListener('ended', onEnded);
+ audio.addEventListener('loadedmetadata', () => {
+ if (startAt != null) {
+ const at = startAt;
+ startAt = null;
+ if (at > 0) { try { audio.currentTime = at / 1000; } catch (e) {} mustReach = at; }
+ }
+ if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
+ applyKind();
+ report();
+ });
+ audio.addEventListener('error', () => {
+ if (!loaded || !audio.getAttribute('src')) return;
+ error = 'MEDIA_ERR_' + (audio.error ? audio.error.code : 0);
+ wantPlay = false;
+ buffering = false;
+ toast('Diese Folge lässt sich gerade nicht abspielen');
+ report();
+ });
+
+ // Once a second: keep the place, save it every 10 s, skip the outro, run the sleep timer.
+ setInterval(() => {
+ const id = curId();
+ const playing = loaded && !audio.paused && !audio.ended && audio.readyState > 2;
+ if (id != null && loaded && audio.readyState > 0) {
+ lastPos = audio.currentTime * 1000;
+ if (isFinite(audio.duration) && audio.duration > 0) lastDur = audio.duration * 1000;
+ if (playing && Date.now() - lastSaved > 10000) saveNow(id, true);
+ if (playing) skipOutro(id);
+ }
+ sleep.tick(playing);
+ if (playing || sleep.leftMs > 0) report();
+ }, 1000);
+
+ // ---- radio and podcasts take turns ---------------------------------------------------
+
+ function stopRadio() {
+ try {
+ if (typeof isPlaying !== 'undefined' && isPlaying && typeof stopPlayback === 'function') stopPlayback(false);
+ } catch (e) {}
+ }
+
+ // A station starts: the episode keeps its place and leaves the player (the app does the same).
+ function putAway() {
+ if (!cur) return;
+ if (loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
+ wantPlay = false;
+ unload();
+ cur = null;
+ storePlayer();
+ report();
+ if (sessionFor != null && 'mediaSession' in navigator) {
+ // The station sets its own handlers; these two it doesn't know about.
+ try { navigator.mediaSession.setActionHandler('seekto', null); } catch (e) {}
+ try { if (navigator.mediaSession.setPositionState) navigator.mediaSession.setPositionState(); } catch (e) {}
+ sessionFor = null;
+ }
+ }
+
+ if (typeof window.playStation === 'function') {
+ const playStation = window.playStation;
+ window.playStation = function () {
+ putAway();
+ return playStation.apply(this, arguments);
+ };
+ }
+
+ // ---- what the page and the lock screen see ---------------------------------------------
+
+ const decoder = document.createElement('textarea');
+ function plain(s) {
+ if (!s || String(s).indexOf('&') < 0) return s || '';
+ decoder.innerHTML = s;
+ return decoder.value;
+ }
+
+ function playerJson() {
+ const fid = curFeed();
+ const o = {speed: speedFor(fid), podcastSpeed: hasOwnSpeed(fid), sleep: sleep.json()};
+ if (!cur) { o.kind = null; return o; }
+ o.kind = 'podcast';
+ o.episodeId = cur.episode__id;
+ o.feedId = fid || 0;
+ o.title = cur.episode__title || '';
+ o.feed = cur.episode__feed__title || '';
+ o.artwork = cur.episode__artwork_url || cur.episode__feed__artwork_url || '';
+ o.pos = Math.round(posMs());
+ o.dur = Math.round(durMs());
+ o.playing = wantPlay;
+ o.buffering = buffering && wantPlay;
+ if (error) o.error = error;
+ return o;
+ }
+
+ function report() {
+ if (window.__dioraPlayer) window.__dioraPlayer(playerJson());
+ if (!('mediaSession' in navigator) || !cur || sessionFor !== cur.episode__id) return;
+ const ms = navigator.mediaSession;
+ ms.playbackState = wantPlay ? 'playing' : 'paused';
+ const dur = durMs() / 1000;
+ if (dur > 0 && ms.setPositionState) {
+ try {
+ ms.setPositionState({duration: dur, playbackRate: audio.playbackRate || 1, position: Math.min(posMs() / 1000, dur)});
+ } catch (e) {}
+ }
+ }
+
+ // Lock screen, notification and media keys: this episode, skip back and forward, next.
+ function setSession() {
+ if (!('mediaSession' in navigator) || !cur) return;
+ const ms = navigator.mediaSession;
+ const art = (cur.episode__artwork_url || cur.episode__feed__artwork_url || '').replace(/^http:/, 'https:');
+ try {
+ ms.metadata = new MediaMetadata({
+ title: plain(cur.episode__title),
+ artist: plain(cur.episode__feed__title),
+ album: plain(cur.episode__feed__title),
+ artwork: art ? [{src: art}] : [],
+ });
+ } catch (e) {}
+ const on = (name, fn) => { try { ms.setActionHandler(name, fn); } catch (e) {} };
+ on('play', () => resume());
+ on('pause', () => pause());
+ on('stop', () => pause());
+ on('seekbackward', () => skip(false));
+ on('seekforward', () => skip(true));
+ on('seekto', d => { if (d && d.seekTime != null) seek(d.seekTime * 1000); });
+ on('nexttrack', () => next());
+ on('previoustrack', null);
+ sessionFor = cur.episode__id;
+ }
+
+ let toastTimer = 0;
+ function toast(text) {
+ let t = document.getElementById('nui-radio-toast');
+ if (!t) {
+ t = document.createElement('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);
+ }
+
+ // ---- the host API (Bridge.kt's podcast part) -------------------------------------------
+
+ const ids = json => (parse(json, []) || []).map(Number);
+ const sent = fn => function () { fn.apply(null, arguments); flush(); };
+
+ window.DioraPodcastHost = {
+ podcastState: () => JSON.stringify(state),
+ podcastRefresh(fetchFeeds) {
+ refresh(!!fetchFeeds).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
+ },
+ podcastFeedRemoved() {
+ refresh(false).then(ok => { if (window.__dioraPodcastsRefreshed) window.__dioraPodcastsRefreshed(ok); });
+ },
+ podcastFavoriteChanged() {},
+ podcastEnqueue: sent((json, where, currentId) => enqueue(parse(json, []) || [], where || null, currentId > 0 ? currentId : null)),
+ podcastDequeue: sent(id => dequeueAll([id])),
+ podcastDequeueAll: sent(json => dequeueAll(ids(json))),
+ podcastMove: sent((id, index) => move(id, index)),
+ podcastReorder: sent(json => reorder(ids(json))),
+ podcastMarkPlayed(id, played) {
+ markPlayedAll([id], !!played, prefs.remove_played);
+ // "Unplayed" brings it back into Neu on the server; fetch that.
+ if (played) flush(); else refresh(false);
+ },
+ podcastMarkPlayedAll: sent((json, played) => markPlayedAll(ids(json), !!played, prefs.remove_played)),
+ podcastDismiss: sent(json => dismiss(ids(json))),
+ podcastSettings: sent(json => setSettings(parse(json, {}) || {})),
+ podcastAutoQueue: sent((feedId, on) => setAutoQueue(feedId, !!on)),
+ podcastFeedSettings: sent((feedId, json) => setFeedSettings(feedId, parse(json, {}) || {})),
+ podcastFeedPrefs: sent((feedId, json) => feedPrefs(feedId, parse(json, {}) || {})),
+ podcastPrefs: () => JSON.stringify(prefsJson()),
+ podcastSetPref: (name, value) => setPref(name, value ? 1 : 0),
+ podcastSetPrefValue: (name, value) => setPref(name, Number(value)),
+
+ podcastPlay: id => playEpisode(id),
+ // Play an episode; one that isn't queued yet is queued first (where the setting says).
+ podcastPlayEpisode(json, currentId) {
+ const ep = parse(json, {}) || {};
+ const id = ep.episode__id != null ? ep.episode__id : ep.id;
+ if (!entry(id)) enqueue([ep], null, currentId > 0 ? currentId : null);
+ playEpisode(id);
+ flush();
+ },
+ podcastNext: () => next(),
+ podcastToggle: () => (wantPlay ? pause() : resume()),
+ podcastSeek: ms => seek(Number(ms)),
+ podcastSkip: forward => skip(!!forward),
+ podcastSpeed(speed) {
+ setPref('speed', Number(speed));
+ applyKind();
+ setTimeout(report, 100);
+ },
+ podcastSpeedForPodcast(speed) {
+ const fid = curFeed();
+ if (fid) { setFeedSettings(fid, {playback_speed: speed > 0 ? Number(speed) : null}); flush(); }
+ applyKind();
+ setTimeout(report, 100);
+ },
+ playerState: () => JSON.stringify(playerJson()),
+ podcastSleep(minutes) {
+ if (minutes > 0) sleep.set(minutes, 0);
+ else if (minutes < 0) sleep.set(0, -minutes);
+ else sleep.set(0, 0);
+ report();
+ },
+ podcastSleepExtend(minutes) { sleep.extend(minutes); report(); },
+ podcastSleepAuto(on) { prefs.sleep_auto = !!on; store('prefs', prefs); },
+ isEinkDevice: () => false,
+ // No podcastDownloads: the browser streams; the page hides everything about downloads.
+ };
+
+ // ---- the bottom sheet (native-ui.js's openSheet) ------------------------------------------
+
+ if (!window.DioraUI) {
+ let sheetEl = null;
+ const closeSheet = () => {
+ if (!sheetEl) return false;
+ sheetEl.remove();
+ sheetEl = null;
+ return true;
+ };
+ // items: {icon, label, hint, action, danger, active} | {custom: el} | falsy (skipped)
+ const openSheet = (title, items) => {
+ closeSheet();
+ const wrap = document.createElement('div');
+ wrap.id = 'nui-sheet';
+ wrap.innerHTML = '
';
+ const panel = wrap.querySelector('.nui-panel');
+ if (title) {
+ const t = document.createElement('div');
+ t.className = 'nui-sheet-title';
+ t.textContent = title;
+ panel.appendChild(t);
+ }
+ for (const it of items) {
+ if (!it) continue;
+ if (it.custom) { panel.appendChild(it.custom); continue; }
+ const b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'nui-row' + (it.danger ? ' nui-danger' : '') + (it.active ? ' nui-active' : '');
+ b.innerHTML = ' ';
+ b.querySelector('.nui-row-icon').textContent = it.icon || '';
+ b.querySelector('.nui-row-label').textContent = it.label;
+ if (it.hint) {
+ const h = document.createElement('span');
+ h.className = 'nui-row-hint';
+ h.textContent = it.hint;
+ b.querySelector('.nui-row-text').appendChild(h);
+ }
+ b.addEventListener('click', () => { closeSheet(); it.action(); });
+ panel.appendChild(b);
+ }
+ wrap.addEventListener('click', e => {
+ e.stopPropagation();
+ if (e.target.classList.contains('nui-backdrop')) closeSheet();
+ });
+ document.body.appendChild(wrap);
+ sheetEl = wrap;
+ const first = panel.querySelector('button');
+ if (first) first.focus({preventScroll: true});
+ };
+ window.DioraUI = {openSheet, closeSheet};
+ }
+
+ // ---- page set-up -------------------------------------------------------------------------
+
+ document.documentElement.classList.add('dpw');
+
+ for (const href of UI_STYLES) {
+ const link = document.createElement('link');
+ link.rel = 'stylesheet';
+ link.href = href;
+ document.head.appendChild(link);
+ }
+
+ // The bottom dock the page puts its mini player in (native-radio.js builds it in the app).
+ const dock = document.createElement('div');
+ dock.id = 'nui-dock';
+ document.body.appendChild(dock);
+
+ // The dock sits right above the radio bar, whose height changes with the screen.
+ const bar = document.getElementById('now-playing-bar');
+ const placeDock = () => {
+ const h = bar ? bar.getBoundingClientRect().height : 0;
+ document.documentElement.style.setProperty('--dpw-bar-h', Math.round(h) + 'px');
+ };
+ placeDock();
+ if (bar && window.ResizeObserver) new ResizeObserver(placeDock).observe(bar);
+ window.addEventListener('resize', placeDock);
+
+ // Escape closes what the podcast page has open: a sheet, the player, notes,
+ // a podcast, choosing several (its back handler) — never the tab itself.
+ document.addEventListener('keydown', e => {
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
+ if (window.DioraUI.closeSheet && window.DioraUI.closeSheet()) return;
+ const podTab = typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts';
+ const open = document.getElementById('nui-pod-player') ||
+ (podTab && document.querySelector('#nui-pod .nui-pod-feedhead, #nui-pod .nui-pod-selbar'));
+ if (open && window.__dioraBack) window.__dioraBack();
+ });
+
+ // The episode that was in the player before the page was left (not loaded until played).
+ const was = load('player', null);
+ if (was && was.entry && was.entry.episode__id != null) {
+ cur = entry(was.entry.episode__id) || was.entry;
+ lastPos = resumeMs();
+ }
+
+ // Keep the place when the page goes away; send what's pending when the connection is back.
+ const leaving = () => {
+ if (cur && loaded && audio.readyState > 0) {
+ saveProgress(cur.episode__id, Math.floor(posMs() / 1000));
+ if (navigator.sendBeacon) {
+ navigator.sendBeacon('/podcasts/progress/save/', JSON.stringify(
+ {episode_id: cur.episode__id, position_seconds: Math.floor(posMs() / 1000)}));
+ }
+ }
+ };
+ window.addEventListener('pagehide', leaving);
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ if (cur && loaded && audio.readyState > 0) saveNow(cur.episode__id, true);
+ } else if (Date.now() - lastRefresh > 5 * 60000) {
+ refresh(false);
+ }
+ });
+ window.addEventListener('online', () => { flush(); });
+
+ // The page (copied from the app) comes last, once all of the above is in place.
+ const script = document.createElement('script');
+ script.src = UI_SCRIPT;
+ document.body.appendChild(script);
+})();
diff --git a/static/js/sw.js b/static/js/sw.js
index b43fd39..641b099 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,11 +2,14 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v51';
-const PODCAST_CACHE = 'diora-podcast-v1';
+const CACHE = 'diora-v52';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
+ '/static/js/podcasts-web.js',
+ '/static/podcasts/native-podcast.js',
+ '/static/podcasts/podcast-ui.css',
+ '/static/podcasts/podcasts-web.css',
'/static/manifest.json',
'/static/fonts/SourceSerif4-normal-latin.woff2',
'/static/fonts/SourceSerif4-normal-latin-ext.woff2',
@@ -25,12 +28,13 @@ self.addEventListener('install', function (event) {
self.skipWaiting();
});
-// Activate: remove stale caches from previous versions
+// Activate: remove stale caches from previous versions (also the old
+// 'diora-podcast-v1' of episodes the web app used to download)
self.addEventListener('activate', function (event) {
event.waitUntil(
caches.keys().then(function (keys) {
return Promise.all(
- keys.filter(function (key) { return key !== CACHE && key !== PODCAST_CACHE; })
+ keys.filter(function (key) { return key !== CACHE; })
.map(function (key) { return caches.delete(key); })
);
}).then(function () {
@@ -68,21 +72,6 @@ self.addEventListener('fetch', function (event) {
return;
}
- // Podcast audio: serve from podcast cache first, then network
- if (event.request.destination === 'audio') {
- event.respondWith(
- caches.open(PODCAST_CACHE).then(function (cache) {
- return cache.match(event.request).then(function (cached) {
- if (cached) return cached;
- return fetch(event.request).then(function (response) {
- return response;
- });
- });
- })
- );
- return;
- }
-
// Cache-first for pre-defined shell assets
const isShell = SHELL.some(function (s) { return url.pathname === s; });
if (isShell) {
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
new file mode 100644
index 0000000..29553b7
--- /dev/null
+++ b/static/podcasts/native-podcast.js
@@ -0,0 +1,1901 @@
+// Copied from diora-android 302c300 2026-10-02: web-native/static/native-podcast.js
+// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
+// and copy again. The browser host is static/js/podcasts-web.js.
+
+// Podcasts for the phone, the AntennaPod way: the queue comes first.
+//
+// The app keeps the podcast state (Podcasts.kt: feeds, queue, inbox and
+// settings, fetched from the server in one request and kept for offline use)
+// and plays it (RadioService: the queue is the playlist, it keeps the place
+// and moves on by itself). This page only shows that state and hands what
+// the user does to DioraNative.podcast*. The web app's own podcast tab stays
+// hidden; its endpoints are the server side of all this.
+//
+// The browser runs this same file (diora-web copies it, tools/sync_podcast_ui.py):
+// there window.DioraPodcastHost (diora-web static/js/podcasts-web.js) plays the
+// part of the app, with the same calls.
+(function () {
+ 'use strict';
+ const N = window.DioraPodcastHost || window.DioraNative;
+ if (!N || !N.podcastState) return;
+ // E-readers have no speaker; they stay a pure book app.
+ if (N.isEinkDevice && N.isEinkDevice()) return;
+
+ document.documentElement.classList.add('nui-podcasts');
+
+ const $id = id => document.getElementById(id);
+ const VIEWS = [
+ {id: 'queue', label: 'Warteschlange'},
+ {id: 'inbox', label: 'Neu'},
+ {id: 'episodes', label: 'Folgen'},
+ {id: 'feeds', label: 'Abos'},
+ {id: 'search', label: 'Suche'},
+ ];
+ const POSITIONS = [
+ {value: 'back', label: 'Ans Ende'},
+ {value: 'front', label: 'An den Anfang'},
+ {value: 'after_current', label: 'Nach der laufenden Folge'},
+ ];
+ const SPEEDS = [0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.5, 1.75, 2];
+
+ function parse(s, fallback) {
+ try { return JSON.parse(s); } catch (e) { return fallback; }
+ }
+
+ let state = parse(N.podcastState(), {});
+ let player = {kind: null};
+ let view = 'queue';
+ let feedView = null; // {feed, episodes} while one podcast is open
+ let searchResults = null;
+ let refreshing = false;
+ let selected = null; // Set of episode ids while choosing several (queue or inbox)
+ let locked = false; // queue locked: no dragging
+ try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {}
+ try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {}
+ if (!VIEWS.some(v => v.id === view)) view = 'queue';
+
+ const queue = () => state.queue || [];
+ const inbox = () => state.inbox || [];
+ const feeds = () => state.feeds || [];
+ const settings = () => state.settings || {};
+ const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
+
+ // ---- small helpers ---------------------------------------------------------------
+
+ function el(tag, cls, text) {
+ const e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (text != null) e.textContent = text;
+ return e;
+ }
+
+ function button(label, title, onClick, cls) {
+ const b = el('button', 'btn-icon nui-icon' + (cls ? ' ' + cls : ''), label);
+ b.type = 'button';
+ b.title = title;
+ b.setAttribute('aria-label', title);
+ b.addEventListener('click', e => { e.stopPropagation(); onClick(e); });
+ return b;
+ }
+
+ function openSheet(title, items) {
+ if (window.DioraUI && DioraUI.openSheet) DioraUI.openSheet(title, items);
+ }
+
+ function clock(seconds) {
+ seconds = Math.max(0, Math.round(seconds || 0));
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ const s = seconds % 60;
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
+ }
+
+ function minutes(seconds) {
+ const m = Math.round((seconds || 0) / 60);
+ if (m < 60) return `${m} min`;
+ return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`;
+ }
+
+ function day(iso) {
+ if (!iso) return '';
+ const d = new Date(iso);
+ if (isNaN(d)) return '';
+ const diff = Math.floor((Date.now() - d) / 86400000);
+ if (diff <= 0) return 'heute';
+ if (diff === 1) return 'gestern';
+ if (diff < 7) return `vor ${diff} Tagen`;
+ return d.toLocaleDateString('de-DE', {day: 'numeric', month: 'short', year: diff > 300 ? 'numeric' : undefined});
+ }
+
+ let toastTimer = 0;
+ function toast(text) {
+ let t = $id('nui-radio-toast');
+ if (!t) {
+ t = el('div');
+ t.id = 'nui-radio-toast';
+ document.body.appendChild(t);
+ }
+ t.textContent = text;
+ t.classList.add('nui-show');
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => t.classList.remove('nui-show'), 2600);
+ }
+
+ // Feed titles sometimes arrive with HTML entities still in them (&).
+ const decoder = document.createElement('textarea');
+ function plain(s) {
+ if (!s || s.indexOf('&') < 0) return s || '';
+ decoder.innerHTML = s;
+ return decoder.value;
+ }
+
+ // Episodes come in two shapes: queue entries (episode__*) and inbox/feed
+ // episodes. One view of both:
+ function ep(o) {
+ const e = shape(o);
+ e.title = plain(e.title);
+ e.feed = plain(e.feed);
+ return e;
+ }
+
+ function shape(o) {
+ if (o.episode__id != null) {
+ return {
+ id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id,
+ art: o.episode__artwork_url || o.episode__feed__artwork_url, duration: o.episode__duration_seconds,
+ pos: o.position_seconds, date: o.episode__pub_date, raw: o,
+ };
+ }
+ return {
+ id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id,
+ art: o.artwork_url || o.feed__artwork_url || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds,
+ pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o,
+ };
+ }
+
+ function art(src, size) {
+ const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : ''));
+ if (src) {
+ const img = el('img');
+ img.loading = 'lazy';
+ img.alt = '';
+ img.src = src.replace(/^http:/, 'https:');
+ img.addEventListener('error', () => img.remove());
+ box.append(img);
+ }
+ return box;
+ }
+
+ function inQueue(id) {
+ return queue().some(q => q.episode__id === id);
+ }
+
+ // ---- actions ---------------------------------------------------------------------------
+
+ function play(e) {
+ if (currentId() === e.id) { openPlayer(); return; }
+ N.podcastPlayEpisode(JSON.stringify(e.raw), currentId());
+ }
+
+ function enqueue(e, where) {
+ N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId());
+ const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position));
+ toast('In der Warteschlange' + (label ? ': ' + label.label.toLowerCase() : ''));
+ }
+
+ // Move within the queue: "next" is right after the episode that plays.
+ function moveTo(e, where) {
+ const ids = queue().map(q => q.episode__id).filter(id => id !== e.id);
+ let index = where === 'top' ? 0 : ids.length;
+ if (where === 'next') {
+ const at = ids.indexOf(currentId());
+ index = at >= 0 ? at + 1 : 0;
+ }
+ N.podcastMove(e.id, index);
+ }
+
+ function markPlayed(e) {
+ N.podcastMarkPlayed(e.id, true);
+ toast('Als gehört markiert');
+ }
+
+ function queueMenu(e) {
+ const playing = currentId() === e.id;
+ openSheet(e.title, [
+ {icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
+ !playing && {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
+ favoriteItem(e),
+ !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
+ {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
+ {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
+ {icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)},
+ ...dlMenuItems(e),
+ {icon: '✕', label: 'Aus der Warteschlange nehmen', danger: true, action: () => N.podcastDequeue(e.id)},
+ ]);
+ }
+
+ function episodeMenu(e) {
+ const queued = inQueue(e.id);
+ openSheet(e.title, [
+ {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
+ {icon: '☰', label: 'Shownotes und Kapitel', action: () => openNotes(e)},
+ favoriteItem(e),
+ !queued && {icon: '⤴', label: 'Als Nächstes', action: () => enqueue(e, 'after_current')},
+ !queued && {icon: '⇈', label: 'An den Anfang der Warteschlange', action: () => enqueue(e, 'front')},
+ !queued && {icon: '⇊', label: 'Ans Ende der Warteschlange', action: () => enqueue(e, 'back')},
+ queued && {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)},
+ !e.played && {icon: '✓', label: 'Als gehört markieren', action: () => markPlayed(e)},
+ e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
+ ...dlMenuItems(e),
+ {icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
+ action: () => N.podcastDismiss(JSON.stringify([e.id]))},
+ ]);
+ }
+
+ function settingsMenu() {
+ const s = settings();
+ const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({
+ icon: s[name] === p.value ? '●' : '○',
+ label: p.label,
+ active: s[name] === p.value,
+ action: () => N.podcastSettings(JSON.stringify({[name]: p.value})),
+ })));
+ const label = v => (POSITIONS.find(p => p.value === v) || POSITIONS[0]).label;
+ const prefs = devicePrefs();
+ // Grouped like AntennaPod's settings; each group opens its own list.
+ const group = (icon, title, hint, items) => ({icon, label: title, hint, action: () => openSheet(title, items())});
+ openSheet('Podcasts', [
+ {icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, die App lädt das Ergebnis',
+ action: () => refresh(true)},
+ group('≡', 'Warteschlange', 'wohin neue Folgen kommen, gehörte entfernen', () => [
+ {icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position),
+ action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')},
+ {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position),
+ action: () => choose('auto_queue_position', 'Neue Folgen von Podcasts mit „Automatisch einreihen“ kommen …')},
+ removePlayedItem(),
+ ]),
+ group('▶', 'Abspielen', ['Sprungweiten', 'skip_silence' in prefs && 'Stille', '„gehört“',
+ 'headset_next' in prefs && 'Kopfhörer'].filter(Boolean).join(', '), () => [
+ skipItem('skip_back', 'Zurückspringen', [5, 10, 15, 30]),
+ skipItem('skip_forward', 'Vorspringen', [10, 15, 30, 45, 60]),
+ silenceItem(),
+ playedItem(),
+ headsetItem(),
+ ]),
+ DL && group('↓', 'Downloads', 'automatisch, Limit, aufräumen', () => dlSettingsItems()),
+ group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
+ {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
+ {icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
+ ]),
+ {icon: '▤', label: 'Hörstatistik', action: openStats},
+ ]);
+ }
+
+ function skipItem(name, label, choices) {
+ if (!N.podcastSetPrefValue) return null;
+ const now = devicePrefs()[name];
+ return {icon: name === 'skip_back' ? '↺' : '↻', label, hint: `${now} Sekunden`,
+ action: () => openSheet(label, choices.map(c => ({
+ icon: c === now ? '●' : '○', label: `${c} Sekunden`, action: () => N.podcastSetPrefValue(name, c),
+ })))};
+ }
+
+ function playedItem() {
+ if (!N.podcastSetPrefValue) return null;
+ const now = devicePrefs().smart_played;
+ if (now == null) return null;
+ const label = v => (v ? `${v >= 60 ? v / 60 + ' min' : v + ' s'} vor dem Ende` : 'erst ganz am Ende');
+ return {icon: '✓', label: 'Als gehört zählen', hint: label(now),
+ action: () => openSheet('Eine Folge zählt als gehört …', [0, 15, 30, 60, 120, 300, 600].map(v => ({
+ icon: v === now ? '●' : '○', label: label(v), action: () => N.podcastSetPrefValue('smart_played', v),
+ })))};
+ }
+
+ // A host without headset buttons or silence skipping (the browser) leaves them out of its prefs.
+ function headsetItem() {
+ if (!N.podcastSetPref || !('headset_next' in devicePrefs())) return null;
+ const next = !!devicePrefs().headset_next;
+ return {icon: '🎧', label: 'Kopfhörer: zweimal tippen', hint: next ? 'nächste Folge' : 'vorspulen (dreimal: zurück)',
+ action: () => openSheet('Kopfhörer zweimal tippen …', [
+ {icon: !next ? '●' : '○', label: 'Vorspulen', hint: 'dreimal tippen springt zurück', action: () => N.podcastSetPref('headset_next', false)},
+ {icon: next ? '●' : '○', label: 'Nächste Folge', action: () => N.podcastSetPref('headset_next', true)},
+ ])};
+ }
+
+ function silenceItem() {
+ if (!N.podcastSetPref || !('skip_silence' in devicePrefs())) return null;
+ const on = !!devicePrefs().skip_silence;
+ return {icon: on ? '☑' : '☐', label: 'Stille überspringen',
+ hint: on ? 'an: Pausen in Folgen werden übersprungen' : 'aus',
+ action: () => N.podcastSetPref('skip_silence', !on)};
+ }
+
+ function devicePrefs() {
+ return parse(N.podcastPrefs ? N.podcastPrefs() : '{}', {});
+ }
+
+ function removePlayedItem() {
+ if (!N.podcastSetPref) return null;
+ const on = devicePrefs().remove_played !== false;
+ return {icon: on ? '☑' : '☐', label: 'Gehörte Folgen aus der Warteschlange nehmen',
+ hint: on ? 'an: fertige Folgen verlassen die Warteschlange' : 'aus: fertige Folgen bleiben stehen',
+ action: () => { N.podcastSetPref('remove_played', !on); toast(on ? 'Gehörte Folgen bleiben jetzt stehen' : 'Gehörte Folgen verlassen jetzt die Warteschlange'); }};
+ }
+
+ // ---- sorting, locking, choosing several ----------------------------------------------
+
+ const SORTS = [
+ {id: 'new', label: 'Neueste zuerst', key: e => -(Date.parse(e.date) || 0)},
+ {id: 'old', label: 'Älteste zuerst', key: e => Date.parse(e.date) || 0},
+ {id: 'short', label: 'Kürzeste zuerst', key: e => Math.max(0, (e.duration || 0) - (e.pos || 0))},
+ {id: 'long', label: 'Längste zuerst', key: e => -Math.max(0, (e.duration || 0) - (e.pos || 0))},
+ {id: 'feed', label: 'Nach Podcast', key: e => (e.feed || '').toLowerCase() + ' ' + String(9e15 - (Date.parse(e.date) || 0))},
+ ];
+
+ // The episode that plays stays on top; everything else is sorted.
+ function sortQueue(sort) {
+ const items = queue().map(ep);
+ const now = items.filter(e => e.id === currentId());
+ const rest = items.filter(e => e.id !== currentId());
+ if (sort === 'shuffle') {
+ for (let i = rest.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [rest[i], rest[j]] = [rest[j], rest[i]];
+ }
+ } else {
+ const s = SORTS.find(x => x.id === sort);
+ rest.sort((a, b) => {
+ const ka = s.key(a), kb = s.key(b);
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
+ });
+ }
+ N.podcastReorder(JSON.stringify(now.concat(rest).map(e => e.id)));
+ }
+
+ const keepSorted = () => settings().keep_sorted || '';
+
+ function sortMenu() {
+ const kept = keepSorted();
+ const keptLabel = kept ? (SORTS.find(x => x.id === kept) || {}).label : '';
+ openSheet('Warteschlange sortieren', SORTS.map(x => ({icon: '⇅', label: x.label, action: () => sortQueue(x.id)}))
+ .concat([{icon: '⤨', label: 'Mischen', action: () => sortQueue('shuffle')}])
+ .concat('keep_sorted' in settings() ? [{icon: kept ? '☑' : '☐', label: 'Immer sortiert halten',
+ hint: kept ? `an: ${keptLabel}` : 'aus: Reihenfolge bestimmst du',
+ action: () => openSheet('Immer sortiert halten', [{id: '', label: 'Aus'}].concat(SORTS).map(x => ({
+ icon: kept === x.id ? '●' : '○', label: x.label, hint: x.id ? 'auch neu eingereihte Folgen' : null,
+ action: () => N.podcastSettings(JSON.stringify({keep_sorted: x.id})),
+ })))}] : []));
+ }
+
+ function toggleLock() {
+ locked = !locked;
+ try { localStorage.setItem('nui_pod_locked', locked ? '1' : '0'); } catch (e) {}
+ toast(locked ? 'Warteschlange gesperrt' : 'Warteschlange entsperrt');
+ render();
+ }
+
+ function startSelecting(id) {
+ selected = new Set(id ? [id] : []);
+ render();
+ }
+
+ function stopSelecting() {
+ if (!selected) return false;
+ selected = null;
+ render();
+ return true;
+ }
+
+ function toggleSelected(id) {
+ if (selected.has(id)) selected.delete(id); else selected.add(id);
+ render();
+ }
+
+ // The bar while choosing: count, all/none, and what to do with the chosen ones.
+ function selectionBar(items, actions) {
+ const bar = el('div', 'nui-pod-selbar');
+ const all = items.length > 0 && items.every(e => selected.has(e.id));
+ bar.append(
+ button('✕', 'Auswahl beenden', stopSelecting),
+ el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : 'Folgen antippen'),
+ button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => {
+ selected = new Set(all ? [] : items.map(e => e.id));
+ render();
+ }),
+ );
+ const acts = el('div', 'nui-pod-selactions');
+ for (const a of actions) {
+ const b = el('button', 'btn btn-sm' + (a.danger ? ' btn-danger' : ''), a.label);
+ b.type = 'button';
+ b.disabled = !selected.size;
+ b.addEventListener('click', () => {
+ const ids = items.filter(e => selected.has(e.id)).map(e => e.id);
+ if (ids.length) a.run(ids);
+ });
+ acts.append(b);
+ }
+ bar.append(acts);
+ return bar;
+ }
+
+ function listTools(buttons) {
+ const t = el('div', 'nui-pod-tools');
+ for (const b of buttons) if (b) t.append(b);
+ return t;
+ }
+
+ function refresh(fetchFeeds) {
+ refreshing = true;
+ render();
+ N.podcastRefresh(!!fetchFeeds);
+ }
+
+ // ---- rendering ---------------------------------------------------------------------------
+
+ let root = null;
+
+ // The page scrolls inside .main-content (app.css), not the window.
+ function scroller() {
+ for (let e = root && root.parentElement; e && e !== document.body; e = e.parentElement) {
+ const o = getComputedStyle(e).overflowY;
+ if (o === 'auto' || o === 'scroll') return e;
+ }
+ return document.scrollingElement || document.documentElement;
+ }
+
+ function scrollTop(y) {
+ const s = scroller();
+ if (y == null) return s.scrollTop;
+ s.scrollTop = y;
+ return y;
+ }
+
+ function build() {
+ const tab = $id('tab-podcasts');
+ if (!tab || $id('nui-pod')) return false;
+ root = el('div');
+ root.id = 'nui-pod';
+ tab.prepend(root);
+ return true;
+ }
+
+ function render() {
+ if (!root) return;
+ const scroll = scrollTop();
+ root.textContent = '';
+
+ const bar = el('div', 'nui-tab-bar');
+ bar.id = 'nui-pod-bar';
+ const text = el('div', 'nui-bar-text');
+ text.append(el('span', 'native-brand', 'Podcasts'));
+ if (refreshing) text.append(el('span', 'muted nui-pod-status', 'Wird abgerufen …'));
+ else if (state.server_time) text.append(el('span', 'muted nui-pod-status', 'Stand ' + day(state.server_time) + ', ' +
+ new Date(state.server_time).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'})));
+ bar.append(text, button('⟳', 'Alle Feeds abrufen', () => refresh(true)), button('⋮', 'Einstellungen', settingsMenu));
+ root.append(bar);
+
+ if (feedView) {
+ renderFeed();
+ } else {
+ const tabs = el('div', 'nui-pod-tabs');
+ for (const v of VIEWS) {
+ const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : ''));
+ b.type = 'button';
+ b.append(el('span', null, v.label));
+ const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inbox().length : 0;
+ if (count) b.append(el('span', 'nui-pod-count', String(count)));
+ b.addEventListener('click', () => {
+ selected = null;
+ view = v.id;
+ try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
+ render();
+ scrollTop(0);
+ });
+ tabs.append(b);
+ }
+ root.append(tabs);
+ if (view === 'queue') renderQueue();
+ else if (view === 'inbox') renderInbox();
+ else if (view === 'episodes') renderEpisodes();
+ else if (view === 'feeds') renderFeeds();
+ else renderSearch();
+ }
+ scrollTop(scroll);
+ }
+
+ function empty(text) {
+ root.append(el('div', 'nui-pod-empty', text));
+ }
+
+ function progressBar(pos, duration) {
+ if (!pos || !duration) return null;
+ const b = el('div', 'nui-pod-progress');
+ const f = el('span');
+ f.style.width = Math.min(100, Math.round(pos / duration * 100)) + '%';
+ b.append(f);
+ return b;
+ }
+
+ function episodeRow(e, opts) {
+ const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : ''));
+ row.dataset.id = e.id;
+ row.append(art(e.art));
+ const main = el('div', 'nui-pod-main');
+ main.append(el('div', 'nui-pod-title', e.title));
+ // Length first: podcast names are long and get cut off.
+ const meta = [];
+ // A played episode, or a place past the feed's (often wrong) length: just the length.
+ const started = e.pos > 30 && !e.played && e.pos < e.duration - 30;
+ if (e.duration) meta.push(started ? minutes(e.duration - e.pos) + ' übrig' : minutes(e.duration));
+ if (opts.showDate && e.date) meta.push(day(e.date));
+ if (opts.showFeed && e.feed) meta.push(e.feed);
+ main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
+ const p = e.played ? null : progressBar(e.pos, e.duration);
+ if (p) main.append(p);
+ row.append(main);
+ if (selected) {
+ row.classList.add('nui-pod-selecting');
+ const box = el('span', 'nui-pod-check' + (selected.has(e.id) ? ' on' : ''), selected.has(e.id) ? '☑' : '☐');
+ row.prepend(box);
+ row.addEventListener('click', () => toggleSelected(e.id));
+ } else {
+ dlAppend(row, e);
+ row.addEventListener('click', () => play(e));
+ }
+ if (!selected && !opts.noSelect) {
+ // Long press starts choosing several, like in AntennaPod.
+ let timer = 0;
+ row.addEventListener('pointerdown', ev => {
+ if (ev.target.closest('button, .nui-pod-handle')) return;
+ timer = setTimeout(() => { timer = 0; startSelecting(e.id); }, 550);
+ });
+ const cancel = () => { if (timer) clearTimeout(timer); timer = 0; };
+ row.addEventListener('pointerup', cancel);
+ row.addEventListener('pointerleave', cancel);
+ row.addEventListener('pointercancel', cancel);
+ row.addEventListener('contextmenu', ev => ev.preventDefault());
+ }
+ return row;
+ }
+
+ function renderQueue() {
+ const items = queue().map(ep);
+ if (!items.length) {
+ empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
+ return;
+ }
+ const total = items.reduce((n, e) => n + Math.max(0, (e.duration || 0) - (e.pos || 0)), 0);
+ if (selected) {
+ root.append(selectionBar(items, [
+ {label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
+ {label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
+ {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
+ {label: 'Entfernen', danger: true, run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
+ ]));
+ } else {
+ const head = el('div', 'nui-pod-head');
+ const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : '';
+ head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}`),
+ listTools([
+ button('⇅', 'Sortieren oder mischen', sortMenu),
+ button('☐', 'Mehrere auswählen', () => startSelecting()),
+ button(locked ? '🔒' : '🔓', locked ? 'Warteschlange entsperren' : 'Warteschlange sperren', toggleLock,
+ locked ? 'nui-pod-lock on' : 'nui-pod-lock'),
+ ]));
+ root.append(head);
+ }
+ const list = el('div', 'nui-pod-list nui-pod-queue');
+ for (const e of items) {
+ const row = episodeRow(e, {showFeed: true});
+ if (!selected) {
+ row.append(button('⋯', 'Mehr', () => queueMenu(e)));
+ if (!locked && !keepSorted()) {
+ const handle = el('div', 'nui-pod-handle', '≡');
+ handle.title = 'Ziehen zum Umsortieren';
+ handle.addEventListener('click', ev => ev.stopPropagation());
+ dragSort(handle, row, list);
+ row.append(handle);
+ }
+ }
+ list.append(row);
+ }
+ root.append(list);
+ }
+
+ function renderInbox() {
+ const items = inbox().map(ep);
+ if (!items.length) {
+ empty(feeds().length
+ ? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.'
+ : 'Noch keine Abos. Unter „Suche“ findest du Podcasts.');
+ return;
+ }
+ if (selected) {
+ const chosen = ids => items.filter(e => ids.includes(e.id));
+ const add = (ids, where) => {
+ N.podcastEnqueue(JSON.stringify(chosen(ids).map(e => e.raw)), where, currentId());
+ toast(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} eingereiht`);
+ stopSelecting();
+ };
+ root.append(selectionBar(items, [
+ {label: 'Einreihen', run: ids => openSheet(`${ids.length} ${ids.length === 1 ? 'Folge' : 'Folgen'} einreihen`, [
+ {icon: '+', label: 'Wie eingestellt', hint: (POSITIONS.find(p => p.value === settings().enqueue_position) || POSITIONS[0]).label,
+ action: () => add(ids, '')},
+ {icon: '⤴', label: 'Als Nächstes', action: () => add(ids, 'after_current')},
+ {icon: '⇈', label: 'An den Anfang', action: () => add(ids, 'front')},
+ {icon: '⇊', label: 'Ans Ende', action: () => add(ids, 'back')},
+ ])},
+ {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
+ {label: 'Ausblenden', run: ids => { N.podcastDismiss(JSON.stringify(ids)); stopSelecting(); }},
+ ]));
+ } else {
+ const head = el('div', 'nui-pod-head');
+ head.append(el('div', 'nui-pod-summary', `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'}`),
+ listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
+ root.append(head);
+ }
+ const list = el('div', 'nui-pod-list');
+ for (const e of items) {
+ const row = episodeRow(e, {showFeed: true, showDate: true});
+ if (!selected) {
+ row.append(
+ e.inQueue || inQueue(e.id)
+ ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
+ : button('+', 'Einreihen', () => enqueue(e)),
+ button('⋯', 'Mehr', () => episodeMenu(e)));
+ }
+ list.append(row);
+ }
+ root.append(list);
+ }
+
+ function renderFeeds() {
+ const items = feeds().slice().sort((a, b) => String(b.latest_episode_at || '').localeCompare(String(a.latest_episode_at || '')));
+ if (!items.length) {
+ empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
+ return;
+ }
+ const list = el('div', 'nui-pod-list');
+ for (const f of items) {
+ const row = el('div', 'nui-pod-row');
+ row.append(art(f.artwork_url));
+ const main = el('div', 'nui-pod-main');
+ main.append(el('div', 'nui-pod-title', plain(f.title)));
+ const meta = [];
+ if (f.author) meta.push(f.author);
+ if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at));
+ main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
+ if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error));
+ row.append(main);
+ if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still'));
+ else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto'));
+ row.addEventListener('click', () => openFeed(f));
+ list.append(row);
+ }
+ root.append(list);
+ }
+
+ async function openFeed(f) {
+ feedView = {feed: f, episodes: null};
+ render();
+ scrollTop(0);
+ try {
+ const r = await fetch(`/podcasts/feeds/${f.id}/episodes/`);
+ if (!r.ok) throw new Error(r.status);
+ const data = await r.json();
+ if (feedView && feedView.feed.id === f.id) {
+ feedView = {feed: Object.assign({}, f, data.feed), episodes: data.episodes};
+ render();
+ }
+ } catch (e) {
+ if (feedView && feedView.feed.id === f.id) {
+ feedView.episodes = [];
+ feedView.error = true;
+ render();
+ }
+ }
+ }
+
+ function renderFeed() {
+ const f = feedView.feed;
+ const head = el('div', 'nui-pod-feedhead');
+ head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; render(); }), art(f.artwork_url, 'big'));
+ const info = el('div', 'nui-pod-main');
+ info.append(el('div', 'nui-pod-title', f.title));
+ if (f.author) info.append(el('div', 'nui-pod-meta', f.author));
+ head.append(info);
+ root.append(head);
+
+ const live = feeds().find(x => x.id === f.id) || f;
+ if (live.last_error) {
+ root.append(el('div', 'nui-pod-feederr nui-pod-feederr-box',
+ `⚠ Der Feed ließ sich zuletzt nicht laden (${live.last_error}${live.last_error_at ? ', ' + day(live.last_error_at) : ''}). ` +
+ 'Der Server versucht es weiter, bei wiederholten Fehlern seltener.'));
+ }
+ if ('new_episodes' in live && N.podcastFeedSettings) {
+ root.append(newEpisodesRow(live));
+ if (N.podcastFeedPrefs) root.append(feedSettings(f));
+ root.append(unsubscribeBox(f));
+ return renderFeedEpisodes(f);
+ }
+ const auto = live.auto_queue;
+ const toggle = el('label', 'nui-pod-switch');
+ const box = el('input');
+ box.type = 'checkbox';
+ box.checked = !!auto;
+ box.addEventListener('change', () => N.podcastAutoQueue(f.id, box.checked));
+ const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
+ toggle.append(box, el('span', null, 'Neue Folgen automatisch einreihen'), el('span', 'nui-pod-meta', pos));
+ root.append(toggle);
+ if (N.podcastFeedPrefs) root.append(feedSettings(f));
+ root.append(unsubscribeBox(f));
+ renderFeedEpisodes(f);
+ }
+
+ // Where this podcast's new episodes go, and which ones count at all.
+ function newEpisodesRow(live) {
+ const row = el('div', 'nui-pod-feedprefs');
+ const chip = (label, value, on, onClick) => {
+ const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''));
+ b.type = 'button';
+ b.append(el('span', null, label), el('strong', null, value));
+ b.addEventListener('click', onClick);
+ return b;
+ };
+ const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); };
+ const where = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
+ const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
+ row.append(chip('Neue Folgen', where[live.new_episodes] || where.inbox, live.new_episodes !== 'inbox', () =>
+ openSheet(`Neue Folgen von „${plain(live.title)}“ …`, [
+ {icon: live.new_episodes === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', action: () => save({new_episodes: 'inbox'})},
+ {icon: live.new_episodes === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, action: () => save({new_episodes: 'queue'})},
+ {icon: live.new_episodes === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', action: () => save({new_episodes: 'ignore'})},
+ ])));
+ const words = [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`,
+ live.min_duration && `ab ${live.min_duration} min`].filter(Boolean).join(', ');
+ const ask = async (key, title, now) => {
+ const v = typeof customPrompt === 'function' ? await customPrompt(title, now || '') : null;
+ if (v != null) save({[key]: v.trim()});
+ };
+ row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [
+ {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle',
+ action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)},
+ {icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine',
+ action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)},
+ {icon: '⏱', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine',
+ action: () => openSheet('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({
+ icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge',
+ action: () => save({min_duration: m}),
+ })))},
+ ])));
+ return row;
+ }
+
+ function renderFeedEpisodes(f) {
+ if (feedView.episodes == null) { empty('Folgen werden geladen …'); return; }
+ if (feedView.error) { empty('Die Folgen lassen sich gerade nicht laden. Das braucht eine Verbindung.'); return; }
+ const list = el('div', 'nui-pod-list');
+ for (const o of feedView.episodes) {
+ // Feed episodes don't name their podcast; queue entries need it.
+ o.feed__id = f.id;
+ o.feed__title = f.title;
+ o.feed__artwork_url = f.artwork_url;
+ const e = ep(o);
+ const row = episodeRow(e, {showDate: true, noSelect: true});
+ row.append(
+ inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
+ : button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }),
+ button('⋯', 'Mehr', () => episodeMenu(e)));
+ list.append(row);
+ }
+ root.append(list);
+ }
+
+ function volumeLabel(v) {
+ if (!v || v === 1) return '';
+ return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || '';
+ }
+
+ // This podcast's own playback: speed, intro and outro to skip.
+ function feedSettings(f) {
+ const live = feeds().find(x => x.id === f.id) || f;
+ const own = 'skip_intro' in live
+ ? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1}
+ : (devicePrefs().feeds || {})[String(f.id)] || {};
+ const row = el('div', 'nui-pod-feedprefs');
+ const chip = (label, value, onClick) => {
+ const b = el('button', 'nui-pod-chip' + (value ? ' on' : ''));
+ b.type = 'button';
+ b.append(el('span', null, label), el('strong', null, value || 'aus'));
+ b.addEventListener('click', onClick);
+ return b;
+ };
+ const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); };
+ const seconds = (key, title) => openSheet(title, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({
+ icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', action: () => set(key, v),
+ })));
+ row.append(
+ chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`,
+ SPEEDS.map(v => ({icon: own.speed === v ? '●' : '○', label: speedLabel(v), action: () => set('speed', v)}))
+ .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}]))),
+ chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')),
+ chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')),
+ chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`,
+ [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']]
+ .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))),
+ );
+ return row;
+ }
+
+ // Unsubscribing (server: POST /podcasts/feeds//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 = '
';
+ const text = el('button', 'nui-mini-text');
+ text.type = 'button';
+ text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
+ text.addEventListener('click', openPlayer);
+ mini.append(art('', 'mini'), text,
+ button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'),
+ button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-mini-toggle'));
+ dock.prepend(mini);
+
+ // A "Podcasts" button in the bottom bar, between books and radio.
+ const tabbar = $id('nui-tabbar');
+ if (tabbar && !tabbar.querySelector('[data-tab="podcasts"]')) {
+ const b = el('button');
+ b.type = 'button';
+ b.dataset.tab = 'podcasts';
+ b.append(el('span', 'nui-tab-icon', '◎'), document.createTextNode('Podcasts'));
+ b.addEventListener('click', () => { showTab('podcasts'); scrollTop(0); });
+ tabbar.insertBefore(b, tabbar.querySelector('[data-tab="radio"]'));
+ }
+ }
+
+ function renderMini() {
+ const isPod = player.kind === 'podcast';
+ document.documentElement.classList.toggle('nui-pod-playing', isPod);
+ if (!mini) return;
+ mini.hidden = !isPod;
+ document.documentElement.classList.toggle('nui-pod-mini-open', isPod);
+ if (!isPod) return;
+ mini.querySelector('.nui-mini-station').textContent = player.title || '';
+ // The back button names the set skip length (checked now and then, it rarely changes).
+ if (!mini.skipChecked || Date.now() - mini.skipChecked > 5000) {
+ mini.skipChecked = Date.now();
+ const sk = mini.querySelector('.nui-pod-skip');
+ sk.title = `${devicePrefs().skip_back || 15} s zurück`;
+ sk.setAttribute('aria-label', sk.title);
+ }
+ mini.querySelector('.nui-mini-track').textContent = player.buffering && player.playing ? 'Lädt …'
+ : (player.feed || '') + (player.dur ? ' · ' + clock((player.dur - player.pos) / 1000) + ' übrig' : '');
+ const t = mini.querySelector('.nui-mini-toggle');
+ t.textContent = player.playing ? '❚❚' : '▶';
+ t.title = player.playing ? 'Pause' : 'Abspielen';
+ const a = mini.querySelector('.nui-pod-art img');
+ const box = mini.querySelector('.nui-pod-art');
+ const src = (player.artwork || '').replace(/^http:/, 'https:');
+ if (!a && src) { const img = el('img'); img.alt = ''; img.src = src; box.append(img); }
+ else if (a && a.getAttribute('src') !== src) { if (src) a.src = src; else a.remove(); }
+ mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0';
+ }
+
+ let sheet = null;
+ let sheetEpisode = 0;
+ let seeking = false;
+
+ // ---- chapters and shownotes (server: /podcasts/episodes//) ------------------------
+ // 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 || 'Keine Shownotes.
';
+ notes.querySelectorAll('a[href]').forEach(a => a.setAttribute('target', '_blank'));
+ if (live) {
+ linkStamps(notes);
+ notes.addEventListener('click', ev => {
+ const a = ev.target.closest('a.nui-pod-stamp');
+ if (!a) return;
+ ev.preventDefault();
+ N.podcastSeek(parseInt(a.dataset.t, 10) * 1000);
+ });
+ }
+ body.append(notes);
+ }
+ };
+ const tab = (which, label) => {
+ const b = el('button', 'nui-pod-subtab', label);
+ b.type = 'button';
+ b.dataset.which = which;
+ b.addEventListener('click', () => show(which));
+ tabs.append(b);
+ };
+ if (chapters.length) tab('chapters', `Kapitel (${chapters.length})`);
+ tab('notes', 'Shownotes');
+ if (d.has_transcript) tab('transcript', 'Transkript');
+ box.append(tabs, body);
+ show(chapters.length ? 'chapters' : 'notes');
+ }
+
+ function markChapter(scope, seconds) {
+ const items = scope.querySelectorAll('.nui-pod-chapter');
+ let current = -1;
+ items.forEach((li, i) => { if (parseFloat(li.dataset.start) <= seconds + 0.5) current = i; });
+ items.forEach((li, i) => li.classList.toggle('current', i === current));
+ const now = scope.querySelector('.nui-pod-chapnow');
+ if (now) {
+ const d = storedDetails(parseInt((scope.querySelector('.nui-pod-notesbox') || scope).dataset.episode, 10));
+ const ch = d && d.chapters ? d.chapters.filter(c => c.start <= seconds + 0.5).pop() : null;
+ now.textContent = ch ? plain(ch.title || '') : '';
+ }
+ markTranscript(scope, seconds);
+ }
+
+ // ---- transcript: follows the playback, a tap jumps there --------------------------------
+
+ const transcripts = new Map();
+ let transcriptTouched = 0; // the user scrolled the transcript: don't pull it along for a while
+
+ async function showTranscript(body, id, live) {
+ body.append(el('div', 'nui-pod-meta', 'Transkript wird geladen …'));
+ let t = transcripts.get(id);
+ if (!t) {
+ try {
+ const r = await fetch(`/podcasts/episodes/${id}/transcript/`);
+ if (!r.ok) throw new Error(r.status);
+ t = await r.json();
+ transcripts.set(id, t);
+ } catch (e) {
+ body.textContent = '';
+ body.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Für diese Folge gibt es kein lesbares Transkript.' : 'Das Transkript braucht eine Verbindung.'));
+ return;
+ }
+ }
+ body.textContent = '';
+ const list = el('div', 'nui-pod-transcript');
+ for (const seg of t.segments || []) {
+ const p = el('p', 'nui-pod-seg');
+ p.dataset.start = String(seg.start || 0);
+ if (seg.speaker) p.append(el('strong', 'nui-pod-speaker', seg.speaker + ': '));
+ p.append(document.createTextNode(seg.text || ''));
+ if (live) p.addEventListener('click', () => N.podcastSeek(Math.round((seg.start || 0) * 1000)));
+ list.append(p);
+ }
+ list.addEventListener('touchstart', () => { transcriptTouched = Date.now(); }, {passive: true});
+ body.append(list);
+ markTranscript(body, (player.pos || 0) / 1000);
+ }
+
+ function markTranscript(scope, seconds) {
+ const segs = scope.querySelectorAll('.nui-pod-seg');
+ if (!segs.length) return;
+ let current = null;
+ segs.forEach(p => { if (parseFloat(p.dataset.start) <= seconds + 0.3) current = p; });
+ segs.forEach(p => p.classList.toggle('current', p === current));
+ if (current && current.dataset.shown !== '1' && Date.now() - transcriptTouched > 8000) {
+ segs.forEach(p => { p.dataset.shown = ''; });
+ current.dataset.shown = '1';
+ current.scrollIntoView({block: 'center', behavior: 'smooth'});
+ }
+ }
+
+ // Shownotes of an episode that isn't playing: its own sheet.
+ let notesSheet = null;
+
+ function openNotes(e) {
+ closeNotes();
+ notesSheet = el('div');
+ notesSheet.id = 'nui-pod-player';
+ notesSheet.className = 'nui-pod-notes-sheet';
+ const panel = el('div', 'nui-pod-player-panel');
+ const top = el('div', 'nui-pod-player-top');
+ top.append(button('⌄', 'Schließen', closeNotes));
+ panel.append(top, el('div', 'nui-pod-player-title', e.title), el('div', 'nui-pod-player-feed', e.feed || ''));
+ const play1 = el('button', 'btn nui-pod-notes-play', '▶ Abspielen');
+ play1.type = 'button';
+ play1.addEventListener('click', () => { closeNotes(); play(e); });
+ panel.append(play1);
+ const box = el('div', 'nui-pod-notesbox');
+ panel.append(box);
+ notesSheet.append(panel);
+ notesSheet.addEventListener('click', ev => { if (ev.target === notesSheet) closeNotes(); });
+ document.body.append(notesSheet);
+ showDetails(box, e.id, false);
+ }
+
+ function closeNotes() {
+ if (!notesSheet) return false;
+ notesSheet.remove();
+ notesSheet = null;
+ return true;
+ }
+
+ function openPlayer() {
+ if (player.kind !== 'podcast') return;
+ closePlayer();
+ sheet = el('div');
+ sheet.id = 'nui-pod-player';
+ sheet.innerHTML = '
';
+ const panel = sheet.firstChild;
+ const top = el('div', 'nui-pod-player-top');
+ top.append(button('⌄', 'Player schließen', closePlayer));
+ panel.append(top, art(player.artwork, 'player'));
+ panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'));
+ const range = el('input', 'nui-pod-seek');
+ range.type = 'range';
+ range.min = 0;
+ range.step = 1;
+ range.id = 'nui-pod-seek';
+ range.addEventListener('input', () => {
+ seeking = true;
+ sheet.querySelector('.nui-pod-time-pos').textContent = clock(range.value);
+ });
+ range.addEventListener('change', () => {
+ N.podcastSeek(range.value * 1000);
+ seeking = false;
+ });
+ const times = el('div', 'nui-pod-times');
+ times.append(el('span', 'nui-pod-time-pos'), el('span', 'nui-pod-time-left'));
+ panel.append(range, times);
+ const prefs = devicePrefs();
+ const back = prefs.skip_back || 15;
+ const fwd = prefs.skip_forward || 30;
+ const controls = el('div', 'nui-pod-controls');
+ controls.append(
+ button(String(back), `${back} Sekunden zurück`, () => N.podcastSkip(false), 'nui-pod-back'),
+ button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-pod-play'),
+ button(String(fwd), `${fwd} Sekunden vor`, () => N.podcastSkip(true), 'nui-pod-fwd'),
+ );
+ panel.append(controls);
+ const extra = el('div', 'nui-pod-extra');
+ extra.append(
+ button('1×', 'Geschwindigkeit', speedMenu, 'nui-pod-speed'),
+ button('☾', 'Sleep-Timer', sleepMenu, 'nui-pod-sleep'),
+ button('☆', 'Favorit', () => toggleFavorite({id: player.episodeId}, star => {
+ const b = sheet && sheet.querySelector('.nui-pod-fav');
+ if (b) { b.textContent = star ? '★' : '☆'; b.classList.toggle('on', star); }
+ }), 'nui-pod-fav'),
+ button('⏭', 'Folge abschließen, nächste spielen', finishAndNext, 'nui-pod-next'),
+ button('≡', 'Warteschlange', () => { closePlayer(); view = 'queue'; feedView = null; showTab('podcasts'); render(); }),
+ );
+ panel.append(extra);
+ const notes = el('div', 'nui-pod-notesbox');
+ panel.append(notes);
+ sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
+ document.body.append(sheet);
+ sheetEpisode = player.episodeId;
+ showDetails(notes, player.episodeId, true);
+ renderPlayer();
+ }
+
+ function closePlayer() {
+ if (!sheet) return false;
+ sheet.remove();
+ sheet = null;
+ return true;
+ }
+
+ function renderPlayer() {
+ if (!sheet) return;
+ if (player.kind !== 'podcast') { closePlayer(); return; }
+ if (sheetEpisode !== player.episodeId) {
+ // The next episode started while the player was open.
+ sheetEpisode = player.episodeId;
+ showDetails(sheet.querySelector('.nui-pod-notesbox'), player.episodeId, true);
+ }
+ markChapter(sheet, (player.pos || 0) / 1000);
+ sheet.querySelector('.nui-pod-player-title').textContent = player.title || '';
+ sheet.querySelector('.nui-pod-player-feed').textContent = player.feed || '';
+ const range = sheet.querySelector('.nui-pod-seek');
+ const dur = Math.round((player.dur || 0) / 1000);
+ const pos = Math.round((player.pos || 0) / 1000);
+ range.max = dur || 1;
+ if (!seeking) {
+ range.value = pos;
+ sheet.querySelector('.nui-pod-time-pos').textContent = clock(pos);
+ }
+ sheet.querySelector('.nui-pod-time-left').textContent = dur ? '−' + clock(dur - pos) : '';
+ const p = sheet.querySelector('.nui-pod-play');
+ p.textContent = player.playing ? '❚❚' : '▶';
+ p.title = player.playing ? 'Pause' : 'Abspielen';
+ const sp = sheet.querySelector('.nui-pod-speed');
+ sp.textContent = speedLabel(player.speed || 1) + (player.podcastSpeed ? '•' : '');
+ sp.title = player.podcastSpeed ? 'Geschwindigkeit (eigene für diesen Podcast)' : 'Geschwindigkeit';
+ const sl = sheet.querySelector('.nui-pod-sleep');
+ sl.textContent = sleepLabel() || '☾';
+ sl.classList.toggle('on', !!sleepLabel());
+ const img = sheet.querySelector('.nui-pod-art-player img');
+ const src = (player.artwork || '').replace(/^http:/, 'https:');
+ if (img && src && img.getAttribute('src') !== src) img.src = src;
+ }
+
+ function speedLabel(s) {
+ return String(Math.round(s * 100) / 100).replace('.', ',') + '×';
+ }
+
+ // Speed: for all podcasts, or (AntennaPod-like) remembered for this podcast.
+ function speedMenu() {
+ const own = !!player.podcastSpeed;
+ const pick = forPodcast => openSheet(forPodcast ? `Geschwindigkeit für „${plain(player.feed)}“` : 'Geschwindigkeit für alle Podcasts',
+ SPEEDS.map(s => ({
+ icon: Math.abs((player.speed || 1) - s) < 0.01 && own === forPodcast ? '●' : '○',
+ label: speedLabel(s),
+ action: () => (forPodcast ? N.podcastSpeedForPodcast(s) : N.podcastSpeed(s)),
+ })).concat(forPodcast && own ? [{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => N.podcastSpeedForPodcast(0)}] : []));
+ if (!N.podcastSpeedForPodcast || !player.feedId) { pick(false); return; }
+ openSheet('Geschwindigkeit', [
+ {icon: '◎', label: 'Für alle Podcasts', hint: own ? null : speedLabel(player.speed || 1), action: () => pick(false)},
+ {icon: '◉', label: `Nur für „${plain(player.feed)}“`, hint: own ? speedLabel(player.speed || 1) : 'noch keine eigene',
+ action: () => pick(true)},
+ ]);
+ }
+
+ // ---- sleep timer (runs in the app, also with the screen off) ----
+
+ function sleepLabel() {
+ const t = player.sleep || {};
+ if (t.episodes > 0) return t.episodes === 1 ? '☾ Folgenende' : `☾ ${t.episodes} Folgen`;
+ if (t.leftMs > 0) return `☾ ${Math.max(1, Math.ceil(t.leftMs / 60000))} min`;
+ return '';
+ }
+
+ // Minutes count only while playing; shaking the phone starts them over.
+ function sleepMenu() {
+ if (!N.podcastSleep) return;
+ const t = player.sleep || {};
+ const on = !!sleepLabel();
+ const items = [];
+ if (on) {
+ items.push({icon: '✕', label: 'Ausschalten', hint: sleepLabel().replace('☾ ', 'läuft: '), action: () => N.podcastSleep(0)});
+ if (t.leftMs > 0 && N.podcastSleepExtend) {
+ items.push({icon: '+', label: '5 Minuten länger', action: () => N.podcastSleepExtend(5)});
+ items.push({icon: '+', label: '10 Minuten länger', action: () => N.podcastSleepExtend(10)});
+ }
+ }
+ for (const m of [5, 10, 15, 30, 45, 60, 90]) {
+ items.push({icon: '☾', label: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
+ }
+ items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
+ items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
+ items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
+ if (N.podcastSleepAuto) {
+ items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)',
+ hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
+ action: () => { N.podcastSleepAuto(!t.auto); t.auto = !t.auto; }});
+ }
+ openSheet('Sleep-Timer', items);
+ }
+
+ function finishAndNext() {
+ const id = currentId();
+ if (!id) return;
+ N.podcastNext();
+ N.podcastMarkPlayed(id, true);
+ }
+
+ // ---- downloads (Downloads.kt) ------------------------------------------------------------
+ // Episodes kept on the phone to play without a connection. Each episode row
+ // gets a mark (↓ fetch it, a ring while it loads, ✓ when it's here), the ⋯
+ // menus and the settings sheet get their entries. The app downloads the
+ // first episodes of the queue by itself and tells us via __dioraDownloads.
+
+ const DL = !!N.podcastDownloads;
+ let downloads = DL ? parse(N.podcastDownloads(), {}) : {};
+ let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {};
+ const DL_COUNTS = [1, 2, 3, 5, 10];
+ // The limit and the clean-up the AntennaPod way (Downloads.kt, DownloadPlan.toClean).
+ const DL_LIMITS = [5, 10, 25, 50, 100, 500, 0];
+ const DL_CLEANUPS = [
+ {value: 'queue', label: 'Wenn nicht mehr in der Warteschlange',
+ hint: 'automatische Downloads, sobald ihre Folge dort fehlt; beim Limit auch gehörte'},
+ {value: 'never', label: 'Nie', hint: 'beim Limit lädt die App nichts mehr von selbst'},
+ ];
+ const dlLimitLabel = n => (n ? n + ' Folgen' : 'Unbegrenzt');
+
+ function size(bytes) {
+ if (bytes >= 1073741824) return (bytes / 1073741824).toFixed(1).replace('.', ',') + ' GB';
+ return Math.max(1, Math.round((bytes || 0) / 1048576)) + ' MB';
+ }
+
+ function dlStart(e) {
+ N.podcastDownload(JSON.stringify(e.raw));
+ window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0}}));
+ toast('Wird heruntergeladen');
+ }
+
+ function dlDelete(e, running) {
+ N.podcastDeleteDownload(e.id);
+ toast(running ? 'Download abgebrochen' : 'Download gelöscht');
+ }
+
+ function dlAppend(row, e) {
+ if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return;
+ const d = downloads[e.id];
+ const state = d ? d.state : null;
+ let b;
+ if (state === 'done') {
+ b = button('✓', 'Heruntergeladen', () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-done');
+ } else if (state === 'queued' || state === 'downloading') {
+ b = button('', state === 'queued' ? 'Wartet auf den Download' : 'Wird heruntergeladen',
+ () => openSheet(e.title, dlMenuItems(e)), 'nui-pod-dl nui-pod-dl-ring');
+ b.append(el('span'));
+ } else {
+ b = button('↓', state === 'failed' ? 'Download hat nicht geklappt — nochmal' : 'Herunterladen', () => dlStart(e),
+ 'nui-pod-dl' + (state === 'failed' ? ' nui-pod-dl-failed' : ''));
+ }
+ b.dataset.dl = e.id;
+ dlRing(b);
+ row.append(b);
+ }
+
+ function dlRing(b) {
+ const d = downloads[b.dataset.dl];
+ if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return;
+ b.style.setProperty('--p', (d.progress || 0) + '%');
+ b.firstChild.textContent = d.state === 'queued' ? '…' : d.progress + '';
+ }
+
+ function dlMenuItems(e) {
+ if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return [];
+ const d = downloads[e.id];
+ if (!d || d.state === 'failed') {
+ return [{icon: '↓', label: 'Herunterladen', hint: d ? 'hat eben nicht geklappt (' + (d.error || '?') + ')' : 'zum Hören ohne Verbindung',
+ action: () => dlStart(e)}];
+ }
+ if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}];
+ return [{icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? 'wartet noch' : d.progress + ' %',
+ action: () => dlDelete(e, true)}];
+ }
+
+ function dlSet(changes) {
+ Object.assign(dlSettings, changes);
+ N.podcastSetDownloadSettings(JSON.stringify(changes));
+ settingsMenu();
+ }
+
+ function dlSettingsItems() {
+ if (!DL) return [];
+ const s = dlSettings = parse(N.podcastDownloadSettings(), dlSettings);
+ const done = Object.values(downloads).filter(d => d.state === 'done');
+ const bytes = done.reduce((n, d) => n + (d.bytes || 0), 0);
+ return [
+ {icon: s.auto ? '☑' : '☐', label: 'Automatisch herunterladen',
+ hint: s.auto ? `die ersten ${s.count} Folgen der Warteschlange` : 'aus', action: () => dlSet({auto: !s.auto})},
+ s.auto && {icon: '#', label: 'Wie viele Folgen', hint: String(s.count), action: () => openSheet('Automatisch herunterladen',
+ DL_COUNTS.map(n => ({icon: s.count === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.count === n,
+ action: () => dlSet({count: n})})))},
+ s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN',
+ action: () => dlSet({mobile: !s.mobile})},
+ {icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen',
+ hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten',
+ action: () => dlSet({deletePlayed: !s.deletePlayed})},
+ s.limit != null && {icon: '▦', label: 'Höchstens behalten',
+ hint: dlLimitLabel(s.limit) + ` · jetzt ${Object.values(downloads).filter(d => d.state !== 'failed').length}`,
+ action: () => openSheet('Höchstens so viele Folgen auf dem Telefon', DL_LIMITS.map(n => ({
+ icon: s.limit === n ? '●' : '○', label: dlLimitLabel(n), active: s.limit === n,
+ hint: n === 25 ? 'Standard' : null, action: () => dlSet({limit: n})})))},
+ s.cleanup && {icon: '♻', label: 'Aufräumen', hint: (DL_CLEANUPS.find(c => c.value === s.cleanup) || DL_CLEANUPS[0]).label,
+ action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({
+ icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value,
+ action: () => dlSet({cleanup: c.value})})))},
+ done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, danger: true,
+ hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
+ action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [
+ {icon: '✕', label: 'Löschen', hint: size(bytes), danger: true,
+ action: () => { N.podcastDeleteAllDownloads(); toast('Downloads gelöscht'); }},
+ {icon: '‹', label: 'Abbrechen', action: () => {}},
+ ])},
+ ];
+ }
+
+ window.__dioraDownloads = function (map) {
+ const before = downloads;
+ downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {};
+ const keys = Object.keys(downloads);
+ const same = keys.length === Object.keys(before).length &&
+ keys.every(id => before[id] && before[id].state === downloads[id].state);
+ // Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going).
+ if (same) document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing);
+ else render();
+ };
+
+ // ---- hooks from the app ---------------------------------------------------------------
+
+ window.__dioraPodcasts = function (json) {
+ state = typeof json === 'string' ? parse(json, state) : json;
+ render();
+ };
+
+ window.__dioraPodcastsRefreshed = function (ok) {
+ refreshing = false;
+ if (!ok) toast(navigator.onLine ? 'Abrufen hat nicht geklappt' : 'Offline — es gilt der letzte Stand');
+ render();
+ };
+
+ window.__dioraPlayer = function (p) {
+ const before = player;
+ player = p || {kind: null};
+ renderMini();
+ renderPlayer();
+ // The current episode changed: the queue view marks it.
+ if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
+ };
+
+ // The app loads this in ; the browser may load it after the page is parsed.
+ function start(fn) {
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn);
+ else fn();
+ }
+
+ start(() => {
+ if (typeof showTab !== 'function') return;
+ // The web's podcast tab loads its own lists; the app has them already.
+ window.loadPodcastTab = function () {};
+ window.refreshAllFeeds = async function () {};
+ build();
+ buildMini();
+ player = parse(N.playerState(), {kind: null});
+ render();
+ renderMini();
+
+ const back = window.__dioraBack;
+ window.__dioraBack = function () {
+ if (closeStats()) return true;
+ if (closeNotes()) return true;
+ if (closePlayer()) return true;
+ if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true;
+ if (back && back()) return true;
+ if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts') {
+ if (feedView) { feedView = null; render(); return true; }
+ showTab('books');
+ return true;
+ }
+ return false;
+ };
+
+ // Pull the latest state once per start; the server has done the fetching.
+ N.podcastRefresh(false);
+ });
+})();
diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css
new file mode 100644
index 0000000..1d49122
--- /dev/null
+++ b/static/podcasts/podcast-ui.css
@@ -0,0 +1,529 @@
+/* Copied from diora-android 62f3a69 2026-10-02: the podcast parts of web-native/static/native.css
+ (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
+ The web's own additions are in podcasts-web.css. */
+
+/* ---- bottom sheet ---------------------------------------------------------------- */
+
+#nui-sheet {
+ position: fixed;
+ inset: 0;
+ z-index: 900;
+}
+
+.nui-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.6);
+}
+
+.nui-panel {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ max-height: 80vh;
+ overflow-y: auto;
+ background: var(--nui-panel);
+ border-top: 1px solid var(--border);
+ border-radius: 12px 12px 0 0;
+ padding: 8px 0 12px;
+ animation: nui-up 0.18s ease-out;
+}
+
+@keyframes nui-up {
+ from { transform: translateY(40px); opacity: 0; }
+ to { transform: none; opacity: 1; }
+}
+
+.nui-sheet-title {
+ padding: 10px 20px 8px;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--nui-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.nui-row,
+.nui-row-main {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ width: 100%;
+ min-height: 52px;
+ padding: 6px 20px;
+ background: none;
+ border: none;
+ color: var(--fg);
+ font: inherit;
+ font-size: 15px;
+ text-align: left;
+ cursor: pointer;
+}
+
+.nui-row:active,
+.nui-row-main:active {
+ background: #1d1d1d;
+}
+
+.nui-row-icon {
+ width: 24px;
+ text-align: center;
+ flex-shrink: 0;
+ font-size: 18px;
+}
+
+.nui-row-text {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.nui-row-hint {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--nui-muted);
+}
+
+.nui-danger { color: var(--accent); }
+.nui-active .nui-row-label { color: var(--accent); }
+
+.nui-row-split {
+ padding: 0 16px 0 0;
+}
+.nui-row-split .nui-row-main {
+ flex: 1;
+ width: auto;
+}
+
+.nui-seg {
+ display: inline-flex;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+.nui-seg button {
+ min-width: 44px;
+ min-height: 34px;
+ background: none;
+ border: none;
+ color: var(--nui-muted);
+ font: inherit;
+}
+.nui-seg button.nui-seg-on {
+ background: var(--fg);
+ color: var(--bg);
+}
+
+/* ---- podcasts (phones only: html.nui-podcasts, see native-podcast.js) ------------ */
+
+html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
+html.nui-pod-playing #nui-mini { display: none !important; }
+html.nui-radio.nui-pod-mini-open body {
+ padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px));
+}
+
+#nui-pod-bar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin: 0 -4px 8px 0;
+}
+#nui-pod-bar .native-brand { font-size: 22px; font-weight: 700; letter-spacing: 0.04em; }
+.nui-pod-status { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
+
+.nui-pod-tabs {
+ display: flex;
+ margin: 0 0 8px;
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+ scrollbar-width: none;
+}
+.nui-pod-tab {
+ flex: 1 0 auto;
+ min-height: var(--nui-tap);
+ padding: 0 10px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: none;
+ color: var(--nui-muted);
+ font: inherit;
+ font-size: 14px;
+ white-space: nowrap;
+}
+.nui-pod-tab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
+.nui-pod-count {
+ min-width: 20px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ background: #2a2a2a;
+ font-size: 11px;
+ font-weight: 700;
+ text-align: center;
+}
+.nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
+
+.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
+.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; }
+
+.nui-pod-list { display: flex; flex-direction: column; }
+.nui-pod-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ min-height: 72px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+ -webkit-tap-highlight-color: transparent;
+}
+.nui-pod-row:active { background: #141414; }
+.nui-pod-row.nui-pod-played .nui-pod-title { color: var(--nui-muted); font-weight: 400; }
+.nui-pod-row.nui-pod-current .nui-pod-title { color: var(--accent); }
+.nui-pod-row.nui-pod-current .nui-pod-title::before { content: '▶ '; }
+
+.nui-pod-art {
+ flex: none;
+ width: 52px;
+ height: 52px;
+ border-radius: 4px;
+ overflow: hidden;
+ background: #1c1c1c;
+}
+.nui-pod-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.nui-pod-art-big { width: 72px; height: 72px; }
+.nui-pod-art-mini { width: 44px; height: 44px; margin-left: 10px; }
+.nui-pod-art-player { width: min(72vw, 320px); height: min(72vw, 320px); border-radius: 8px; align-self: center; }
+
+.nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
+.nui-pod-title {
+ font-size: 15px;
+ font-weight: 700;
+ line-height: 1.3;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.nui-pod-meta {
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--nui-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.nui-pod-progress { height: 3px; margin-top: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; max-width: 160px; }
+.nui-pod-progress > span { display: block; height: 100%; background: var(--accent); }
+.nui-pod-badge {
+ flex: none;
+ padding: 3px 7px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ font-size: 11px;
+ color: var(--nui-muted);
+}
+.nui-pod-queued { color: var(--green); }
+
+.nui-pod-handle {
+ flex: none;
+ width: var(--nui-tap);
+ height: var(--nui-tap);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ color: var(--nui-muted);
+ touch-action: none;
+ cursor: grab;
+}
+.nui-pod-queue .nui-pod-row { transition: transform 0.15s ease; }
+.nui-pod-queue .nui-pod-row.nui-pod-dragging {
+ transition: none;
+ position: relative;
+ z-index: 2;
+ background: #1a1a1a;
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
+}
+
+.nui-pod-feedhead { display: flex; align-items: center; gap: 10px; margin: 4px 0 10px; }
+.nui-pod-switch {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ column-gap: 10px;
+ align-items: center;
+ padding: 10px 0;
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+}
+.nui-pod-switch input { width: 20px; height: 20px; accent-color: var(--accent); }
+.nui-pod-switch .nui-pod-meta { grid-column: 2; }
+
+.nui-pod-search { display: flex; gap: 8px; margin: 4px 0 10px; }
+.nui-pod-search input { flex: 1; min-width: 0; min-height: var(--nui-tap); font-size: 16px; }
+.nui-pod-search .btn { min-height: var(--nui-tap); }
+
+/* mini player in the bottom bar */
+#nui-pod-mini {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ height: 64px;
+ padding: 0 4px 0 0;
+ border-bottom: 1px solid var(--border);
+}
+#nui-pod-mini[hidden] { display: none; }
+.nui-pod-mini-bar { position: absolute; left: 0; right: 0; top: -1px; height: 2px; background: #2a2a2a; }
+.nui-pod-mini-bar span { display: block; height: 100%; background: var(--accent); }
+.nui-pod-skip { font-size: 20px; }
+
+/* full player */
+#nui-pod-player {
+ position: fixed;
+ inset: 0;
+ z-index: 880;
+ background: rgba(0, 0, 0, 0.6);
+ display: flex;
+ align-items: flex-end;
+}
+.nui-pod-player-panel {
+ width: 100%;
+ max-height: 100%;
+ overflow-y: auto;
+ background: #0b0b0b;
+ border-top: 1px solid var(--border);
+ border-radius: 14px 14px 0 0;
+ padding: 8px 20px calc(24px + env(safe-area-inset-bottom, 0px));
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+.nui-pod-player-top { display: flex; justify-content: center; }
+.nui-pod-player-top .nui-icon { font-size: 26px; color: var(--nui-muted); }
+.nui-pod-player-title { font-size: 18px; font-weight: 700; line-height: 1.3; text-align: center; }
+.nui-pod-player-feed { margin-top: -6px; font-size: 13px; color: var(--nui-muted); text-align: center; }
+.nui-pod-seek { width: 100%; accent-color: var(--accent); height: 28px; }
+.nui-pod-times { display: flex; justify-content: space-between; margin-top: -8px; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
+.nui-pod-controls { display: flex; align-items: center; justify-content: center; gap: 28px; }
+.nui-pod-controls .nui-icon { font-size: 18px; font-weight: 700; }
+.nui-pod-back::before { content: '↺'; font-size: 26px; margin-right: 2px; }
+.nui-pod-fwd::after { content: '↻'; font-size: 26px; margin-left: 2px; }
+.nui-pod-play {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: var(--accent) !important;
+ color: #fff !important;
+ font-size: 26px !important;
+}
+.nui-pod-extra { display: flex; justify-content: space-around; }
+.nui-pod-speed { font-size: 15px !important; font-weight: 700; min-width: 64px; }
+
+/* e-reader mode on a phone: black on white */
+body.eink .nui-pod-row { background: #fff; }
+body.eink .nui-pod-row:active { background: #ddd; }
+body.eink .nui-pod-count { background: #ddd; color: #000; }
+body.eink .nui-pod-tab.active { border-bottom-color: #000; color: #000; }
+body.eink .nui-pod-tab.active .nui-pod-count { background: #000; color: #fff; }
+body.eink .nui-pod-progress { background: #ccc; }
+body.eink .nui-pod-progress > span,
+body.eink .nui-pod-mini-bar span { background: #000; }
+body.eink .nui-pod-row.nui-pod-current .nui-pod-title { color: #000; }
+body.eink .nui-pod-player-panel { background: #fff; border-top: 2px solid #000; }
+body.eink .nui-pod-play { background: #000 !important; }
+body.eink .nui-pod-art { background: #eee; }
+
+/* podcast queue tools: sort, choose several, lock */
+.nui-pod-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.nui-pod-head .nui-pod-summary { padding: 6px 2px; }
+.nui-pod-tools { display: flex; align-items: center; margin-right: -6px; }
+.nui-pod-tools .nui-icon { font-size: 18px; color: var(--nui-muted); }
+.nui-pod-lock.on { color: var(--accent) !important; }
+.nui-pod-selbar {
+ position: sticky;
+ top: 0;
+ z-index: 5;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 6px;
+ margin: 0 -12px 4px;
+ padding: 6px 12px 8px;
+ background: var(--bg);
+ border-bottom: 1px solid var(--border);
+}
+.nui-pod-selcount { flex: 1; min-width: 0; font-size: 14px; font-weight: 700; }
+.nui-pod-selactions { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 6px; }
+.nui-pod-selactions .btn { min-height: 38px; flex: 1 0 auto; }
+.nui-pod-selactions .btn:disabled { opacity: 0.4; }
+.nui-pod-check { flex: none; width: 28px; font-size: 22px; color: var(--nui-muted); text-align: center; }
+.nui-pod-check.on { color: var(--accent); }
+.nui-pod-row.nui-pod-selecting { user-select: none; -webkit-user-select: none; }
+body.eink .nui-pod-check.on,
+body.eink .nui-pod-lock.on { color: #000 !important; }
+body.eink .nui-pod-selbar { background: #fff; }
+
+/* podcast playback: per-podcast chips, sleep timer */
+.nui-pod-feedprefs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
+.nui-pod-chip {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ min-height: 36px;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ background: none;
+ color: var(--nui-muted);
+ font: inherit;
+ font-size: 13px;
+}
+.nui-pod-chip strong { color: inherit; font-weight: 700; }
+.nui-pod-chip.on { border-color: var(--accent); color: inherit; }
+.nui-pod-chip.on strong { color: var(--accent); }
+.nui-pod-sleep { font-size: 15px !important; white-space: nowrap; }
+.nui-pod-sleep.on { color: var(--accent) !important; font-weight: 700; }
+/* unsubscribing: a quiet link, asked once more in place */
+.nui-pod-unsub { display: flex; justify-content: flex-end; padding: 6px 0; border-bottom: 1px solid var(--border); }
+.nui-pod-unsub-btn {
+ min-height: var(--nui-tap, 44px);
+ padding: 0 4px;
+ border: none;
+ background: none;
+ color: var(--nui-muted);
+ font: inherit;
+ font-size: 13px;
+}
+.nui-pod-unsub-ask { display: block; padding: 12px 0; }
+.nui-pod-unsub-ask .nui-pod-title { display: block; overflow: visible; }
+.nui-pod-unsub-ask .nui-pod-meta { margin-top: 4px; line-height: 1.4; white-space: normal; overflow: visible; }
+.nui-pod-unsub-actions { display: flex; gap: 8px; margin-top: 10px; }
+.nui-pod-unsub-actions .btn { min-height: var(--nui-tap, 44px); }
+.nui-pod-unsub-yes { color: var(--accent); border-color: var(--accent); font-weight: 700; }
+body.eink .nui-pod-unsub-yes { color: #000; border-color: #000; }
+body.eink .nui-pod-chip.on { border-color: #000; }
+body.eink .nui-pod-chip.on strong,
+body.eink .nui-pod-sleep.on { color: #000 !important; }
+
+/* podcast chapters and shownotes */
+.nui-pod-notesbox { margin-top: 6px; border-top: 1px solid var(--border); padding-top: 6px; min-height: 40px; }
+.nui-pod-subtabs { display: flex; gap: 4px; margin-bottom: 6px; }
+.nui-pod-subtab {
+ min-height: 36px;
+ padding: 0 12px;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: none;
+ color: var(--nui-muted);
+ font: inherit;
+ font-size: 14px;
+}
+.nui-pod-subtab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
+.nui-pod-chapters { list-style: none; margin: 0; padding: 0; }
+.nui-pod-chapter {
+ display: grid;
+ grid-template-columns: 4.5em 1fr;
+ gap: 10px;
+ align-items: baseline;
+ padding: 10px 4px;
+ border-bottom: 1px solid var(--border);
+ font-size: 14px;
+ cursor: pointer;
+}
+.nui-pod-chapter-t { color: var(--nui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
+.nui-pod-chapter.current { color: var(--accent); font-weight: 700; }
+.nui-pod-chapter.current .nui-pod-chapter-t { color: var(--accent); }
+.nui-pod-notes { font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
+.nui-pod-notes img { max-width: 100%; height: auto; }
+.nui-pod-notes a { color: var(--accent); }
+.nui-pod-notes .nui-pod-stamp { font-weight: 700; text-decoration: none; border-bottom: 1px dotted currentColor; }
+.nui-pod-notes table { display: block; overflow-x: auto; }
+.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
+.nui-pod-notes-play { align-self: center; min-height: var(--nui-tap); }
+body.eink .nui-pod-chapter.current,
+body.eink .nui-pod-chapter.current .nui-pod-chapter-t,
+body.eink .nui-pod-notes a { color: #000; }
+body.eink .nui-pod-subtab.active { border-bottom-color: #000; color: #000; }
+
+/* ---- podcast downloads (Downloads.kt; the "downloads" block of native-podcast.js) ---- */
+.nui-pod-dl { min-width: 36px !important; margin-left: -6px; font-size: 22px; color: var(--nui-muted); }
+.nui-pod-dl-failed { color: var(--yellow); }
+/* ✓ in a ring: set apart from the plain green ✓ of "in der Warteschlange" */
+.nui-pod-dl.nui-pod-dl-done {
+ position: relative;
+ font-size: 12px;
+ font-weight: 700;
+ color: var(--green);
+}
+.nui-pod-dl-done::after {
+ content: '';
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ left: 50%;
+ top: 50%;
+ margin: -11px 0 0 -11px;
+ border: 1px solid var(--green);
+ border-radius: 50%;
+}
+.nui-pod-dl-ring > span {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 9px;
+ font-weight: 700;
+ color: var(--nui-muted);
+ background:
+ radial-gradient(closest-side, var(--bg) 78%, transparent 80% 100%),
+ conic-gradient(var(--accent) var(--p, 0%), #2a2a2a 0);
+}
+body.eink .nui-pod-dl-done { color: #000; }
+body.eink .nui-pod-dl-done::after { border-color: #000; }
+body.eink .nui-pod-dl-ring > span {
+ color: #000;
+ background:
+ radial-gradient(closest-side, #fff 78%, transparent 80% 100%),
+ conic-gradient(#000 var(--p, 0%), #ccc 0);
+}
+
+/* tab row scrolls sideways without a visible bar; the lock emoji in the app's greys */
+.nui-pod-tabs::-webkit-scrollbar { display: none; }
+.nui-pod-lock { filter: grayscale(1); opacity: 0.6; }
+.nui-pod-lock.on { opacity: 1; filter: grayscale(1) brightness(1.4); }
+
+/* episode filters: one row that scrolls sideways */
+.nui-pod-filters { display: flex; gap: 6px; overflow-x: auto; padding: 6px 0 10px; scrollbar-width: none; }
+.nui-pod-filters::-webkit-scrollbar { display: none; }
+.nui-pod-filters .nui-pod-chip { flex: none; }
+.nui-pod-more { display: block; margin: 12px auto; min-height: var(--nui-tap); }
+.nui-pod-star { color: var(--yellow); }
+.nui-pod-fav.on { color: var(--yellow) !important; }
+.nui-pod-stat-total { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 0 18px; }
+.nui-pod-stat-total strong { font-size: 30px; }
+.nui-pod-stat-total span { font-size: 13px; color: var(--nui-muted); }
+.nui-pod-stat-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
+.nui-pod-stat-bar { max-width: none; height: 5px; }
+body.eink .nui-pod-star, body.eink .nui-pod-fav.on { color: #000 !important; }
+
+/* feed errors, chapter navigation, transcript */
+.nui-pod-feederr { font-size: 12px; color: var(--yellow); }
+.nui-pod-feederr-box { padding: 8px 10px; margin: 4px 0 8px; border: 1px solid var(--yellow); border-radius: 6px; line-height: 1.4; }
+.nui-pod-chapnav { display: flex; align-items: center; gap: 4px; margin-bottom: 4px; }
+.nui-pod-chapnow { flex: 1; min-width: 0; text-align: center; font-size: 13px; color: var(--nui-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.nui-pod-transcript { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
+.nui-pod-seg { margin: 0; padding: 6px 4px; font-size: 14px; line-height: 1.5; color: var(--nui-muted); border-radius: 4px; cursor: pointer; }
+.nui-pod-seg.current { color: inherit; background: #1a1a1a; }
+.nui-pod-speaker { color: inherit; }
+body.eink .nui-pod-feederr { color: #000; }
+body.eink .nui-pod-feederr-box { border-color: #000; }
+body.eink .nui-pod-seg { color: #444; }
+body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
diff --git a/static/podcasts/podcasts-web.css b/static/podcasts/podcasts-web.css
new file mode 100644
index 0000000..de7c0cc
--- /dev/null
+++ b/static/podcasts/podcasts-web.css
@@ -0,0 +1,189 @@
+/* Podcasts in the browser (static/js/podcasts-web.js): the app's podcast page
+ — its styles are podcast-ui.css, copied from diora-android's native.css by
+ tools/sync_podcast_ui.py — set into the web layout. The app's look stays
+ (black, monospace, red accent); on wide screens the podcast page, its mini
+ player and its sheets keep to one centred column. Only loaded in browsers;
+ everything is scoped to html.dpw. */
+
+html.dpw {
+ --nui-tap: 44px;
+ --nui-muted: #9a9a9a;
+ --nui-panel: #111;
+ --dpw-surface: #0b0b0b;
+ --dpw-hover: #111;
+ --dpw-col: 760px;
+}
+
+/* The placeholder shown until the page is there. */
+html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
+.dpw-loading { padding: 2rem 0; text-align: center; }
+
+html.dpw #nui-pod {
+ max-width: var(--dpw-col);
+ margin: 0 auto;
+}
+
+/* ---- what the app has in its other native styles ---------------------------------- */
+
+html.dpw .nui-icon {
+ min-width: var(--nui-tap);
+ min-height: var(--nui-tap);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ padding: 0;
+}
+html.dpw .nui-icon:hover { color: var(--fg); }
+
+html.dpw .nui-bar-text {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* ---- the dock: mini player right above the radio bar ----------------------------- */
+
+html.dpw #nui-dock {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: var(--dpw-bar-h, 72px);
+ z-index: 190; /* under the reader (200), the sidebar and the sheets */
+ max-width: var(--dpw-col);
+ margin: 0 auto;
+}
+html.dpw body.dnd-mode #nui-dock { display: none; }
+html.dpw #nui-pod-mini {
+ background: var(--dpw-surface);
+ border: 1px solid var(--border);
+ border-bottom: none;
+ border-radius: 10px 10px 0 0;
+ overflow: hidden;
+ box-shadow: 0 -6px 18px rgba(0, 0, 0, 0.5);
+}
+html.dpw .nui-pod-mini-bar { top: 0; }
+html.dpw.nui-pod-mini-open .main-content {
+ padding-bottom: calc(var(--dpw-bar-h, 72px) + 64px + 2rem);
+}
+
+html.dpw .nui-mini-text {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 2px;
+ padding: 0 14px;
+ background: none;
+ border: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+html.dpw .nui-mini-station,
+html.dpw .nui-mini-track {
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+html.dpw .nui-mini-station { font-size: 15px; font-weight: 700; }
+html.dpw .nui-mini-track { font-size: 12px; font-weight: 400; color: var(--nui-muted); }
+html.dpw .nui-mini-track:empty { display: none; }
+html.dpw .nui-mini-toggle { font-size: 22px; color: var(--accent); }
+
+#nui-radio-toast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--dpw-bar-h, 72px) + 80px);
+ transform: translateX(-50%);
+ z-index: 950;
+ max-width: calc(100% - 32px);
+ padding: 10px 16px;
+ border-radius: 6px;
+ background: #222;
+ color: #fff;
+ font-size: 14px;
+ text-shadow: none;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s;
+}
+#nui-radio-toast.nui-show { opacity: 1; }
+
+/* ---- a mouse: hover states the phone doesn't need --------------------------------- */
+
+@media (hover: hover) {
+ html.dpw #nui-pod .nui-pod-row { cursor: pointer; }
+ html.dpw #nui-pod .nui-pod-row:hover { background: var(--dpw-hover); }
+ html.dpw .nui-row:hover { background: #1a1a1a; }
+ html.dpw .nui-pod-chip:hover,
+ html.dpw .nui-pod-tab:hover,
+ html.dpw .nui-pod-subtab:hover { color: var(--fg); }
+ html.dpw .nui-pod-chapter:hover,
+ html.dpw .nui-pod-seg:hover { background: #141414; }
+}
+html.dpw .nui-row,
+html.dpw .nui-pod-tab,
+html.dpw .nui-pod-chip,
+html.dpw .nui-pod-subtab { cursor: pointer; }
+
+/* ---- wide screens: sheets and the player in the column ------------------------------ */
+
+@media (min-width: 761px) {
+ html.dpw .nui-panel {
+ margin: 0 auto;
+ max-width: 560px;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ }
+ html.dpw #nui-pod-player { justify-content: center; }
+ html.dpw .nui-pod-player-panel {
+ max-width: var(--dpw-col);
+ max-height: 92vh;
+ border: 1px solid var(--border);
+ border-bottom: none;
+ }
+ html.dpw .nui-pod-art-player { width: 240px; height: 240px; }
+ html.dpw .nui-pod-selbar { margin: 0 0 4px; padding: 6px 0 8px; }
+}
+
+/* Phones: the dock spans the screen like in the app. */
+@media (max-width: 760px) {
+ html.dpw #nui-pod-mini { border-left: none; border-right: none; border-radius: 0; box-shadow: none; }
+ html.dpw .nui-pod-selbar { margin: 0 -0.75rem 4px; padding: 6px 0.75rem 8px; }
+}
+
+/* ---- the web's light scheme (◑, body.bright-bg) ------------------------------------- */
+
+html.dpw body.bright-bg {
+ --nui-muted: #555;
+ --nui-panel: #f4f4f4;
+ --dpw-surface: #f7f7f7;
+ --dpw-hover: #f0f0f0;
+}
+/* app.css resolves --text-muted (the colour of .btn-icon) on :root, so it stays
+ white under body.bright-bg; inside the podcast parts it follows --fg. */
+html.dpw body.bright-bg #nui-pod,
+html.dpw body.bright-bg #nui-dock,
+html.dpw body.bright-bg #nui-sheet,
+html.dpw body.bright-bg #nui-pod-player { --text-muted: var(--fg); --text: var(--fg); }
+html.dpw body.bright-bg .nui-pod-player-panel { background: var(--dpw-surface); }
+html.dpw body.bright-bg .nui-pod-row:active,
+html.dpw body.bright-bg .nui-row:active,
+html.dpw body.bright-bg .nui-row:hover,
+html.dpw body.bright-bg .nui-pod-chapter:hover,
+html.dpw body.bright-bg .nui-pod-seg:hover,
+html.dpw body.bright-bg .nui-pod-seg.current,
+html.dpw body.bright-bg .nui-pod-queue .nui-pod-row.nui-pod-dragging { background: #e8e8e8; }
+html.dpw body.bright-bg .nui-pod-art { background: #e4e4e4; }
+html.dpw body.bright-bg .nui-pod-count,
+html.dpw body.bright-bg .nui-pod-progress,
+html.dpw body.bright-bg .nui-pod-mini-bar { background: #d4d4d4; }
+html.dpw body.bright-bg .nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
+html.dpw body.bright-bg #nui-pod-mini { box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); }
diff --git a/templates/radio/player.html b/templates/radio/player.html
index b8dbc5c..e80683d 100644
--- a/templates/radio/player.html
+++ b/templates/radio/player.html
@@ -22,23 +22,6 @@
⊙
◉
-
-
« 15
-
0:00
-
-
0:00
-
30 »
-
- ¾×
- 1×
- 1¼×
- 1½×
- 1¾×
- 2×
- 2½×
-
-
Sleep
-