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>
This commit is contained in:
mrwnslz 2026-10-02 12:44:49 +02:00
parent 07aed26864
commit 649cf0f340
6 changed files with 483 additions and 32 deletions

View file

@ -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.',

View file

@ -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):

View file

@ -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

View file

@ -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',

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
// 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 = '<div class="nui-pod-player-panel" role="dialog" aria-label="Player"></div>';
@ -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 = '<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() {
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;

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.
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; }