Compare commits

...

2 commits

Author SHA1 Message Date
649cf0f340 AntennaPod-Import: --replace für einen Neuanfang; Podcast-Oberfläche aus diora-android 6c2b071
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 5m19s
--replace bestellt Podcasts ab, die das Backup nicht hat, behält nur
AntennaPods Warteschlange und lässt AntennaPod auch über spätere
Änderungen in diora gewinnen. Die Oberfläche bringt den gemeinsamen
Player und die Download-Übersicht (nur in der App sichtbar). sw v54.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:44:49 +02:00
07aed26864 AntennaPod-Import: „Neu“ wie in AntennaPod
All checks were successful
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 5m21s
Folgen archivierter Podcasts (neue Folgen ignorieren), die AntennaPod nie
abgerufen hat, verlassen „Neu“, sofern sie vor dem Backup erschienen
sind; --quiet-diora-only macht das auch für Abos, die nur diora hat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:38:41 +02:00
6 changed files with 588 additions and 32 deletions

View file

@ -260,6 +260,10 @@ class Stats:
downloads_created: int = 0 downloads_created: int = 0
downloads_missing: int = 0 downloads_missing: int = 0
downloads_offered: bool = True 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 backup_date: datetime | None = None
seconds: float = 0.0 seconds: float = 0.0
@ -292,13 +296,21 @@ def _columns(db, table):
class AntennaPodImport: class AntennaPodImport:
def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True): def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True,
quiet_diora_only=False, replace=False):
self.user = user self.user = user
self.path = path self.path = path
self.backup_date = backup_date self.backup_date = backup_date
self.global_auto_download = global_auto_download self.global_auto_download = global_auto_download
# False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod). # False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod).
self.offer_downloads = offer_downloads self.offer_downloads = offer_downloads
# 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) self.stats = Stats(backup_date=backup_date)
def run(self, dry_run=False): def run(self, dry_run=False):
@ -332,6 +344,8 @@ class AntennaPodImport:
self.new_feed = PodcastSettings.new_feed_defaults(user) self.new_feed = PodcastSettings.new_feed_defaults(user)
feed_map = self._import_feeds(db) # AntennaPod feed id -> diora feed id 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')] 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')} favorite_items = {r[0] for r in db.execute('SELECT feeditem FROM Favorites')}
st.queue_backup = len(queue_items) st.queue_backup = len(queue_items)
@ -340,6 +354,7 @@ class AntennaPodImport:
states, not_new, item_episode, downloads = self._import_episodes( states, not_new, item_episode, downloads = self._import_episodes(
db, feed_map, set(queue_items), favorite_items) db, feed_map, set(queue_items), favorite_items)
self._import_progress(states, not_new) self._import_progress(states, not_new)
self._quiet_inbox(feed_map, states)
self._import_queue(queue_items, item_episode) self._import_queue(queue_items, item_episode)
self._import_downloads(settings, downloads, item_episode) self._import_downloads(settings, downloads, item_episode)
@ -582,7 +597,7 @@ class AntennaPodImport:
def diora_newer(p, activity): def diora_newer(p, activity):
# Changed in diora after the backup was made (and after the # Changed in diora after the backup was made (and after the
# episode was last played in AntennaPod): diora's state stays. # 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 = [], [], [], [] new_rows, changed, favorite_only, dismiss_only = [], [], [], []
for ep_id, s in states.items(): for ep_id, s in states.items():
@ -629,6 +644,63 @@ class AntennaPodImport:
for ids in _chunks(dismiss_only): for ids in _chunks(dismiss_only):
EpisodeProgress.objects.filter(pk__in=ids).update(dismissed=True) EpisodeProgress.objects.filter(pk__in=ids).update(dismissed=True)
def _quiet_inbox(self, feed_map, states):
"""Episodes AntennaPod never showed as new, though diora has them in
the inbox: those of podcasts whose new episodes are ignored (AntennaPod
stopped fetching archived ones, so it never saw them), and with
quiet_diora_only those of podcasts AntennaPod doesn't have. Only ones
out before the backup; AntennaPod's own new ones stay."""
st = self.stats
user = self.user
backup = self.backup_date
imported = set(feed_map.values())
feeds = set(PodcastFeed.objects.filter(pk__in=imported, new_episodes=PodcastFeed.NEW_IGNORE)
.values_list('pk', flat=True))
diora_only = set()
if self.quiet_diora_only:
diora_only = set(PodcastFeed.objects.filter(user=user).exclude(pk__in=imported).values_list('pk', flat=True))
if not feeds and not diora_only:
return
keep = {ep_id for ep_id, s in states.items() if not s.dismissed}
hidden = EpisodeProgress.objects.filter(user=user).filter(Q(played=True) | Q(dismissed=True))
rows = (PodcastEpisode.objects.filter(feed_id__in=feeds | diora_only)
.filter(Q(pub_date__lte=backup) | Q(pub_date__isnull=True))
.exclude(id__in=hidden.values('episode_id'))
.values_list('id', 'feed_id'))
ids = []
for ep_id, feed_id in rows:
if ep_id in keep:
continue
ids.append(ep_id)
if feed_id in feeds:
st.ignored_left_inbox += 1
else:
st.diora_only_left_inbox += 1
# Changed in diora after the backup: diora's state stays.
newer = set()
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]
for chunk in _chunks(ids):
EpisodeProgress.objects.filter(user=user, episode_id__in=chunk, dismissed=False).update(dismissed=True)
have = set(EpisodeProgress.objects.filter(user=user, episode_id__in=chunk)
.values_list('episode_id', flat=True))
with _given_updated_at():
EpisodeProgress.objects.bulk_create(
[EpisodeProgress(user=user, episode_id=i, dismissed=True, updated_at=backup)
for i in chunk if i not in have],
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 -------------------------------------------------------------- # -- queue --------------------------------------------------------------
def _import_queue(self, queue_items, item_episode): def _import_queue(self, queue_items, item_episode):
@ -647,6 +719,12 @@ class AntennaPodImport:
return return
in_front = set(front) in_front = set(front)
rest = [ep_id for ep_id in before if ep_id not in in_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) st.queue_appended = len(rest)
present = set(before) present = set(before)
PodcastQueue.objects.bulk_create( PodcastQueue.objects.bulk_create(
@ -708,7 +786,8 @@ def summary_lines(st, username, path, dry_run, global_auto_download):
+ (' -- DRY RUN, nothing was written' if dry_run else ''), + (' -- DRY RUN, nothing was written' if dry_run else ''),
f'Feeds: {st.backup_feeds} in backup, {subscribed} subscribed ({st.not_subscribed} not subscribed ' 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'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'Settings: changed: {settings}',
f' {st.not_kept_updated} feeds not kept updated in AntennaPod -> new episodes "ignore"', 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, ' f'Auto-download: on for {st.auto_download_on} feeds ({st.auto_download_explicit} on in AntennaPod, '
@ -723,15 +802,20 @@ 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'{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'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'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'; kept sorted by "{st.keep_sorted}"' if st.keep_sorted else ''),
f'Inbox: {st.inbox_before} before, {st.inbox_after} after', 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)'
+ (f', {st.diora_only_left_inbox} of podcasts only diora has' if st.diora_only_left_inbox else ''),
f'Downloads: {st.downloads} on the phone in AntennaPod ({st.downloads_bytes / 1_000_000:.0f} MB) ' f'Downloads: {st.downloads} on the phone in AntennaPod ({st.downloads_bytes / 1_000_000:.0f} MB) '
+ ('-> offered to the app for download' if st.downloads_offered else '-> not offered (--no-downloads)') + ('-> offered to the app for download' if st.downloads_offered else '-> not offered (--no-downloads)')
+ (f' ({st.downloads_created} episodes created for them)' if st.downloads_created else '') + (f' ({st.downloads_created} episodes created for them)' if st.downloads_created else '')
+ (f'; {st.downloads_missing} not found (feeds not imported, no audio URL)' if st.downloads_missing else ''), + (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'Created feeds ({len(st.created_titles)}):',
*(f' - {t}' for t in 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'Skipped feeds ({len(st.skipped_feeds)}):',
*(f' - {t} ({u})' for t, u in st.skipped_feeds), *(f' - {t} ({u})' for t, u in st.skipped_feeds),
f'Done in {st.seconds:.1f} s.', f'Done in {st.seconds:.1f} s.',

View file

@ -25,6 +25,12 @@ class Command(BaseCommand):
help='Do everything inside a transaction that is rolled back, print the summary') help='Do everything inside a transaction that is rolled back, print the summary')
parser.add_argument('--global-auto-download', choices=('on', 'off'), default='off', parser.add_argument('--global-auto-download', choices=('on', 'off'), default='off',
help="Auto-download for feeds that use AntennaPod's global setting (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)')
parser.add_argument('--no-downloads', action='store_true', parser.add_argument('--no-downloads', action='store_true',
help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)") help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)")
parser.add_argument('--backup-date', parser.add_argument('--backup-date',
@ -53,7 +59,9 @@ class Command(BaseCommand):
global_auto_download = options['global_auto_download'] == 'on' global_auto_download = options['global_auto_download'] == 'on'
try: try:
stats = AntennaPodImport(user, path, backup_date, global_auto_download, stats = AntennaPodImport(user, path, backup_date, global_auto_download,
offer_downloads=not options['no_downloads']).run(dry_run=options['dry_run']) offer_downloads=not options['no_downloads'],
quiet_diora_only=options['quiet_diora_only'],
replace=options['replace']).run(dry_run=options['dry_run'])
except AntennaPodImportError as e: except AntennaPodImportError as e:
raise CommandError(str(e)) raise CommandError(str(e))
for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download): for line in summary_lines(stats, user.username, path, options['dry_run'], global_auto_download):

View file

@ -576,6 +576,71 @@ class ImportDownloadsViewTests(TestCase):
self.assertEqual(len(PodcastSettings.objects.get(user=self.user).import_downloads), 4) self.assertEqual(len(PodcastSettings.objects.get(user=self.user).import_downloads), 4)
class QuietInboxTests(ImportTestCase):
"""What AntennaPod never showed as new leaves diora's inbox too."""
def inbox(self):
hidden = EpisodeProgress.objects.filter(user=self.user, dismissed=True).values('episode_id')
return sorted(PodcastEpisode.objects.filter(feed__user=self.user).exclude(id__in=hidden)
.values_list('guid', flat=True))
def setUp(self):
super().setUp()
archived = self.diora_feed('https://example.org/archived')
self.ap_feed = self.ap.feed('https://example.org/archived', keep_updated=0)
self.ap.item(self.ap_feed, 'ap-new', read=-1)
self.episode(archived, 'ap-new', pub_date=T0)
self.episode(archived, 'unknown-old', pub_date=T0 + timedelta(days=3)) # AntennaPod never fetched it
self.episode(archived, 'unknown-late', pub_date=BACKUP_DATE + timedelta(days=1))
self.episode(archived, 'undated')
self.ap.feed('https://example.org/kept')
kept = self.diora_feed('https://example.org/kept')
self.episode(kept, 'kept-unknown', pub_date=T0) # not archived: stays
only = self.diora_feed('https://example.org/only-diora')
self.episode(only, 'only-old', pub_date=T0)
self.episode(only, 'only-late', pub_date=BACKUP_DATE + timedelta(days=1))
def test_archived_podcasts(self):
out = self.run_import()
self.assertEqual(self.inbox(), ['ap-new', 'kept-unknown', 'only-late', 'only-old', 'unknown-late'])
self.assertIn('out of it: 2 of podcasts whose new episodes are ignored (AntennaPod never showed them)\n', out)
p = self.progress(PodcastEpisode.objects.get(guid='unknown-old'))
self.assertEqual((p.played, p.dismissed, p.updated_at), (False, True, BACKUP_DATE))
def test_quiet_diora_only(self):
out = self.run_import('--quiet-diora-only')
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
self.run_import()
self.assertIn('unknown-old', self.inbox())
class AfterFirstRefreshTests(ImportTestCase): class AfterFirstRefreshTests(ImportTestCase):
"""The way to move: import, let the new feeds be fetched, import again.""" """The way to move: import, let the new feeds be fetched, import again."""

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v53'; const CACHE = 'diora-v54';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app.js', '/static/js/app.js',

View file

@ -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 // 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. // and copy again. The browser host is static/js/podcasts-web.js.
@ -262,7 +262,8 @@
playedItem(), playedItem(),
headsetItem(), 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. // 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', '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', 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 …')); 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) + ', ' + 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'}))); 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); root.append(bar);
if (feedView) { if (feedView) {
@ -500,6 +502,8 @@
tabs.append(b); tabs.append(b);
} }
root.append(tabs); root.append(tabs);
const offer = importOffer();
if (offer) root.append(offer);
if (view === 'queue') renderQueue(); if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox(); else if (view === 'inbox') renderInbox();
else if (view === 'episodes') renderEpisodes(); else if (view === 'episodes') renderEpisodes();
@ -509,6 +513,31 @@
scrollTop(scroll); 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) { function empty(text) {
root.append(el('div', 'nui-pod-empty', text)); root.append(el('div', 'nui-pod-empty', text));
} }
@ -1272,11 +1301,15 @@
const text = el('button', 'nui-mini-text'); const text = el('button', 'nui-mini-text');
text.type = 'button'; text.type = 'button';
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track')); 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, mini.append(art('', 'mini'), text,
button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'), 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); 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. // A "Podcasts" button in the bottom bar, between books and radio.
const tabbar = $id('nui-tabbar'); 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() { function renderMini() {
const isPod = player.kind === 'podcast'; const isPod = player.kind === 'podcast';
document.documentElement.classList.toggle('nui-pod-playing', isPod); document.documentElement.classList.toggle('nui-pod-playing', isPod);
if (!mini) return; if (!mini) return;
mini.hidden = !isPod; const radio = isPod ? null : radioNow();
document.documentElement.classList.toggle('nui-pod-mini-open', isPod); 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; if (!isPod) return;
mini.querySelector('.nui-mini-station').textContent = player.title || ''; mini.querySelector('.nui-mini-station').textContent = player.title || '';
// The back button names the set skip length (checked now and then, it rarely changes). // 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'); const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶'; t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen'; t.title = player.playing ? 'Pause' : 'Abspielen';
const a = mini.querySelector('.nui-pod-art img'); setArt(mini.querySelector('.nui-pod-art'), player.artwork);
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'; 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; return true;
} }
// The full player: one for both. Which one it shows now.
let sheetKind = '';
function openPlayer() { function openPlayer() {
if (player.kind !== 'podcast') return; if (player.kind !== 'podcast') return;
closePlayer(); closePlayer();
sheetKind = 'podcast';
sheet = el('div'); sheet = el('div');
sheet.id = 'nui-pod-player'; sheet.id = 'nui-pod-player';
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>'; sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
@ -1632,8 +1702,75 @@
return true; return true;
} }
// The radio in the same player: the station instead of the episode, "Live"
// instead of the seek bar, save / stop / sleep timer as the buttons.
function openRadioPlayer() {
const r = radioNow();
if (!r) return;
closePlayer();
sheetKind = 'radio';
sheet = el('div');
sheet.id = 'nui-pod-player';
sheet.innerHTML = '<div class="nui-pod-player-panel" role="dialog" aria-label="Radio"></div>';
const panel = sheet.firstChild;
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Player schließen', closePlayer));
const cover = art(r.logo, 'player');
cover.classList.add('nui-radio-art');
panel.append(top, cover, el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'),
el('div', 'nui-radio-live'));
const controls = el('div', 'nui-pod-controls');
controls.append(
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-pod-fav nui-radio-save'),
button('▶', 'Abspielen', () => window.__dioraRadioToggle && window.__dioraRadioToggle(), 'nui-pod-play'),
button('☾', 'Sleep-Timer', () => sleepMenu(true), 'nui-pod-sleep'),
);
const extra = el('div', 'nui-pod-extra');
const stations = el('button', 'btn nui-radio-stations', 'Sender wechseln');
stations.type = 'button';
stations.addEventListener('click', () => { closePlayer(); showTab('radio'); });
extra.append(stations);
panel.append(controls, extra);
sheet.addEventListener('click', e => { if (e.target === sheet) closePlayer(); });
document.body.append(sheet);
renderRadioPlayer();
}
function renderRadioPlayer() {
const r = radioNow();
if (!r) { closePlayer(); return; }
sheet.querySelector('.nui-pod-player-title').textContent = r.name;
sheet.querySelector('.nui-pod-player-feed').textContent = r.track || '';
const live = sheet.querySelector('.nui-radio-live');
live.textContent = r.buffering ? 'Verbinde …' : r.playing ? '● Live' : 'Angehalten';
live.classList.toggle('on', r.playing && !r.buffering);
const p = sheet.querySelector('.nui-pod-play');
p.textContent = r.playing ? '■' : '▶';
p.title = r.playing ? 'Stopp' : 'Abspielen';
const s = sheet.querySelector('.nui-radio-save');
s.textContent = r.saved ? '★' : '☆';
s.classList.toggle('on', r.saved);
s.disabled = r.saved;
s.title = r.saved ? 'Gespeichert' : 'Sender speichern';
const sl = sheet.querySelector('.nui-pod-sleep');
sl.textContent = sleepLabel() || '☾';
sl.classList.toggle('on', !!sleepLabel());
setArt(sheet.querySelector('.nui-pod-art-player'), r.logo);
}
// native-radio.js: the station, its track or its state changed.
window.__dioraRadioChanged = function () {
renderMini();
if (sheet && sheetKind === 'radio') renderRadioPlayer();
};
window.__dioraOpenPlayer = kind => (kind === 'radio' ? openRadioPlayer() : openPlayer());
function renderPlayer() { function renderPlayer() {
if (!sheet) return; if (!sheet) return;
if (sheetKind === 'radio') {
if (player.kind === 'podcast') closePlayer(); else renderRadioPlayer();
return;
}
if (player.kind !== 'podcast') { closePlayer(); return; } if (player.kind !== 'podcast') { closePlayer(); return; }
if (sheetEpisode !== player.episodeId) { if (sheetEpisode !== player.episodeId) {
// The next episode started while the player was open. // The next episode started while the player was open.
@ -1697,7 +1834,7 @@
} }
// Minutes count only while playing; shaking the phone starts them over. // Minutes count only while playing; shaking the phone starts them over.
function sleepMenu() { function sleepMenu(radio) {
if (!N.podcastSleep) return; if (!N.podcastSleep) return;
const t = player.sleep || {}; const t = player.sleep || {};
const on = !!sleepLabel(); const on = !!sleepLabel();
@ -1712,9 +1849,11 @@
for (const m of [5, 10, 15, 30, 45, 60, 90]) { 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: `${m} Minuten`, hint: m === t.last && !on ? 'zuletzt' : null, action: () => N.podcastSleep(m)});
} }
if (radio !== true) {
items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)}); items.push({icon: '⏹', label: 'Am Ende der Folge', action: () => N.podcastSleep(-1)});
items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)}); items.push({icon: '⏹', label: 'Nach 2 Folgen', action: () => N.podcastSleep(-2)});
items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)}); items.push({icon: '⏹', label: 'Nach 3 Folgen', action: () => N.podcastSleep(-3)});
}
if (N.podcastSleepAuto) { if (N.podcastSleepAuto) {
items.push({icon: t.auto ? '☑' : '☐', label: 'Nachts von selbst (22–6 Uhr)', 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', hint: t.auto ? `an: ${t.last || 15} Minuten, wenn eine Folge startet` : 'aus',
@ -1739,7 +1878,15 @@
const DL = !!N.podcastDownloads; const DL = !!N.podcastDownloads;
let downloads = DL ? parse(N.podcastDownloads(), {}) : {}; let downloads = DL ? parse(N.podcastDownloads(), {}) : {};
let dlSettings = DL ? parse(N.podcastDownloadSettings(), {}) : {}; 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_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). // 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_LIMITS = [5, 10, 25, 50, 100, 500, 0];
const DL_CLEANUPS = [ const DL_CLEANUPS = [
@ -1756,7 +1903,8 @@
function dlStart(e) { function dlStart(e) {
N.podcastDownload(JSON.stringify(e.raw)); 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'); toast('Wird heruntergeladen');
} }
@ -1789,7 +1937,15 @@
const d = downloads[b.dataset.dl]; const d = downloads[b.dataset.dl];
if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return; if (!d || (d.state !== 'queued' && d.state !== 'downloading')) return;
b.style.setProperty('--p', (d.progress || 0) + '%'); 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) { function dlMenuItems(e) {
@ -1800,8 +1956,12 @@
action: () => dlStart(e)}]; action: () => dlStart(e)}];
} }
if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(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 + ' %', return [
action: () => dlDelete(e, true)}]; 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) { function dlSet(changes) {
@ -1823,6 +1983,10 @@
action: () => dlSet({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', 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})}, 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', {icon: s.deletePlayed ? '☑' : '☐', label: 'Gehörte Folgen löschen',
hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten', hint: 'sobald die Folge zu Ende ist – nicht bei Favoriten',
action: () => dlSet({deletePlayed: !s.deletePlayed})}, action: () => dlSet({deletePlayed: !s.deletePlayed})},
@ -1848,17 +2012,216 @@
]; ];
} }
window.__dioraDownloads = function (map) { window.__dioraDownloads = function (map, info) {
const before = downloads; const before = downloads;
downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {}; downloads = (typeof map === 'string' ? parse(map, downloads) : map) || {};
if (info) dlInfo = typeof info === 'string' ? parse(info, dlInfo) : info;
const keys = Object.keys(downloads); const keys = Object.keys(downloads);
const same = keys.length === Object.keys(before).length && 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). // 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); if (same) {
else render(); 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 --------------------------------------------------------------- // ---- hooks from the app ---------------------------------------------------------------
window.__dioraPodcasts = function (json) { window.__dioraPodcasts = function (json) {
@ -1901,6 +2264,7 @@
const back = window.__dioraBack; const back = window.__dioraBack;
window.__dioraBack = function () { window.__dioraBack = function () {
if (closeStats()) return true; if (closeStats()) return true;
if (closeDownloads()) return true;
if (closeNotes()) return true; if (closeNotes()) return true;
if (closePlayer()) return true; if (closePlayer()) return true;
if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true; if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts' && stopSelecting()) return true;

View file

@ -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. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ 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-podcasts #tab-podcasts > :not(#nui-pod) { display: none !important; }
html.nui-pod-playing #nui-mini { 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 { html.nui-radio.nui-pod-mini-open body {
padding-bottom: calc(124px + env(safe-area-inset-bottom, 0px)); 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); 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 */ /* 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-tabs::-webkit-scrollbar { display: none; }
.nui-pod-lock { filter: grayscale(1); opacity: 0.6; } .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 */ /* feed errors, chapter navigation, transcript */
.nui-pod-feederr { font-size: 12px; color: var(--yellow); } .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-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-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-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; } .nui-pod-speaker { color: inherit; }
body.eink .nui-pod-feederr { color: #000; } body.eink .nui-pod-feederr { color: #000; }
body.eink .nui-pod-feederr-box { border-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 { color: #444; }
body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; } body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }