Compare commits

..

No commits in common. "649cf0f3407a8fad229994ba939ddbcb79e0786b" and "561776556f63c6581ee0f94eaa98136ad0fdfdf9" have entirely different histories.

6 changed files with 32 additions and 588 deletions

View file

@ -260,10 +260,6 @@ class Stats:
downloads_created: int = 0
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
@ -296,21 +292,13 @@ def _columns(db, table):
class AntennaPodImport:
def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True,
quiet_diora_only=False, replace=False):
def __init__(self, user, path, backup_date, global_auto_download=False, offer_downloads=True):
self.user = user
self.path = path
self.backup_date = backup_date
self.global_auto_download = global_auto_download
# False: the app doesn't offer AntennaPod's downloads (they stay in AntennaPod).
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)
def run(self, dry_run=False):
@ -344,8 +332,6 @@ 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)
@ -354,7 +340,6 @@ class AntennaPodImport:
states, not_new, item_episode, downloads = self._import_episodes(
db, feed_map, set(queue_items), favorite_items)
self._import_progress(states, not_new)
self._quiet_inbox(feed_map, states)
self._import_queue(queue_items, item_episode)
self._import_downloads(settings, downloads, item_episode)
@ -597,7 +582,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 not self.replace and p.updated_at > backup and (activity is None or p.updated_at > activity)
return 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():
@ -644,63 +629,6 @@ class AntennaPodImport:
for ids in _chunks(dismiss_only):
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 --------------------------------------------------------------
def _import_queue(self, queue_items, item_episode):
@ -719,12 +647,6 @@ 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(
@ -786,8 +708,7 @@ 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', {len(st.removed_titles)} only in diora unsubscribed (--replace)' if st.removed_titles else ''),
f'{st.duplicates} duplicates',
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, '
@ -802,20 +723,15 @@ 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_removed} removed (--replace)' if st.queue_removed else '') + ')'
f'{st.queue_after} after ({st.queue_appended} diora entries after AntennaPod\'s)'
+ (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)'
+ (f', {st.diora_only_left_inbox} of podcasts only diora has' if st.diora_only_left_inbox else ''),
f'Inbox: {st.inbox_before} before, {st.inbox_after} after',
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)')
+ (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'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,12 +25,6 @@ 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)')
parser.add_argument('--no-downloads', action='store_true',
help="Don't offer AntennaPod's downloaded episodes to the app (they stay in AntennaPod)")
parser.add_argument('--backup-date',
@ -59,9 +53,7 @@ class Command(BaseCommand):
global_auto_download = options['global_auto_download'] == 'on'
try:
stats = AntennaPodImport(user, path, backup_date, global_auto_download,
offer_downloads=not options['no_downloads'],
quiet_diora_only=options['quiet_diora_only'],
replace=options['replace']).run(dry_run=options['dry_run'])
offer_downloads=not options['no_downloads']).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

@ -576,71 +576,6 @@ class ImportDownloadsViewTests(TestCase):
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):
"""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.
*/
const CACHE = 'diora-v54';
const CACHE = 'diora-v53';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 6c2b071 2026-10-02: web-native/static/native-podcast.js
// Copied from diora-android f94c501 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,8 +262,7 @@
playedItem(),
headsetItem(),
]),
DL && {icon: '↓', label: 'Downloads', hint: dlSummary(), action: openDownloads},
DL && group('⚙︎', 'Download-Einstellungen', 'automatisch, gleichzeitig, Limit, aufräumen', () => [
DL && group('↓', 'Downloads', 'automatisch, 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',
@ -478,8 +477,7 @@
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, dlIndicator(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)),
button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean));
bar.append(text, button('⟳', 'Alle Feeds abrufen', () => refresh(true)), button('⋮', 'Einstellungen', settingsMenu));
root.append(bar);
if (feedView) {
@ -502,8 +500,6 @@
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();
@ -513,31 +509,6 @@
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));
}
@ -1301,15 +1272,11 @@
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', () => (miniKind === 'radio' ? openRadioPlayer() : openPlayer()));
text.addEventListener('click', openPlayer);
mini.append(art('', 'mini'), text,
button('↺', '15 s zurück', () => N.podcastSkip(false), 'nui-pod-skip'),
button('☆', 'Sender speichern', () => window.__dioraRadioSave && window.__dioraRadioSave(), 'nui-mini-save'),
button('▶', 'Abspielen', () => (miniKind === 'radio'
? window.__dioraRadioToggle && window.__dioraRadioToggle() : N.podcastToggle()), 'nui-mini-toggle'));
button('▶', 'Abspielen', () => 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');
@ -1323,49 +1290,12 @@
}
}
// 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;
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;
}
mini.hidden = !isPod;
document.documentElement.classList.toggle('nui-pod-mini-open', isPod);
if (!isPod) return;
mini.querySelector('.nui-mini-station').textContent = player.title || '';
// The back button names the set skip length (checked now and then, it rarely changes).
@ -1380,7 +1310,11 @@
const t = mini.querySelector('.nui-mini-toggle');
t.textContent = player.playing ? '❚❚' : '▶';
t.title = player.playing ? 'Pause' : 'Abspielen';
setArt(mini.querySelector('.nui-pod-art'), player.artwork);
const a = mini.querySelector('.nui-pod-art img');
const box = mini.querySelector('.nui-pod-art');
const src = (player.artwork || '').replace(/^http:/, 'https:');
if (!a && src) { const img = el('img'); img.alt = ''; img.src = src; box.append(img); }
else if (a && a.getAttribute('src') !== src) { if (src) a.src = src; else a.remove(); }
mini.querySelector('.nui-pod-mini-bar span').style.width = player.dur ? Math.min(100, player.pos / player.dur * 100) + '%' : '0';
}
@ -1633,13 +1567,9 @@
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>';
@ -1702,75 +1632,8 @@
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.
@ -1834,7 +1697,7 @@
}
// Minutes count only while playing; shaking the phone starts them over.
function sleepMenu(radio) {
function sleepMenu() {
if (!N.podcastSleep) return;
const t = player.sleep || {};
const on = !!sleepLabel();
@ -1849,11 +1712,9 @@
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)});
}
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',
@ -1878,15 +1739,7 @@
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 = [
@ -1903,8 +1756,7 @@
function dlStart(e) {
N.podcastDownload(JSON.stringify(e.raw));
window.__dioraDownloads(Object.assign({}, downloads,
{[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0, title: e.title, reason: 'queue'}}));
window.__dioraDownloads(Object.assign({}, downloads, {[e.id]: {state: 'queued', progress: 0, bytes: 0, total: 0}}));
toast('Wird heruntergeladen');
}
@ -1937,15 +1789,7 @@
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.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';
b.firstChild.textContent = d.state === 'queued' ? '…' : d.progress + '';
}
function dlMenuItems(e) {
@ -1956,12 +1800,8 @@
action: () => dlStart(e)}];
}
if (d.state === 'done') return [{icon: '✕', label: 'Download löschen', hint: size(d.bytes), action: () => dlDelete(e)}];
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)},
];
return [{icon: '✕', label: 'Download abbrechen', hint: d.state === 'queued' ? 'wartet noch' : d.progress + ' %',
action: () => dlDelete(e, true)}];
}
function dlSet(changes) {
@ -1983,10 +1823,6 @@
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})},
@ -2012,216 +1848,17 @@
];
}
window.__dioraDownloads = function (map, info) {
window.__dioraDownloads = function (map) {
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 && before[id].reason === downloads[id].reason);
keys.every(id => before[id] && before[id].state === downloads[id].state);
// Only the numbers moved: turn the rings, no new list (keeps a drag or a scroll going).
if (same) {
document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing);
dlIndicatorText();
} else {
render();
}
renderDownloads();
if (same) document.querySelectorAll('#nui-pod [data-dl]').forEach(dlRing);
else render();
};
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) {
@ -2264,7 +1901,6 @@
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 6c2b071 2026-10-02: the podcast parts of web-native/static/native.css
/* Copied from diora-android 62f3a69 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
@ -120,18 +120,6 @@
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));
}
@ -507,26 +495,6 @@ 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; }
@ -548,8 +516,6 @@ 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; }
@ -559,6 +525,5 @@ 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; }