From 76a164709d3effe10f30fcd3574d4737b4a08b3a Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 2 Oct 2026 10:26:32 +0200 Subject: [PATCH] Podcasts im Browser: die Podcast-Seite der App statt des alten Podcast-Tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Podcast-Tab der Web-App zeigt jetzt dieselbe Oberfläche wie die Android-App: native-podcast.js aus diora-android, unverändert kopiert nach static/podcasts/ (tools/sync_podcast_ui.py, mit Quell-Commit im Kopf), dazu die Podcast-Teile von native.css. static/js/podcasts-web.js spielt im Browser die Rolle der App (window.DioraPodcastHost statt DioraNative): - Zustand aus /podcasts/state/, in localStorage für einen schnellen Start; Änderungen sofort lokal und der Reihe nach an den Server (Outbox, übersteht Neuladen und Offline), wie Podcasts.kt, inkl. „immer sortiert halten“ - eigener Player (nicht das Radio-Audio-Element), Warteschlange = Playlist, wie RadioService.EpisodeTracker: Stelle fortsetzen, Intro/Outro, Fortschritt alle 10 s und bei Pause, „gehört“ kurz vor Schluss (auch bei „nächste“), automatisch weiter, nach Pause etwas zurück (0,8/3/10/20 s), Tempo und Lautstärke (bis 1) pro Podcast, Sleep-Timer nur während des Abspielens mit Ausblenden - Sperrbildschirm und Medientasten über navigator.mediaSession - Abspiel-Einstellungen dieses Geräts in localStorage (wie PlaybackPrefs) - keine Downloads (die Oberfläche blendet sie aus) - Radio und Podcast nie gleichzeitig: eine Folge stoppt das Radio, ein Sender legt die Folge weg - Bottom-Sheet und Mini-Player-Dock über der Radio-Leiste; auf breiten Bildschirmen eine mittige Spalte (760 px) In der App und der Desktop-App (window.DioraNative vorhanden) tut podcasts-web.js nichts. Entfernt: der alte Podcast-Tab (Markup, ~1000 Zeilen in app.js, CSS), die Podcast-Leiste im Player, INITIAL_PODCAST_FEEDS und das Herunterladen von Folgen in den Browser-Cache (der Service Worker räumt diora-podcast-v1 ab). Service-Worker-Cache auf v52, neue Dateien im App-Shell. Co-Authored-By: Claude Opus 5.5 --- radio/tests.py | 40 + radio/views.py | 8 - static/css/app.css | 324 ----- static/js/app.js | 1073 ---------------- static/js/podcasts-web.js | 1069 ++++++++++++++++ static/js/sw.js | 27 +- static/podcasts/native-podcast.js | 1901 +++++++++++++++++++++++++++++ static/podcasts/podcast-ui.css | 529 ++++++++ static/podcasts/podcasts-web.css | 189 +++ templates/radio/player.html | 97 +- tools/sync_podcast_ui.py | 78 ++ 11 files changed, 3817 insertions(+), 1518 deletions(-) create mode 100644 radio/tests.py create mode 100644 static/js/podcasts-web.js create mode 100644 static/podcasts/native-podcast.js create mode 100644 static/podcasts/podcast-ui.css create mode 100644 static/podcasts/podcasts-web.css create mode 100644 tools/sync_podcast_ui.py 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)}
-
- - `; - // 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)}
` : ''} -
-
- - - - -
- `; - 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 = ` -
- ${feed.artwork_url ? `` : ''} -
-
${escapeHtml(feed.title)}
- ${feed.author ? `
${escapeHtml(feed.author)}
` : ''} -
- -
- `; - } - - 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 ? `
` : ''} -
-
- - - - - -
- `; - 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 ? `
` : ''} -
-
- - - - -
- `; - 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 @@ -
    focus 25:00 @@ -228,82 +211,8 @@