diff --git a/podcasts/antennapod.py b/podcasts/antennapod.py index 2302dc6..456a92e 100644 --- a/podcasts/antennapod.py +++ b/podcasts/antennapod.py @@ -261,6 +261,8 @@ class Stats: downloads_missing: int = 0 downloads_offered: bool = True ignored_left_inbox: int = 0 + queue_removed: int = 0 + removed_titles: list = field(default_factory=list) diora_only_left_inbox: int = 0 backup_date: datetime | None = None seconds: float = 0.0 @@ -295,7 +297,7 @@ def _columns(db, table): class AntennaPodImport: def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True, - quiet_diora_only=False): + quiet_diora_only=False, replace=False): self.user = user self.path = path self.backup_date = backup_date @@ -305,6 +307,10 @@ class AntennaPodImport: # True: subscriptions only diora has keep their episodes, but the # ones out before the backup leave the inbox. self.quiet_diora_only = quiet_diora_only + # A clean slate: AntennaPod's state is the new start. Podcasts only + # diora has are unsubscribed, the queue is AntennaPod's alone, and + # AntennaPod wins also over later changes in diora. + self.replace = replace self.stats = Stats(backup_date=backup_date) def run(self, dry_run=False): @@ -338,6 +344,8 @@ class AntennaPodImport: self.new_feed = PodcastSettings.new_feed_defaults(user) feed_map = self._import_feeds(db) # AntennaPod feed id -> diora feed id + if self.replace: + self._remove_diora_only(feed_map) queue_items = [r[0] for r in db.execute('SELECT feeditem FROM Queue ORDER BY id')] favorite_items = {r[0] for r in db.execute('SELECT feeditem FROM Favorites')} st.queue_backup = len(queue_items) @@ -589,7 +597,7 @@ class AntennaPodImport: def diora_newer(p, activity): # Changed in diora after the backup was made (and after the # episode was last played in AntennaPod): diora's state stays. - return p.updated_at > backup and (activity is None or p.updated_at > activity) + return not self.replace and p.updated_at > backup and (activity is None or p.updated_at > activity) new_rows, changed, favorite_only, dismiss_only = [], [], [], [] for ep_id, s in states.items(): @@ -670,7 +678,7 @@ class AntennaPodImport: st.diora_only_left_inbox += 1 # Changed in diora after the backup: diora's state stays. newer = set() - for chunk in _chunks(ids): + for chunk in ([] if self.replace else _chunks(ids)): newer.update(EpisodeProgress.objects.filter(user=user, episode_id__in=chunk, updated_at__gt=backup) .values_list('episode_id', flat=True)) ids = [i for i in ids if i not in newer] @@ -685,6 +693,14 @@ class AntennaPodImport: ignore_conflicts=True, ) + def _remove_diora_only(self, feed_map): + """Unsubscribe the podcasts the backup doesn't have (their episodes, + states and queue entries go with them).""" + st = self.stats + gone = PodcastFeed.objects.filter(user=self.user).exclude(pk__in=set(feed_map.values())) + st.removed_titles = sorted(gone.values_list('title', flat=True)) + gone.delete() + # -- queue -------------------------------------------------------------- def _import_queue(self, queue_items, item_episode): @@ -703,6 +719,12 @@ class AntennaPodImport: return in_front = set(front) rest = [ep_id for ep_id in before if ep_id not in in_front] + if self.replace and rest: + for chunk in _chunks(rest): + PodcastQueue.objects.filter(user=user, episode_id__in=chunk).delete() + st.queue_removed = len(rest) + before = [ep_id for ep_id in before if ep_id in in_front] + rest = [] st.queue_appended = len(rest) present = set(before) PodcastQueue.objects.bulk_create( @@ -764,7 +786,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download): + (' -- DRY RUN, nothing was written' if dry_run else ''), f'Feeds: {st.backup_feeds} in backup, {subscribed} subscribed ({st.not_subscribed} not subscribed ' f'ignored); {st.matched} matched, {st.created} created, {len(st.skipped_feeds)} skipped (not http/https), ' - f'{st.duplicates} duplicates', + f'{st.duplicates} duplicates' + + (f', {len(st.removed_titles)} only in diora unsubscribed (--replace)' if st.removed_titles else ''), f'Settings: changed: {settings}', f' {st.not_kept_updated} feeds not kept updated in AntennaPod -> new episodes "ignore"', f'Auto-download: on for {st.auto_download_on} feeds ({st.auto_download_explicit} on in AntennaPod, ' @@ -779,7 +802,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download): f'{st.left_inbox} taken out of the inbox (not new in AntennaPod)', f'Favourites: {st.favorites_backup} in backup; {st.favorites_before} before, {st.favorites_after} after', f'Queue: {st.queue_backup} in backup ({st.queue_missing} not found); {st.queue_before} before, ' - f'{st.queue_after} after ({st.queue_appended} diora entries after AntennaPod\'s)' + f'{st.queue_after} after ({st.queue_appended} diora entries after AntennaPod\'s' + + (f', {st.queue_removed} removed (--replace)' if st.queue_removed else '') + ')' + (f'; kept sorted by "{st.keep_sorted}"' if st.keep_sorted else ''), f'Inbox: {st.inbox_before} before, {st.inbox_after} after; out of it: {st.ignored_left_inbox} of podcasts ' f'whose new episodes are ignored (AntennaPod never showed them)' @@ -790,6 +814,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download): + (f'; {st.downloads_missing} not found (feeds not imported, no audio URL)' if st.downloads_missing else ''), f'Created feeds ({len(st.created_titles)}):', *(f' - {t}' for t in st.created_titles), + *([f'Unsubscribed feeds ({len(st.removed_titles)}):'] if st.removed_titles else []), + *(f' - {t}' for t in st.removed_titles), f'Skipped feeds ({len(st.skipped_feeds)}):', *(f' - {t} ({u})' for t, u in st.skipped_feeds), f'Done in {st.seconds:.1f} s.', diff --git a/podcasts/management/commands/import_antennapod.py b/podcasts/management/commands/import_antennapod.py index 461968d..bb0e8ca 100644 --- a/podcasts/management/commands/import_antennapod.py +++ b/podcasts/management/commands/import_antennapod.py @@ -25,6 +25,9 @@ class Command(BaseCommand): help='Do everything inside a transaction that is rolled back, print the summary') parser.add_argument('--global-auto-download', choices=('on', 'off'), default='off', help="Auto-download for feeds that use AntennaPod's global setting (default off)") + parser.add_argument('--replace', action='store_true', + help="A clean slate: unsubscribe podcasts the backup doesn't have, keep only " + "AntennaPod's queue, AntennaPod wins over later changes in diora") parser.add_argument('--quiet-diora-only', action='store_true', help='Podcasts only diora has (not in the backup): their episodes out before the backup ' 'leave the inbox (the subscriptions stay)') @@ -57,7 +60,8 @@ class Command(BaseCommand): try: stats = AntennaPodImport(user, path, backup_date, global_auto_download, offer_downloads=not options['no_downloads'], - quiet_diora_only=options['quiet_diora_only']).run(dry_run=options['dry_run']) + quiet_diora_only=options['quiet_diora_only'], + replace=options['replace']).run(dry_run=options['dry_run']) except AntennaPodImportError as e: raise CommandError(str(e)) for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download): diff --git a/podcasts/test_antennapod.py b/podcasts/test_antennapod.py index 3d39dbd..91207c1 100644 --- a/podcasts/test_antennapod.py +++ b/podcasts/test_antennapod.py @@ -612,6 +612,28 @@ class QuietInboxTests(ImportTestCase): self.assertEqual(self.inbox(), ['ap-new', 'kept-unknown', 'only-late', 'unknown-late']) self.assertIn(', 1 of podcasts only diora has', out) + def test_replace_is_a_clean_slate(self): + archived = PodcastFeed.objects.get(rss_url='https://example.org/archived') + ap_new = PodcastEpisode.objects.get(guid='ap-new') + old = PodcastEpisode.objects.get(guid='unknown-old') + late = PodcastEpisode.objects.get(guid='unknown-late') + PodcastQueue.objects.create(user=self.user, episode=late, position=0) + other = PodcastEpisode.objects.get(guid='kept-unknown') + PodcastQueue.objects.create(user=self.user, episode=other, position=1) + EpisodeProgress.objects.create(user=self.user, episode=old, position_seconds=30) # changed in diora now + queued = self.ap.item(self.ap_feed, 'unknown-late', read=0, pub=None) + self.ap.queue(queued) + out = self.run_import('--replace') + self.assertFalse(PodcastFeed.objects.filter(rss_url='https://example.org/only-diora').exists()) + self.assertFalse(PodcastEpisode.objects.filter(guid__startswith='only-').exists()) + self.assertEqual(self.queue(), [late.id]) + self.assertTrue(self.progress(old).dismissed) # AntennaPod wins + self.assertIn('ap-new', self.inbox()) + self.assertTrue(archived.episodes.filter(id=ap_new.id).exists()) + self.assertIn(', 1 only in diora unsubscribed (--replace)', out) + self.assertIn(', 1 removed (--replace))', out) + self.assertIn('Unsubscribed feeds (1):\n - Diora\n', out) + def test_diora_newer_stays(self): ep = PodcastEpisode.objects.get(guid='unknown-old') EpisodeProgress.objects.create(user=self.user, episode=ep, position_seconds=30) # updated now diff --git a/static/js/sw.js b/static/js/sw.js index 753418d..94ffcac 100644 --- a/static/js/sw.js +++ b/static/js/sw.js @@ -2,7 +2,7 @@ * diora service worker — caches the app shell for offline use. */ -const CACHE = 'diora-v53'; +const CACHE = 'diora-v54'; const SHELL = [ '/static/css/app.css', '/static/js/app.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index d9ceb97..c9e7919 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android f94c501 2026-10-02: web-native/static/native-podcast.js +// Copied from diora-android 6c2b071 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. @@ -262,7 +262,8 @@ playedItem(), headsetItem(), ]), - DL && group('↓', 'Downloads', 'automatisch, Limit, aufräumen', () => [ + DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads}, + DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [ // Account-wide (server): new subscriptions start with auto-download on or off. 'auto_download_default' in s && {icon: s.auto_download_default ? '☑' : '☐', label: 'Neue Abos laden von selbst', hint: s.auto_download_default ? 'an: neue Folgen neuer Abos werden geladen' : 'aus: pro Podcast einschaltbar', @@ -477,7 +478,8 @@ 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)); + bar.append(...[text, dlIndicator(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)), + button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean)); root.append(bar); if (feedView) { @@ -500,6 +502,8 @@ tabs.append(b); } root.append(tabs); + const offer = importOffer(); + if (offer) root.append(offer); if (view === 'queue') renderQueue(); else if (view === 'inbox') renderInbox(); else if (view === 'episodes') renderEpisodes(); @@ -509,6 +513,31 @@ scrollTop(scroll); } + // AntennaPod had episodes on the phone: offered once to fetch them again (Wi-Fi only). + function importOffer() { + const o = state.import_downloads; + if (!o || !o.count || !N.podcastImportDownloads || !N.podcastDownloadSettings) return null; + const box = el('div', 'nui-pod-offer'); + const s = parse(N.podcastDownloadSettings(), {}); + const needed = (Object.keys(downloads).length || 0) + o.count + (s.auto ? s.count || 0 : 0); + const raise = s.limit > 0 && needed > s.limit + ? ` „Höchstens behalten“ geht dafür von ${s.limit} auf ${[5, 10, 25, 50, 100, 500].find(n => n >= needed) || 'unbegrenzt'}.` : ''; + box.append(el('div', null, `${o.count} ${o.count === 1 ? 'Folge war' : 'Folgen waren'} in AntennaPod heruntergeladen` + + (o.bytes ? ` (${size(o.bytes)})` : '') + '. Hier noch einmal laden? Nur im WLAN; sie kommen nicht in die Warteschlange.' + raise)); + const row = el('div', 'nui-pod-offer-row'); + row.append(button('Laden', 'Im WLAN herunterladen', () => { N.podcastImportDownloads(true); box.remove(); }, 'btn'), + button('Nein danke', 'Nicht laden', () => { N.podcastImportDownloads(false); box.remove(); }, 'btn')); + box.append(row); + return box; + } + + window.__dioraImportDownloads = function (r) { + if (!r || !r.ok) toast('Die Liste aus AntennaPod kam nicht an — später noch einmal'); + else if (r.count) toast(`${r.count} ${r.count === 1 ? 'Folge wird' : 'Folgen werden'} im WLAN geladen` + + (r.limit != null ? ` · Höchstens behalten: ${r.limit || 'unbegrenzt'}` : '')); + render(); + }; + function empty(text) { root.append(el('div', 'nui-pod-empty', text)); } @@ -1272,11 +1301,15 @@ 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); + text.addEventListener('click', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer())); mini.append(art('', 'mini'), text, button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'), - button('▶', 'Abspielen', () => N.podcastToggle(), 'nui-mini-toggle')); + button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'), + button('▶', 'Abspielen', () => (miniKind === 'radio' + ? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle')); dock.prepend(mini); + // One player for podcasts and radio: the radio's own bar steps aside. + document.documentElement.classList.add('nui-one-player'); // A "Podcasts" button in the bottom bar, between books and radio. const tabbar = $id('nui-tabbar'); @@ -1290,12 +1323,49 @@ } } + // What the bar shows: 'podcast', 'radio' (a station is chosen) or ''. + let miniKind = ''; + + function radioNow() { + return typeof window.__dioraRadioNow === 'function' ? window.__dioraRadioNow() : null; + } + + function setArt(box, src) { + const img = box.querySelector('img'); + src = (src || '').replace(/^http:/, 'https:'); + if (!img && src) { + const i = el('img'); + i.alt = ''; + i.src = src; + i.addEventListener('error', () => i.remove()); + box.append(i); + } else if (img && img.getAttribute('src') !== src) { + if (src) img.src = src; else img.remove(); + } + } + 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); + const radio = isPod ? null : radioNow(); + miniKind = isPod ? 'podcast' : radio ? 'radio' : ''; + mini.hidden = !miniKind; + mini.classList.toggle('nui-mini-radio', miniKind === 'radio'); + document.documentElement.classList.toggle('nui-pod-mini-open', !!miniKind); + mini.querySelector('.nui-pod-skip').hidden = miniKind !== 'podcast'; + mini.querySelector('.nui-mini-save').hidden = miniKind !== 'radio' || radio.saved; + if (miniKind === 'radio') { + mini.querySelector('.nui-mini-station').textContent = radio.name; + mini.querySelector('.nui-mini-track').textContent = radio.buffering ? 'Verbinde …' + : radio.playing ? (radio.track || '● Live') : 'Angehalten'; + const t = mini.querySelector('.nui-mini-toggle'); + t.textContent = radio.playing ? '■' : '▶'; + t.title = radio.playing ? 'Stopp' : 'Abspielen'; + setArt(mini.querySelector('.nui-pod-art'), radio.logo); + mini.querySelector('.nui-pod-mini-bar span').style.width = radio.playing ? '100%' : '0'; + return; + } 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). @@ -1310,11 +1380,7 @@ 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(); } + setArt(mini.querySelector('.nui-pod-art'), player.artwork); mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0'; } @@ -1567,9 +1633,13 @@ return true; } + // The full player: one for both. Which one it shows now. + let sheetKind = ''; + function openPlayer() { if (player.kind !== 'podcast') return; closePlayer(); + sheetKind = 'podcast'; sheet = el('div'); sheet.id = 'nui-pod-player'; sheet.innerHTML = ''; @@ -1632,8 +1702,75 @@ return true; } + // The radio in the same player: the station instead of the episode, "Live" + // instead of the seek bar, save / stop / sleep timer as the buttons. + function openRadioPlayer() { + const r = radioNow(); + if (!r) return; + closePlayer(); + sheetKind = 'radio'; + sheet = el('div'); + sheet.id = 'nui-pod-player'; + sheet.innerHTML = ''; + const panel = sheet.firstChild; + const top = el('div', 'nui-pod-player-top'); + top.append(button('⌄', 'Player schließen', closePlayer)); + const cover = art(r.logo, 'player'); + cover.classList.add('nui-radio-art'); + panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), + el('div', 'nui-radio-live')); + const controls = el('div', 'nui-pod-controls'); + controls.append( + button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-pod-fav nui-radio-save'), + button('▶', 'Abspielen', () => window.__dioraRadioToggle && window.__dioraRadioToggle(), 'nui-pod-play'), + button('☾', 'Sleep-Timer', () => sleepMenu(true), 'nui-pod-sleep'), + ); + const extra = el('div', 'nui-pod-extra'); + const stations = el('button', 'btn nui-radio-stations', 'Sender wechseln'); + stations.type = 'button'; + stations.addEventListener('click', () => { closePlayer(); showTab('radio'); }); + extra.append(stations); + panel.append(controls, extra); + sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); }); + document.body.append(sheet); + renderRadioPlayer(); + } + + function renderRadioPlayer() { + const r = radioNow(); + if (!r) { closePlayer(); return; } + sheet.querySelector('.nui-pod-player-title').textContent = r.name; + sheet.querySelector('.nui-pod-player-feed').textContent = r.track || ''; + const live = sheet.querySelector('.nui-radio-live'); + live.textContent = r.buffering ? 'Verbinde …' : r.playing ? '● Live' : 'Angehalten'; + live.classList.toggle('on', r.playing && !r.buffering); + const p = sheet.querySelector('.nui-pod-play'); + p.textContent = r.playing ? '■' : '▶'; + p.title = r.playing ? 'Stopp' : 'Abspielen'; + const s = sheet.querySelector('.nui-radio-save'); + s.textContent = r.saved ? '★' : '☆'; + s.classList.toggle('on', r.saved); + s.disabled = r.saved; + s.title = r.saved ? 'Gespeichert' : 'Sender speichern'; + const sl = sheet.querySelector('.nui-pod-sleep'); + sl.textContent = sleepLabel() || '☾'; + sl.classList.toggle('on', !!sleepLabel()); + setArt(sheet.querySelector('.nui-pod-art-player'), r.logo); + } + + // native-radio.js: the station, its track or its state changed. + window.__dioraRadioChanged = function () { + renderMini(); + if (sheet && sheetKind === 'radio') renderRadioPlayer(); + }; + window.__dioraOpenPlayer = kind => (kind === 'radio' ? openRadioPlayer() : openPlayer()); + function renderPlayer() { if (!sheet) return; + if (sheetKind === 'radio') { + if (player.kind === 'podcast') closePlayer(); else renderRadioPlayer(); + return; + } if (player.kind !== 'podcast') { closePlayer(); return; } if (sheetEpisode !== player.episodeId) { // The next episode started while the player was open. @@ -1697,7 +1834,7 @@ } // Minutes count only while playing; shaking the phone starts them over. - function sleepMenu() { + function sleepMenu(radio) { if (!N.podcastSleep) return; const t = player.sleep || {}; const on = !!sleepLabel(); @@ -1712,9 +1849,11 @@ 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 (radio !== true) { + items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); + items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); + items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); + } 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', @@ -1739,7 +1878,15 @@ const DL = !!N.podcastDownloads; let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; + // All at a glance (Downloads.infoJson): paused, how many run and wait, done of all in this round. + let dlInfo = DL && N.podcastDownloadsInfo ? parse(N.podcastDownloadsInfo(), {}) : {}; const DL_COUNTS = [1, 2, 3, 5, 10]; + const DL_PARALLEL = [1, 2, 3, 4]; + // Why a download waits (DownloadPlan.waitReason). + const DL_REASONS = { + paused: 'pausiert', wifi: 'wartet auf WLAN', offline: 'wartet auf eine Verbindung', queue: 'in der Reihe', + retry: 'neuer Versuch', + }; // 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 = [ @@ -1756,7 +1903,8 @@ function dlStart(e) { N.podcastDownload(JSON.stringify(e.raw)); - window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0}})); + window.__dioraDownloads(Object.assign({}, downloads, + {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'}})); toast('Wird heruntergeladen'); } @@ -1789,7 +1937,15 @@ 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 + ''; + b.firstChild.textContent = d.state === 'queued' ? (d.reason === 'paused' ? '‖' : '…') : d.progress + ''; + } + + // "wartet auf WLAN", "neuer Versuch um 14:05" … + function dlReason(d) { + if (d.reason === 'retry' && d.retryAt) { + return 'neuer Versuch um ' + new Date(d.retryAt).toLocaleTimeString('de-DE', {hour: '2-digit', minute: '2-digit'}); + } + return DL_REASONS[d.reason] || 'wartet'; } function dlMenuItems(e) { @@ -1800,8 +1956,12 @@ 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)}]; + return [ + d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst && + {icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(e.id)}, + {icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? dlReason(d) : d.progress + ' %', + action: () => dlDelete(e, true)}, + ]; } function dlSet(changes) { @@ -1823,6 +1983,10 @@ action: () => dlSet({count: n})})))}, s.auto && {icon: s.mobile ? '☑' : '☐', label: 'Auch über mobile Daten', hint: s.mobile ? 'nicht nur im WLAN' : 'sonst nur im WLAN', action: () => dlSet({mobile: !s.mobile})}, + s.parallel != null && {icon: '⇉', label: 'Gleichzeitig laden', hint: s.parallel === 1 ? '1 Folge' : s.parallel + ' Folgen', + action: () => openSheet('Gleichzeitig laden', DL_PARALLEL.map(n => ({ + icon: s.parallel === n ? '●' : '○', label: n === 1 ? '1 Folge' : n + ' Folgen', active: s.parallel === n, + hint: n === 3 ? 'Standard' : null, action: () => dlSet({parallel: n})})))}, {icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen', hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten', action: () => dlSet({deletePlayed: !s.deletePlayed})}, @@ -1848,17 +2012,216 @@ ]; } - window.__dioraDownloads = function (map) { + window.__dioraDownloads = function (map, info) { const before = downloads; downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {}; + if (info) dlInfo = typeof info === 'string' ? parse(info, dlInfo) : info; const keys = Object.keys(downloads); const same = keys.length === Object.keys(before).length && - keys.every(id => before[id] && before[id].state === downloads[id].state); + keys.every(id => before[id] && before[id].state === downloads[id].state && before[id].reason === downloads[id].reason); // Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going). - if (same) document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing); - else render(); + if (same) { + document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing); + dlIndicatorText(); + } else { + render(); + } + renderDownloads(); }; + function dlCounts() { + const c = {running: 0, waiting: 0, failed: 0, done: 0, bytes: 0}; + for (const d of Object.values(downloads)) { + if (d.state === 'downloading') c.running++; + else if (d.state === 'queued') c.waiting++; + else if (d.state === 'failed') c.failed++; + else if (d.state === 'done') { c.done++; c.bytes += d.bytes || 0; } + } + return c; + } + + function dlSummary() { + const c = dlCounts(); + const parts = []; + if (c.running) parts.push(c.running + (c.running === 1 ? ' läuft' : ' laufen')); + if (c.waiting) parts.push(c.waiting + (c.waiting === 1 ? ' wartet' : ' warten') + (dlInfo.paused ? ' (pausiert)' : '')); + if (c.failed) parts.push(c.failed + ' fehlgeschlagen'); + if (c.done) parts.push(c.done + ' fertig'); + return parts.join(' · ') || 'keine'; + } + + // The top bar's sign while downloads run or wait: "↓ 3/68" (the one loading of all in this round, + // like the notification), "‖ 68" while paused. Opens the overview. + function dlIndicator() { + if (!DL || !N.podcastDownloadsInfo) return null; + const b = button('', 'Downloads', openDownloads, 'nui-pod-dlind'); + b.id = 'nui-pod-dlind'; + dlIndicatorText(b); + return b; + } + + function dlIndicatorText(b) { + b = b || $id('nui-pod-dlind'); + if (!b) return; + const c = dlCounts(); + const busy = c.running + c.waiting; + b.hidden = !busy; + if (!busy) return; + const total = Math.max(dlInfo.total || 0, busy); + const done = Math.min(dlInfo.done || 0, total); + b.textContent = dlInfo.paused ? '‖ ' + busy : c.running ? `↓ ${Math.min(done + 1, total)}/${total}` : '↓ ' + busy; + b.title = 'Downloads: ' + dlSummary(); + b.setAttribute('aria-label', b.title); + } + + // ---- the downloads overview: what runs, waits, failed, is done --------------------------- + + let dlSheet = null; + let dlBody = null; + + function closeDownloads() { + if (!dlSheet) return false; + dlSheet.remove(); + dlSheet = dlBody = null; + return true; + } + + function openDownloads() { + if (!DL) return; + closeDownloads(); + dlSheet = el('div'); + dlSheet.id = 'nui-pod-player'; + dlSheet.className = 'nui-pod-notes-sheet nui-pod-dls'; + const panel = el('div', 'nui-pod-player-panel'); + const top = el('div', 'nui-pod-player-top'); + top.append(button('⌄', 'Schließen', closeDownloads)); + dlBody = el('div', 'nui-pod-dls-body'); + panel.append(top, el('div', 'nui-pod-player-title', 'Downloads'), dlBody); + dlSheet.append(panel); + dlSheet.addEventListener('click', ev => { if (ev.target === dlSheet) closeDownloads(); }); + document.body.append(dlSheet); + renderDownloads(true); + } + + function dlTitle(d) { + return plain(d.title) || 'Folge ' + d.id; + } + + // The line under the title: how far, why it waits, what went wrong. + function dlStatus(d) { + const of = d.total ? `${size(d.bytes)} von ${size(d.total)}` : size(d.bytes); + if (d.state === 'downloading') return (d.total ? d.progress + ' % · ' : '') + of; + if (d.state === 'failed') return d.error || 'hat nicht geklappt'; + const parts = [dlReason(d)]; + if (d.bytes) parts.push(of + ' geladen'); + if (d.reason === 'retry' && d.error) parts.push(d.error); + return parts.join(' · '); + } + + function dlActions(d) { + const items = []; + if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) { + items.push({icon: '⇈', label: 'Zuerst laden', hint: 'vor die anderen wartenden', action: () => N.podcastDownloadFirst(d.id)}); + } + if ((d.state === 'failed' || (d.state === 'queued' && d.error)) && N.podcastRetryDownload) { + items.push({icon: '↻', label: 'Erneut versuchen', hint: d.error || null, action: () => N.podcastRetryDownload(d.id)}); + } + items.push(d.state === 'failed' + ? {icon: '✕', label: 'Löschen', danger: true, action: () => N.podcastDeleteDownload(d.id)} + : {icon: '✕', label: 'Abbrechen', hint: d.bytes ? 'was schon geladen ist, geht mit' : null, danger: true, + action: () => N.podcastDeleteDownload(d.id)}); + return items; + } + + function dlRow(d) { + const row = el('div', 'nui-pod-dlrow' + (d.state === 'failed' ? ' nui-pod-dlrow-failed' : '')); + row.dataset.dlrow = d.id; + const main = el('div', 'nui-pod-main'); + main.append(el('div', 'nui-pod-title', dlTitle(d))); + const f = d.feedId && feeds().find(x => x.id === d.feedId); + const meta = el('div', 'nui-pod-meta nui-pod-dlstatus'); + main.append(meta); + if (f) main.append(el('div', 'nui-pod-meta', plain(f.title))); + if (d.state === 'downloading' || (d.state === 'queued' && d.bytes)) { + const bar = el('div', 'nui-pod-progress nui-pod-stat-bar'); + bar.append(el('span')); + main.append(bar); + } + row.append(main); + // The likely tap right there; all of them in the row's sheet. + if (d.state === 'queued' && d.order > 0 && N.podcastDownloadFirst) { + row.append(button('⇈', 'Zuerst laden', () => N.podcastDownloadFirst(d.id))); + } + if (d.state === 'failed' && N.podcastRetryDownload) { + row.append(button('↻', 'Erneut versuchen', () => N.podcastRetryDownload(d.id))); + } + row.append(button('✕', d.state === 'failed' ? 'Löschen' : 'Abbrechen', () => N.podcastDeleteDownload(d.id))); + row.addEventListener('click', () => openSheet(dlTitle(d), dlActions(d))); + dlRowNumbers(row, d); + return row; + } + + function dlRowNumbers(row, d) { + row.querySelector('.nui-pod-dlstatus').textContent = dlStatus(d); + const fill = row.querySelector('.nui-pod-progress > span'); + if (fill) fill.style.width = (d.total ? Math.round(d.bytes / d.total * 100) : 0) + '%'; + } + + function renderDownloads(force) { + if (!dlBody) return; + const all = Object.keys(downloads).map(id => Object.assign({id: +id}, downloads[id])); + const running = all.filter(d => d.state === 'downloading'); + const waiting = all.filter(d => d.state === 'queued').sort((a, b) => (a.order || 0) - (b.order || 0)); + const failed = all.filter(d => d.state === 'failed'); + const done = all.filter(d => d.state === 'done'); + // The same rows as before: only the numbers move (keeps a tap or the scroll going). + const sig = [running, waiting, failed].map(l => l.map(d => `${d.id}:${d.reason || ''}:${d.error || ''}`).join(',')) + .join('|') + `|${done.length}|${!!dlInfo.paused}`; + if (!force && sig === dlBody.dataset.sig) { + for (const d of running.concat(waiting)) { + const row = dlBody.querySelector(`[data-dlrow="${d.id}"]`); + if (row) dlRowNumbers(row, d); + } + return; + } + dlBody.dataset.sig = sig; + const panel = dlBody.parentElement; + const scroll = panel.scrollTop; + dlBody.textContent = ''; + + const head = el('div', 'nui-pod-dls-head'); + head.append(el('div', 'nui-pod-summary', dlSummary())); + const tools = el('div', 'nui-pod-offer-row'); + if (N.podcastPauseDownloads && (running.length || waiting.length || dlInfo.paused)) { + const paused = !!dlInfo.paused; + tools.append(button(paused ? '▶ Fortsetzen' : '‖ Alle pausieren', paused ? 'Downloads fortsetzen' : 'Alle Downloads pausieren', + () => { dlInfo.paused = !paused; N.podcastPauseDownloads(!paused); renderDownloads(true); }, 'btn')); + } + if (failed.length > 1 && N.podcastRetryDownload) { + tools.append(button('↻ Alle erneut', 'Alle fehlgeschlagenen erneut versuchen', () => N.podcastRetryDownload(0), 'btn')); + } + if (tools.childNodes.length) head.append(tools); + dlBody.append(head); + + const section = (title, list) => { + if (!list.length) return; + dlBody.append(el('div', 'nui-pod-dls-sec', `${title} (${list.length})`)); + const box = el('div', 'nui-pod-list'); + for (const d of list) box.append(dlRow(d)); + dlBody.append(box); + }; + section('Läuft', running); + section('Wartet', waiting); + section('Fehlgeschlagen', failed); + if (done.length) { + dlBody.append(el('div', 'nui-pod-dls-sec', 'Fertig')); + dlBody.append(el('div', 'nui-pod-meta nui-pod-dls-done', + `${done.length} ${done.length === 1 ? 'Folge' : 'Folgen'} auf dem Telefon · ${size(done.reduce((n, d) => n + (d.bytes || 0), 0))}`)); + } + if (!all.length) dlBody.append(el('div', 'nui-pod-empty', 'Keine Downloads. Beim ↓ einer Folge lädst du sie aufs Telefon.')); + panel.scrollTop = scroll; + } + // ---- hooks from the app --------------------------------------------------------------- window.__dioraPodcasts = function (json) { @@ -1901,6 +2264,7 @@ const back = window.__dioraBack; window.__dioraBack = function () { if (closeStats()) return true; + if (closeDownloads()) return true; if (closeNotes()) return true; if (closePlayer()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true; diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 1d49122..b3367d6 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 62f3a69 2026-10-02: the podcast parts of web-native/static/native.css +/* Copied from diora-android 6c2b071 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. */ @@ -120,6 +120,18 @@ html.nui-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; } html.nui-pod-playing #nui-mini { display: none !important; } +/* One player for both: the podcast bar shows the radio too. */ +html.nui-one-player #nui-mini { display: none !important; } +.nui-mini-radio .nui-pod-art, +.nui-radio-art { display: flex; align-items: center; justify-content: center; } +.nui-mini-radio .nui-pod-art:empty::after, +.nui-radio-art:empty::after { content: '◉'; color: var(--accent); font-size: 22px; } +.nui-radio-art:empty::after { font-size: 96px; } +.nui-mini-radio .nui-pod-art img, +.nui-radio-art img { object-fit: contain; background: #fff; } +.nui-radio-live { text-align: center; font-size: 13px; color: var(--nui-muted); letter-spacing: 0.04em; } +.nui-radio-live.on { color: var(--accent); font-weight: 700; } +.nui-radio-stations { align-self: center; } html.nui-radio.nui-pod-mini-open body { padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px)); } @@ -495,6 +507,26 @@ body.eink .nui-pod-dl-ring > span { conic-gradient(#000 var(--p, 0%), #ccc 0); } +/* the top bar's "↓ 3/68" and the downloads overview (openDownloads) */ +.nui-pod-dlind { min-width: 0 !important; padding: 0 8px; font-size: 13px !important; font-weight: 700; color: var(--accent); white-space: nowrap; font-variant-numeric: tabular-nums; } +.nui-pod-dlind[hidden] { display: none !important; } +.nui-pod-dls .nui-pod-player-panel { min-height: 50%; } +.nui-pod-dls-head { display: flex; flex-direction: column; gap: 2px; } +.nui-pod-dls-head .nui-pod-summary { padding: 0 2px; text-align: center; } +.nui-pod-dls-head .nui-pod-offer-row { justify-content: center; } +.nui-pod-dls-head .btn { min-width: 0 !important; padding: 0 14px; font-size: 15px !important; } +.nui-pod-dls-sec { margin-top: 10px; padding: 4px 2px; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--nui-muted); border-bottom: 1px solid var(--border); } +.nui-pod-dlrow { display: flex; align-items: center; gap: 4px; min-height: 56px; padding: 6px 0; border-bottom: 1px solid var(--border); -webkit-tap-highlight-color: transparent; } +.nui-pod-dlrow:active { background: #141414; } +.nui-pod-dlrow .nui-pod-title { font-size: 14px; -webkit-line-clamp: 1; } +.nui-pod-dlrow .nui-icon { font-size: 18px; color: var(--nui-muted); } +.nui-pod-dlrow-failed .nui-pod-dlstatus { color: var(--yellow); } +.nui-pod-dls-done { padding: 8px 2px; white-space: normal; } +body.eink .nui-pod-dlind { color: #000; } +body.eink .nui-pod-dlrow:active { background: #ddd; } +body.eink .nui-pod-dlrow-failed .nui-pod-dlstatus { color: #000; font-weight: 700; } +body.eink .nui-pod-dls-sec { color: #000; border-bottom-color: #000; } + /* 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; } @@ -516,6 +548,8 @@ 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-offer { padding: 10px 12px; margin: 8px 0; border: 1px solid var(--nui-muted); border-radius: 6px; line-height: 1.45; font-size: 14px; } +.nui-pod-offer-row { display: flex; gap: 8px; margin-top: 8px; } .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; } @@ -525,5 +559,6 @@ body.eink .nui-pod-star, body.eink .nui-pod-fav.on { color: #000 !important; } .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-offer { border-color: #000; } body.eink .nui-pod-seg { color: #444; } body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }