diff --git a/podcasts/tests.py b/podcasts/tests.py
index 732d347..9dae24c 100644
--- a/podcasts/tests.py
+++ b/podcasts/tests.py
@@ -337,6 +337,19 @@ class RefreshAllTests(TestCase):
'keep_sorted': '', 'auto_download_default': False,
**PLAYBACK_DEFAULTS})
+ def test_state_counts_the_whole_inbox(self):
+ # The list is capped (inbox_limit); inbox_total counts every new episode of this user.
+ eps = [PodcastEpisode.objects.create(feed=self.a, guid=f'n{i}', title=f'N{i}', audio_url=f'https://example.org/{i}.mp3',
+ pub_date=datetime.now(timezone.utc) - timedelta(days=i)) for i in range(5)]
+ EpisodeProgress.objects.create(user=self.user, episode=eps[0], played=True)
+ EpisodeProgress.objects.create(user=self.user, episode=eps[1], dismissed=True)
+ EpisodeProgress.objects.create(user=self.user, episode=eps[2], position_seconds=30)
+ PodcastEpisode.objects.create(feed=self.bobs, guid='b', title='Bobs', audio_url='https://example.org/b.mp3')
+ data = self.client.get('/podcasts/state/?inbox_limit=2').json()
+ self.assertEqual([e['id'] for e in data['inbox']], [eps[2].id, eps[3].id])
+ self.assertEqual((data['inbox_total'], data['inbox_limit']), (3, 2))
+ self.assertEqual(self.client.get('/podcasts/state/').json()['inbox_total'], 3)
+
# ---------------------------------------------------------------------------
# Shownotes, chapters, transcripts
diff --git a/podcasts/views.py b/podcasts/views.py
index d075d4f..21825b3 100644
--- a/podcasts/views.py
+++ b/podcasts/views.py
@@ -1569,8 +1569,9 @@ def refresh_all_feeds(request):
@csrf_exempt
def podcast_state(request):
"""Everything a client shows first, in one response: feeds, queue, the
- first page of the inbox, the settings and how many AntennaPod downloads
- wait to be offered (import-downloads/)."""
+ first page of the inbox (inbox_limit of inbox_total new episodes), the
+ settings and how many AntennaPod downloads wait to be offered
+ (import-downloads/)."""
if not request.user.is_authenticated:
return JsonResponse({'error': 'authentication required'}, status=401)
try:
@@ -1583,6 +1584,8 @@ def podcast_state(request):
'feeds': _feeds_data(request.user),
'queue': _queue_data(request.user),
'inbox': _inbox_data(request.user, 0, limit, with_description=False),
+ 'inbox_total': _inbox_episodes(request.user).count(),
+ 'inbox_limit': limit,
'settings': _settings_data(s),
'import_downloads': {'count': len(downloads), 'bytes': sum(e['size'] for e in downloads)},
'server_time': timezone.now().isoformat(),
@@ -1984,20 +1987,19 @@ def _add_user_state(user, episodes):
return episodes
+def _inbox_episodes(user):
+ """The inbox: the user's episodes neither played nor dismissed."""
+ hidden = EpisodeProgress.objects.filter(user=user).filter(
+ Q(played=True) | Q(dismissed=True)
+ ).values('episode_id')
+ return PodcastEpisode.objects.filter(feed__user=user).exclude(id__in=hidden)
+
+
def _inbox_data(user, offset, limit, with_description=True):
"""Inbox episodes. Shownotes can be large (up to 50 KB each); the apps'
state/ leaves them out and loads one episode's at a time."""
- hidden_ids = set(
- EpisodeProgress.objects.filter(
- user=user,
- ).filter(
- Q(played=True) | Q(dismissed=True)
- ).values_list('episode_id', flat=True)
- )
-
episodes = list(
- PodcastEpisode.objects.filter(feed__user=user)
- .exclude(id__in=hidden_ids)
+ _inbox_episodes(user)
.select_related('feed')
.order_by('-pub_date')
.values(*_inbox_fields(with_description))[offset:offset + limit]
diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js
index cb7bac2..03f5add 100644
--- a/static/js/podcasts-web.js
+++ b/static/js/podcasts-web.js
@@ -1684,6 +1684,18 @@
// The shared player: what plays on the other devices, and from now on every change.
startSync();
+ // The start page chosen in the podcasts' settings (nui_start_page, this browser; none: the
+ // last used tab, the web's way). Opening diora shows it; reloading and back/forward keep the tab.
+ (function openStartPage() {
+ let start = '';
+ try { start = localStorage.getItem('nui_start_page') || ''; } catch (e) {}
+ if (!start || typeof showTab !== 'function') return;
+ const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
+ if (nav && nav.type !== 'navigate') return;
+ const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio' : 'books';
+ if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
+ })();
+
// The page (copied from the app) comes last, once all of the above is in place.
const script = document.createElement('script');
script.src = UI_SCRIPT;
diff --git a/static/js/sw.js b/static/js/sw.js
index c8f2fd2..07514dd 100644
--- a/static/js/sw.js
+++ b/static/js/sw.js
@@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use.
*/
-const CACHE = 'diora-v65';
+const CACHE = 'diora-v66';
const SHELL = [
'/static/css/app.css',
'/static/js/app.js',
diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js
index 96eadb6..66495c6 100644
--- a/static/podcasts/native-podcast.js
+++ b/static/podcasts/native-podcast.js
@@ -1,4 +1,4 @@
-// Copied from diora-android 304abf0 2026-10-02: web-native/static/native-podcast.js
+// Copied from diora-android 339de8f 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.
@@ -24,13 +24,28 @@
document.documentElement.classList.add('nui-podcasts');
const $id = id => document.getElementById(id);
+ // The tabs; the search is a view of its own, opened from the top bar.
const VIEWS = [
{id: 'queue', label: 'Warteschlange'},
{id: 'inbox', label: 'Neu'},
{id: 'episodes', label: 'Folgen'},
{id: 'feeds', label: 'Abos'},
- {id: 'search', label: 'Suche'},
];
+ const VIEW_IDS = VIEWS.map(v => v.id).concat('search');
+ // The app's start page (this device; native.js opens it, the browser host too).
+ const IN_APP = !window.DioraPodcastHost;
+ const START_PAGES = [
+ !IN_APP && {id: '', label: 'Zuletzt benutzt', hint: 'Standard im Browser'},
+ {id: 'books', label: 'Bücher', hint: IN_APP ? 'Standard' : null},
+ {id: 'queue', label: 'Podcasts: Warteschlange'},
+ {id: 'inbox', label: 'Podcasts: Neu'},
+ {id: 'radio', label: 'Radio'},
+ ].filter(Boolean);
+ function startPage() {
+ let v = null;
+ try { v = localStorage.getItem('nui_start_page'); } catch (e) {}
+ return START_PAGES.some(p => p.id === v) ? v : START_PAGES[0].id;
+ }
const POSITIONS = [
{value: 'back', label: 'Ans Ende'},
{value: 'front', label: 'An den Anfang'},
@@ -60,10 +75,21 @@
let locked = false; // queue locked: no dragging
try { locked = localStorage.getItem('nui_pod_locked') === '1'; } catch (e) {}
try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) {}
- if (!VIEWS.some(v => v.id === view)) view = 'queue';
+ // Opening into the podcasts (start page): that list.
+ if (startPage() === 'queue' || startPage() === 'inbox') view = startPage();
+ if (!VIEW_IDS.includes(view)) view = 'queue';
const queue = () => state.queue || [];
const inbox = () => state.inbox || [];
+ // All new episodes: the server sends the first ones (inbox_limit) and how many there are
+ // (inbox_total); what left „Neu“ here since then counts off.
+ function inboxTotal() {
+ const n = inbox().length;
+ const total = state.inbox_total;
+ if (typeof total !== 'number') return n;
+ const sent = typeof state.inbox_limit === 'number' ? Math.min(total, state.inbox_limit) : n;
+ return Math.max(n, total - Math.max(0, sent - n));
+ }
const feeds = () => state.feeds || [];
const settings = () => state.settings || {};
const currentId = () => (player.kind === 'podcast' ? player.episodeId : 0);
@@ -127,15 +153,19 @@
return `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`;
}
+ // Short dates: heute, gestern, vor 3 Tagen (up to 6), then 8.7. this year, 8.7.22 before.
function day(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
- const diff = Math.floor((Date.now() - d) / 86400000);
+ const now = new Date();
+ const midnight = x => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
+ const diff = Math.round((midnight(now) - midnight(d)) / 86400000);
if (diff <= 0) return 'heute';
if (diff === 1) return 'gestern';
if (diff < 7) return `vor ${diff} Tagen`;
- return d.toLocaleDateString('de-DE', {day: 'numeric', month: 'short', year: diff > 300 ? 'numeric' : undefined});
+ const dm = `${d.getDate()}.${d.getMonth() + 1}.`;
+ return d.getFullYear() === now.getFullYear() ? dm : dm + String(d.getFullYear() % 100).padStart(2, '0');
}
let toastTimer = 0;
@@ -173,27 +203,43 @@
if (o.episode__id != null) {
return {
id: o.episode__id, title: o.episode__title, feed: o.episode__feed__title, feedId: o.episode__feed__id,
- art: o.episode__artwork_url || o.episode__feed__artwork_url, duration: o.episode__duration_seconds,
- pos: o.position_seconds, date: o.episode__pub_date, raw: o,
+ art: o.episode__artwork_url, feedArt: o.episode__feed__artwork_url || feedArtOf(o.episode__feed__id),
+ duration: o.episode__duration_seconds, pos: o.position_seconds, date: o.episode__pub_date, raw: o,
};
}
return {
id: o.id, title: o.title, feed: o.feed__title || (feedView && feedView.feed.title), feedId: o.feed__id,
- art: o.artwork_url || o.feed__artwork_url || (feedView && feedView.feed.artwork_url), duration: o.duration_seconds,
- pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o,
+ art: o.artwork_url, feedArt: o.feed__artwork_url || feedArtOf(o.feed__id) || (feedView && feedView.feed.artwork_url),
+ duration: o.duration_seconds, pos: o.position_seconds, date: o.pub_date, played: o.played, inQueue: o.in_queue, raw: o,
};
}
- function art(src, size) {
+ function feedArtOf(id) {
+ const f = id ? feeds().find(x => x.id === id) : null;
+ return f ? f.artwork_url : '';
+ }
+
+ // A cover: the episode's own, else its podcast's, else a tile with the podcast's initial.
+ function art(src, size, fallback, name) {
const box = el('div', 'nui-pod-art' + (size ? ' nui-pod-art-' + size : ''));
- if (src) {
+ const srcs = [src, fallback].filter(Boolean).map(s => s.replace(/^http:/, 'https:'))
+ .filter((s, i, all) => all.indexOf(s) === i);
+ const initial = () => {
+ if (box.querySelector('img') || box.firstChild) return;
+ const letter = (plain(name || '').replace(/^(the|der|die|das)\s+/i, '').match(/[0-9A-Za-zÀ-ÿ]/) || [''])[0];
+ if (letter) box.append(el('span', 'nui-pod-initial', letter.toUpperCase()));
+ };
+ const next = () => {
+ const s = srcs.shift();
+ if (!s) { initial(); return; }
const img = el('img');
img.loading = 'lazy';
img.alt = '';
- img.src = src.replace(/^http:/, 'https:');
- img.addEventListener('error', () => img.remove());
+ img.src = s;
+ img.addEventListener('error', () => { img.remove(); next(); });
box.append(img);
- }
+ };
+ next();
return box;
}
@@ -231,7 +277,11 @@
toast('Als gehört markiert');
}
- function queueMenu(e) {
+ // Choosing several, from a row's menu (long press or ⋯).
+ const selectItem = (e, opts) => opts && opts.select &&
+ {icon: '☐', label: 'Mehrere auswählen', hint: 'diese und weitere Folgen auf einmal', action: () => startSelecting(e.id)};
+
+ function queueMenu(e, opts) {
const playing = currentId() === e.id;
openSheet(e.title, [
{icon: '▶', label: playing ? 'Player öffnen' : 'Abspielen', action: () => play(e)},
@@ -242,11 +292,12 @@
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
{icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)},
...dlMenuItems(e),
- {icon: '✕', label: 'Aus der Warteschlange nehmen', danger: true, action: () => N.podcastDequeue(e.id)},
+ {icon: '✕', label: 'Aus der Warteschlange nehmen', action: () => N.podcastDequeue(e.id)},
+ selectItem(e, opts),
]);
}
- function episodeMenu(e) {
+ function episodeMenu(e, opts) {
const queued = inQueue(e.id);
openSheet(e.title, [
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
@@ -261,6 +312,7 @@
...dlMenuItems(e),
{icon: '⊘', label: 'Ausblenden', hint: 'aus „Neu“ entfernen, ohne es als gehört zu zählen',
action: () => N.podcastDismiss(JSON.stringify([e.id]))},
+ selectItem(e, opts),
]);
}
@@ -311,6 +363,8 @@
...dlSettingsItems(),
]),
backgroundSyncItem(),
+ {icon: '⇆', label: 'Wischgesten', hint: 'was Wischen nach links und rechts in jeder Liste tut', action: swipeSettings},
+ startPageItem(),
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
@@ -427,7 +481,22 @@
].filter(Boolean);
};
if (!('mobile_downloads' in s || 'mobile_streaming' in s) || !items().length) return null;
- return {icon: '📶', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())};
+ return {icon: '◢', label: 'Mobile Daten', hint: 'Downloads und Streamen unterwegs', action: () => openSheet('Mobile Daten', items())};
+ }
+
+ // Which page the app opens with (this device).
+ function startPageItem() {
+ const now = startPage();
+ const label = id => (START_PAGES.find(p => p.id === id) || START_PAGES[0]).label;
+ return {icon: '⌂', label: 'Startseite', hint: label(now) + (IN_APP ? ' · beim Öffnen der App' : ' · beim Öffnen von diora'),
+ action: () => openSheet('Beim Öffnen zeigen …', START_PAGES.filter(p => p.id !== 'radio' || !IN_APP ||
+ document.documentElement.classList.contains('nui-radio')).map(p => ({
+ icon: p.id === now ? '●' : '○', label: p.label, hint: p.hint || null, active: p.id === now,
+ action: () => {
+ try { localStorage.setItem('nui_start_page', p.id); } catch (e) {}
+ toast('Startseite: ' + p.label);
+ },
+ })))};
}
// ---- speed presets (the account's; AntennaPod's speed presets, up to 3.5×) ----
@@ -580,6 +649,7 @@
}
function startSelecting(id) {
+ commitSwipe();
selected = new Set(id ? [id] : []);
render();
}
@@ -788,6 +858,11 @@
chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id)
: active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id)));
}
+ // An active filter further right shows (the row scrolls sideways).
+ requestAnimationFrame(() => {
+ const on = chips.querySelector('.nui-pod-chip.on');
+ if (on && on.offsetLeft + on.offsetWidth > chips.scrollLeft + chips.clientWidth) chips.scrollLeft = on.offsetLeft - 8;
+ });
return chips;
}
@@ -852,7 +927,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)),
+ bar.append(...[text, dlIndicator(), searchButton(), button('⟳', 'Alle Feeds abrufen', () => refresh(true)),
button('⋮', 'Einstellungen', settingsMenu)].filter(Boolean));
root.append(bar);
@@ -866,8 +941,8 @@
const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : ''));
b.type = 'button';
b.append(el('span', null, v.label));
- const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inbox().length : 0;
- if (count) b.append(el('span', 'nui-pod-count', String(count)));
+ const count = v.id === 'queue' ? queue().length : v.id === 'inbox' ? inboxTotal() : 0;
+ if (count) b.append(el('span', 'nui-pod-count' + (v.id === 'queue' ? ' nui-pod-count-queue' : ''), count > 999 ? '999+' : String(count)));
b.addEventListener('click', () => {
selected = null;
view = v.id;
@@ -894,6 +969,23 @@
}
}
+ // The search: a magnifier in the top bar (an SVG: the text glyphs for it differ from font to font).
+ function searchButton() {
+ const b = button('', 'Suchen', () => {
+ selected = null;
+ feedView = null;
+ preview = null;
+ view = 'search';
+ render();
+ scrollTop(0);
+ const q = $id('nui-pod-q');
+ if (q && !(searchResults && searchResults.q)) q.focus();
+ }, 'nui-pod-searchbtn' + (view === 'search' && !feedView && !preview ? ' on' : ''));
+ b.innerHTML = '';
+ return b;
+ }
+
// AntennaPod had episodes on the phone: offered once to fetch them again (Wi-Fi only).
function importOffer() {
const o = state.import_downloads;
@@ -932,10 +1024,15 @@
return b;
}
+ // One episode in a list. opts.list names the list (queue, inbox, episodes, feed, downloads):
+ // it decides the one action at hand and the swipes; everything else is in the row's menu
+ // (⋯ or a long press). opts.filters: the list's filters (what a swipe takes out of it).
function episodeRow(e, opts) {
+ const list = opts.list || '';
const row = el('div', 'nui-pod-row' + (currentId() === e.id ? ' nui-pod-current' : '') + (e.played ? ' nui-pod-played' : ''));
row.dataset.id = e.id;
- row.append(art(e.art));
+ if (swipeGone(e.id)) row.classList.add('nui-pod-gone');
+ row.append(art(e.art, null, e.feedArt, e.feed));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', e.title));
// Length first: podcast names are long and get cut off.
@@ -945,7 +1042,11 @@
if (e.duration) meta.push(started ? minutes(shown(e.duration - e.pos, e.feedId)) + ' übrig' : minutes(shown(e.duration, e.feedId)));
if (opts.showDate && e.date) meta.push(day(e.date));
if (opts.showFeed && e.feed) meta.push(e.feed);
- main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
+ const metaEl = el('div', 'nui-pod-meta', meta.join(' · '));
+ // Where the row's button isn't the download: its state as a small mark.
+ const mark = selected || dlPrimary(e, list) ? null : dlMark(e);
+ if (mark) metaEl.prepend(mark);
+ main.append(metaEl);
const p = e.played ? null : progressBar(e.pos, e.duration);
if (p) main.append(p);
row.append(main);
@@ -954,26 +1055,381 @@
const box = el('span', 'nui-pod-check' + (selected.has(e.id) ? ' on' : ''), selected.has(e.id) ? '☑' : '☐');
row.prepend(box);
row.addEventListener('click', () => toggleSelected(e.id));
- } else {
- dlAppend(row, e);
- row.addEventListener('click', () => play(e));
- }
- if (!selected && !opts.noSelect) {
- // Long press starts choosing several, like in AntennaPod.
- let timer = 0;
- row.addEventListener('pointerdown', ev => {
- if (ev.target.closest('button, .nui-pod-handle')) return;
- timer = setTimeout(() => { timer = 0; startSelecting(e.id); }, 550);
- });
- const cancel = () => { if (timer) clearTimeout(timer); timer = 0; };
- row.addEventListener('pointerup', cancel);
- row.addEventListener('pointerleave', cancel);
- row.addEventListener('pointercancel', cancel);
- row.addEventListener('contextmenu', ev => ev.preventDefault());
+ return row;
}
+ const menu = () => (list === 'queue' ? queueMenu(e, {select: !opts.noSelect}) : episodeMenu(e, {select: !opts.noSelect}));
+ row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu'));
+ row.addEventListener('click', () => play(e));
+ // Swipes: touch only, not while the queue is locked.
+ const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : '';
+ rowGestures(row, menu, swipes && ((act, dir) => swipeDone(e, swipes, act, opts.filters || [], dir)), swipes);
return row;
}
+ // The one action at hand: the queue and the downloads play, „Neu“ queues,
+ // the episode lists download (or queue, where nothing downloads).
+ function dlPrimary(e, list) {
+ return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url);
+ }
+
+ function primaryButton(e, list) {
+ if (list === 'queue' || list === 'downloads') {
+ const now = currentId() === e.id;
+ const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen',
+ () => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn');
+ b.dataset.play = e.id;
+ return b;
+ }
+ if (dlPrimary(e, list)) return dlButton(e);
+ const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue);
+ return queued ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
+ : button('+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; render(); });
+ }
+
+ // ▶ / ❚❚ in the rows follow the player without drawing the lists again.
+ function playButtons() {
+ document.querySelectorAll('#nui-pod [data-play]').forEach(b => {
+ const now = currentId() === Number(b.dataset.play) && player.playing;
+ b.textContent = now ? '❚❚' : '▶';
+ b.title = now ? 'Pause' : 'Abspielen';
+ b.setAttribute('aria-label', b.title);
+ });
+ }
+
+ // ---- long press and swipes on a row ---------------------------------------------------
+ // Long press opens the row's menu. Swiping (a finger, not a mouse) left or right does what
+ // the list's swipe settings say, with 4 s to undo. Vertical moves stay the page's scrolling;
+ // the queue's drag handle and the sideways chip rows aren't rows, so nothing competes.
+
+ function rowGestures(row, onLong, onSwipe, list) {
+ let start = null;
+ let timer = 0;
+ let mode = '';
+ let dx = 0;
+ let vx = 0;
+ let last = null;
+ let bg = null;
+ let swallow = false;
+ const stopTimer = () => { if (timer) clearTimeout(timer); timer = 0; };
+ const actionFor = d => swipeAction(list, d);
+ const limit = () => Math.min(140, Math.max(72, row.offsetWidth * 0.3));
+ const armed = () => {
+ const act = actionFor(dx < 0 ? 'left' : 'right');
+ if (act === 'none') return false;
+ const fling = Math.abs(dx) > 40 && Math.abs(vx) > 0.6 && Math.sign(vx) === Math.sign(dx);
+ return Math.abs(dx) >= limit() || fling;
+ };
+ const paint = () => {
+ const d = dx < 0 ? 'left' : 'right';
+ const act = actionFor(d);
+ const shownDx = act === 'none' ? dx * 0.25 : dx;
+ row.style.transform = `translateX(${shownDx}px)`;
+ const a = SWIPE_ACTIONS.find(x => x.id === act) || SWIPE_ACTIONS[0];
+ bg.dataset.dir = d;
+ bg.classList.toggle('armed', armed());
+ const label = bg.querySelector(d === 'right' ? '.nui-pod-swipe-l' : '.nui-pod-swipe-r');
+ label.textContent = act === 'none' ? '' : (d === 'right' ? `${a.icon} ${swipeLabel(a, row)}` : `${swipeLabel(a, row)} ${a.icon}`);
+ bg.querySelector(d === 'right' ? '.nui-pod-swipe-r' : '.nui-pod-swipe-l').textContent = '';
+ };
+ const finish = done => {
+ row.classList.remove('nui-pod-swiping');
+ const gone = bg;
+ bg = null;
+ if (!done) {
+ row.style.transition = 'transform 0.18s ease';
+ row.style.transform = '';
+ setTimeout(() => { row.style.transition = ''; if (gone) gone.remove(); }, 200);
+ } else if (gone) {
+ setTimeout(() => gone.remove(), 220);
+ }
+ };
+ row.addEventListener('pointerdown', ev => {
+ if (ev.target.closest('.nui-pod-handle') || (ev.pointerType === 'mouse' && ev.button !== 0)) return;
+ start = {x: ev.clientX, y: ev.clientY, id: ev.pointerId, touch: ev.pointerType === 'touch'};
+ mode = '';
+ dx = vx = 0;
+ last = {x: ev.clientX, t: ev.timeStamp};
+ swallow = false;
+ stopTimer();
+ if (!ev.target.closest('button')) {
+ timer = setTimeout(() => {
+ timer = 0;
+ if (mode || !start) return;
+ mode = 'long';
+ swallow = true;
+ onLong();
+ }, 550);
+ }
+ });
+ row.addEventListener('pointermove', ev => {
+ if (!start || ev.pointerId !== start.id) return;
+ const mx = ev.clientX - start.x;
+ const my = ev.clientY - start.y;
+ if (!mode) {
+ if (Math.abs(mx) > 8 || Math.abs(my) > 8) { stopTimer(); start.moved = true; }
+ if (onSwipe && start.touch && Math.abs(mx) > 12 && Math.abs(mx) > Math.abs(my) * 1.5) {
+ mode = 'swipe';
+ commitSwipe();
+ try { row.setPointerCapture(ev.pointerId); } catch (e) {}
+ bg = el('div', 'nui-pod-swipebg');
+ bg.append(el('span', 'nui-pod-swipe-l'), el('span', 'nui-pod-swipe-r'));
+ bg.style.top = row.offsetTop + 'px';
+ bg.style.height = row.offsetHeight + 'px';
+ row.parentElement.insertBefore(bg, row);
+ row.classList.add('nui-pod-swiping');
+ } else if (Math.abs(my) > 12) {
+ start = null;
+ return;
+ }
+ }
+ if (mode !== 'swipe') return;
+ ev.preventDefault();
+ const dt = ev.timeStamp - last.t;
+ if (dt > 0) vx = 0.7 * vx + 0.3 * ((ev.clientX - last.x) / dt);
+ last = {x: ev.clientX, t: ev.timeStamp};
+ dx = mx;
+ paint();
+ });
+ const end = ev => {
+ if (!start || (ev && ev.pointerId !== start.id)) return;
+ stopTimer();
+ const moved = start.moved;
+ start = null;
+ // A drag (a mouse's too) isn't a tap: no playing by accident.
+ if (mode !== 'swipe') { if (moved && mode !== 'long') swallow = true; return; }
+ swallow = true;
+ const d = dx < 0 ? 'left' : 'right';
+ const act = actionFor(d);
+ if (ev.type === 'pointerup' && armed() && act !== 'none') {
+ row.style.transition = 'transform 0.16s ease-out';
+ row.style.transform = `translateX(${d === 'left' ? -row.offsetWidth : row.offsetWidth}px)`;
+ finish(true);
+ setTimeout(() => {
+ const kept = onSwipe(act, d);
+ // The row stays in the list: back in its place.
+ if (kept !== false && row.isConnected) {
+ row.style.transition = 'transform 0.18s ease';
+ row.style.transform = '';
+ setTimeout(() => { row.style.transition = ''; }, 200);
+ }
+ }, 160);
+ } else {
+ finish(false);
+ }
+ };
+ row.addEventListener('pointerup', end);
+ row.addEventListener('pointercancel', end);
+ row.addEventListener('pointerleave', ev => { if (mode !== 'swipe') { stopTimer(); } else if (ev.pointerType !== 'touch') end(ev); });
+ // The tap that ends a long press or a swipe isn't a tap on the row.
+ row.addEventListener('click', ev => {
+ if (!swallow) return;
+ swallow = false;
+ ev.stopPropagation();
+ ev.preventDefault();
+ }, true);
+ row.addEventListener('contextmenu', ev => ev.preventDefault());
+ }
+
+ // What swiping does, per list (this device). The defaults are an AntennaPod setup.
+ const SWIPE_LISTS = {
+ queue: {label: 'Warteschlange', left: 'delete', right: 'dequeue'},
+ inbox: {label: 'Neu', left: 'dismiss', right: 'queue'},
+ episodes: {label: 'Folgen', left: 'download', right: 'favorite'},
+ feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'},
+ downloads: {label: 'Downloads', hint: 'Folgen mit dem Filter „Geladen“', left: 'delete', right: 'delete'},
+ };
+ const SWIPE_ACTIONS = [
+ {id: 'none', label: 'Nichts', icon: ''},
+ {id: 'queue', label: 'Einreihen', icon: '+'},
+ {id: 'dequeue', label: 'Aus der Warteschlange', icon: '✕'},
+ {id: 'download', label: 'Herunterladen', icon: '↓', dl: true},
+ {id: 'delete', label: 'Download löschen', icon: '✕', dl: true},
+ {id: 'played', label: 'Gehört / ungehört', icon: '✓'},
+ {id: 'favorite', label: 'Favorit an / aus', icon: '★'},
+ {id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘'},
+ ];
+
+ function swipePrefs() {
+ let all = {};
+ try { all = JSON.parse(localStorage.getItem('nui_pod_swipes') || '{}') || {}; } catch (e) {}
+ return all;
+ }
+
+ function swipeAction(list, dir) {
+ const l = SWIPE_LISTS[list];
+ if (!l) return 'none';
+ const own = (swipePrefs()[list] || {})[dir];
+ const id = own || l[dir];
+ const a = SWIPE_ACTIONS.find(x => x.id === id);
+ return a && (!a.dl || DL) ? id : 'none';
+ }
+
+ function swipeLabel(a) {
+ return {played: 'Gehört', favorite: 'Favorit', dequeue: 'Entfernen', delete: 'Löschen', dismiss: 'Aus „Neu“'}[a.id] || a.label;
+ }
+
+ function swipeSettings() {
+ openSheet('Wischgesten', Object.keys(SWIPE_LISTS).filter(id => id !== 'downloads' || DL).map(id => {
+ const l = SWIPE_LISTS[id];
+ const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(id, d)) || SWIPE_ACTIONS[0]).label;
+ return {icon: '⇆', label: l.label, hint: `← ${name('left')} · → ${name('right')}`, action: () => swipeListSheet(id)};
+ }).concat([{custom: el('div', 'nui-pod-meta nui-pod-sheetnote',
+ 'Nur mit dem Finger. In der gesperrten Warteschlange ist Wischen aus. Nach jedem Wischen 4 Sekunden „Rückgängig“.')}]));
+ }
+
+ function swipeListSheet(list) {
+ const l = SWIPE_LISTS[list];
+ const pick = dir => openSheet(`${l.label}: nach ${dir === 'left' ? 'links' : 'rechts'} wischen`,
+ SWIPE_ACTIONS.filter(a => !a.dl || DL).map(a => ({
+ icon: swipeAction(list, dir) === a.id ? '●' : '○', label: a.label, active: swipeAction(list, dir) === a.id,
+ hint: a.id === l[dir] ? 'Standard' : null,
+ action: () => {
+ const all = swipePrefs();
+ all[list] = Object.assign({}, all[list], {[dir]: a.id});
+ try { localStorage.setItem('nui_pod_swipes', JSON.stringify(all)); } catch (e) {}
+ swipeListSheet(list);
+ },
+ })));
+ const name = d => (SWIPE_ACTIONS.find(a => a.id === swipeAction(list, d)) || SWIPE_ACTIONS[0]).label;
+ openSheet(`Wischen in „${l.label}“` + (l.hint ? ` (${l.hint})` : ''), [
+ {icon: '←', label: 'Nach links', hint: name('left'), action: () => pick('left')},
+ {icon: '→', label: 'Nach rechts', hint: name('right'), action: () => pick('right')},
+ {icon: '‹', label: 'Alle Listen', action: swipeSettings},
+ ]);
+ }
+
+ // A swipe's change waits 4 s for „Rückgängig“ where it can't simply be undone
+ // (queued or dismissed: out of „Neu“ for good; out of the queue; played; a download
+ // deleted): a row that leaves its list is gone at once, the change is sent then.
+ // Downloading and starring happen at once; undoing them is their opposite.
+ let pendingSwipe = null; // {id, hide, run, undo, timer}
+ const recentlyGone = new Map();
+
+ function swipeGone(id) {
+ if (pendingSwipe && pendingSwipe.hide && pendingSwipe.id === id) return true;
+ const t = recentlyGone.get(id);
+ return !!t && Date.now() - t < 4000;
+ }
+
+ function commitSwipe() {
+ const p = pendingSwipe;
+ if (!p) return;
+ pendingSwipe = null;
+ clearTimeout(p.timer);
+ undoBar(null);
+ if (p.hide) recentlyGone.set(p.id, Date.now());
+ if (p.run) p.run();
+ }
+
+ function undoSwipe() {
+ const p = pendingSwipe;
+ if (!p) return;
+ pendingSwipe = null;
+ clearTimeout(p.timer);
+ undoBar(null);
+ if (p.undo) p.undo();
+ render();
+ }
+
+ // The bar with „Rückgängig“ (the toast can't be tapped).
+ function undoBar(text) {
+ let bar = $id('nui-pod-undo');
+ if (!text) { if (bar) bar.classList.remove('nui-show'); return; }
+ if (!bar) {
+ bar = el('div');
+ bar.id = 'nui-pod-undo';
+ bar.append(el('span', 'nui-pod-undo-text'));
+ const b = el('button', 'nui-pod-undo-btn', 'Rückgängig');
+ b.type = 'button';
+ b.addEventListener('click', ev => { ev.stopPropagation(); undoSwipe(); });
+ bar.append(b);
+ document.body.append(bar);
+ }
+ const t = $id('nui-radio-toast');
+ if (t) t.classList.remove('nui-show');
+ bar.querySelector('.nui-pod-undo-text').textContent = text;
+ bar.classList.add('nui-show');
+ }
+
+ function offerUndo(e, text, change) {
+ commitSwipe();
+ pendingSwipe = Object.assign({id: e.id}, change);
+ pendingSwipe.timer = setTimeout(commitSwipe, 4000);
+ undoBar(text);
+ if (change.hide) render();
+ }
+
+ // A swipe on an episode of `list` asks for `act`. Returns false when the row leaves the list.
+ function swipeDone(e, list, act, filters) {
+ const o = e.raw || {};
+ const has = d => DL && downloads[e.id] && downloads[e.id].state === d;
+ if (act === 'queue') {
+ if (inQueue(e.id)) { toast('Schon in der Warteschlange'); return true; }
+ // Queued episodes leave „Neu“ for good (AntennaPod's rule): sent only after the 4 s.
+ const hide = list === 'inbox';
+ offerUndo(e, 'Eingereiht', {hide, run: () => {
+ N.podcastEnqueue(JSON.stringify([o]), '', currentId());
+ o.in_queue = true;
+ if (!hide) render();
+ }});
+ return !hide;
+ }
+ if (act === 'dequeue') {
+ if (!inQueue(e.id)) { toast('Nicht in der Warteschlange'); return true; }
+ const hide = list === 'queue';
+ offerUndo(e, 'Aus der Warteschlange genommen', {hide, run: () => { N.podcastDequeue(e.id); o.in_queue = false; }});
+ return !hide;
+ }
+ if (act === 'download') {
+ if (!(o.audio_url || o.episode__audio_url)) { toast('Diese Folge lässt sich nicht laden'); return true; }
+ if (downloads[e.id] && downloads[e.id].state !== 'failed') {
+ toast(has('done') ? 'Schon heruntergeladen' : 'Wird schon geladen');
+ return true;
+ }
+ dlStart(e);
+ offerUndo(e, 'Wird heruntergeladen', {undo: () => N.podcastDeleteDownload(e.id)});
+ return true;
+ }
+ if (act === 'delete') {
+ if (!downloads[e.id]) { toast('Nicht heruntergeladen'); return true; }
+ const running = !has('done') && !has('failed');
+ // "Löschen nimmt aus der Warteschlange" (account): then it leaves the queue too.
+ const out = !running && 'delete_dequeue' in settings() && settings().delete_dequeue !== false && inQueue(e.id);
+ const hide = list === 'downloads' || filters.includes('downloaded') || (list === 'queue' && out);
+ offerUndo(e, running ? 'Download abgebrochen' : 'Download gelöscht', {hide, run: () => dlDelete(e, running, true)});
+ return !hide;
+ }
+ if (act === 'played') {
+ const played = !(o.played || e.played);
+ const hide = played
+ ? list === 'inbox' || (list === 'queue' && devicePrefs().remove_played !== false) ||
+ filters.includes('unplayed') || filters.includes('in_progress')
+ : filters.includes('played');
+ offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => {
+ N.podcastMarkPlayed(e.id, played);
+ o.played = played;
+ if (played) o.position_seconds = 0;
+ if (!hide) render();
+ }});
+ return !hide;
+ }
+ if (act === 'favorite') {
+ toggleFavorite(e, star => {
+ o.favorite = star;
+ offerUndo(e, star ? '★ Favorit' : 'Kein Favorit mehr', {undo: () => toggleFavorite(e, s => { o.favorite = s; }, true)});
+ }, true);
+ return true;
+ }
+ if (act === 'dismiss') {
+ // „Neu“ is every unplayed episode not taken out (the page knows only the first ones).
+ if (o.played || e.played) { toast('Gehört – nicht in „Neu“'); return true; }
+ const hide = list === 'inbox';
+ offerUndo(e, 'Aus „Neu“ entfernt', {hide, run: () => N.podcastDismiss(JSON.stringify([e.id]))});
+ return !hide;
+ }
+ return true;
+ }
+
function renderQueue() {
const items = queue().map(ep);
if (!items.length) {
@@ -986,7 +1442,7 @@
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
{label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
- {label: 'Entfernen', danger: true, run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
+ {label: 'Entfernen', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
]));
} else {
const head = el('div', 'nui-pod-head');
@@ -1002,9 +1458,8 @@
}
const list = el('div', 'nui-pod-list nui-pod-queue');
for (const e of items) {
- const row = episodeRow(e, {showFeed: true});
+ const row = episodeRow(e, {showFeed: true, list: 'queue'});
if (!selected) {
- row.append(button('⋯', 'Mehr', () => queueMenu(e)));
if (!locked && !keepSorted()) {
const handle = el('div', 'nui-pod-handle', '≡');
handle.title = 'Ziehen zum Umsortieren';
@@ -1047,22 +1502,14 @@
]));
} else {
const head = el('div', 'nui-pod-head');
- head.append(el('div', 'nui-pod-summary', `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'}`),
+ const total = inboxTotal();
+ head.append(el('div', 'nui-pod-summary', `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` +
+ (total > items.length ? ` · die ${items.length} neuesten` : '')),
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
root.append(head);
}
const list = el('div', 'nui-pod-list');
- for (const e of items) {
- const row = episodeRow(e, {showFeed: true, showDate: true});
- if (!selected) {
- row.append(
- e.inQueue || inQueue(e.id)
- ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
- : button('+', 'Einreihen', () => enqueue(e)),
- button('⋯', 'Mehr', () => episodeMenu(e)));
- }
- list.append(row);
- }
+ for (const e of items) list.append(episodeRow(e, {showFeed: true, showDate: true, list: 'inbox'}));
root.append(list);
}
@@ -1119,7 +1566,7 @@
function feedRow(f) {
const row = el('div', 'nui-pod-row');
- row.append(art(f.artwork_url));
+ row.append(art(f.artwork_url, null, null, f.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(f.title)));
const meta = [];
@@ -1179,97 +1626,188 @@
}
}
+ // One podcast: cover, title, author, ⓘ and ⚙, one quiet line with what is set
+ // differently from the defaults, then its episodes. All its settings are in the ⚙ sheet.
function renderFeed() {
const f = feedView.feed;
- const head = el('div', 'nui-pod-feedhead');
- head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; selected = null; render(); }), art(f.artwork_url, 'big'));
- const info = el('div', 'nui-pod-main');
- info.append(el('div', 'nui-pod-title', f.title));
- if (f.author) info.append(el('div', 'nui-pod-meta', f.author));
- head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn'));
- root.append(head);
-
const live = feeds().find(x => x.id === f.id) || f;
+ const head = el('div', 'nui-pod-feedhead');
+ head.append(button('‹', 'Zurück zu den Abos', () => { feedView = null; selected = null; render(); }, 'nui-pod-back-btn'),
+ art(f.artwork_url, 'big', null, f.title));
+ const info = el('div', 'nui-pod-main');
+ info.append(el('div', 'nui-pod-title', plain(f.title)));
+ if (f.author) info.append(el('div', 'nui-pod-meta', plain(f.author)));
+ head.append(info, button('ⓘ', 'Über diesen Podcast', () => openFeedInfo(f), 'nui-pod-infobtn'));
+ if (feedHasSettings(live)) head.append(button('⚙︎', 'Einstellungen für diesen Podcast', () => feedSettingsSheet(f), 'nui-pod-infobtn nui-pod-gear'));
+ root.append(head);
+ const line = feedHasSettings(live) ? feedSummary(f, live) : '';
+ if (line) {
+ const s = el('button', 'nui-pod-feedsum', line);
+ s.type = 'button';
+ s.title = 'Einstellungen für diesen Podcast';
+ s.addEventListener('click', () => feedSettingsSheet(f));
+ root.append(s);
+ }
if (live.last_error) {
root.append(el('div', 'nui-pod-feederr nui-pod-feederr-box',
`⚠ Der Feed ließ sich zuletzt nicht laden (${live.last_error}${live.last_error_at ? ', ' + day(live.last_error_at) : ''}). ` +
'Der Server versucht es weiter, bei wiederholten Fehlern seltener.'));
}
- if ('new_episodes' in live && N.podcastFeedSettings) {
- root.append(newEpisodesRow(live));
- if (N.podcastFeedPrefs) root.append(feedSettings(f));
- root.append(unsubscribeBox(f));
- return renderFeedEpisodes(f);
- }
- const auto = live.auto_queue;
- const toggle = el('label', 'nui-pod-switch');
- const box = el('input');
- box.type = 'checkbox';
- box.checked = !!auto;
- box.addEventListener('change', () => N.podcastAutoQueue(f.id, box.checked));
- const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
- toggle.append(box, el('span', null, 'Neue Folgen automatisch einreihen'), el('span', 'nui-pod-meta', pos));
- root.append(toggle);
- if (N.podcastFeedPrefs) root.append(feedSettings(f));
- root.append(unsubscribeBox(f));
renderFeedEpisodes(f);
}
- // Where this podcast's new episodes go, and which ones count at all.
- function newEpisodesRow(live) {
- const row = el('div', 'nui-pod-feedprefs');
- const chip = (label, value, on, onClick) => {
- const b = el('button', 'nui-pod-chip' + (on ? ' on' : ''));
- b.type = 'button';
- b.append(el('span', null, label), el('strong', null, value));
- b.addEventListener('click', onClick);
- return b;
- };
- const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); };
- const where = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
- const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
- row.append(chip('Neue Folgen', where[live.new_episodes] || where.inbox, live.new_episodes !== 'inbox', () =>
- openSheet(`Neue Folgen von „${plain(live.title)}“ …`, [
- {icon: live.new_episodes === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', action: () => save({new_episodes: 'inbox'})},
- {icon: live.new_episodes === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, action: () => save({new_episodes: 'queue'})},
- {icon: live.new_episodes === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', action: () => save({new_episodes: 'ignore'})},
- ])));
- const words = [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`,
+ function feedHasSettings(live) {
+ return !!N.podcastFeedSettings || !!N.podcastAutoQueue || !!N.podcastFeedPrefs;
+ }
+
+ function volumeLabel(v) {
+ if (!v || v === 1) return '';
+ return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || '';
+ }
+
+ // This podcast's own playback (speed, intro, outro, volume): the server's where it keeps them, else this device's.
+ function feedOwn(f) {
+ const live = feeds().find(x => x.id === f.id) || f;
+ return 'skip_intro' in live
+ ? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1}
+ : (devicePrefs().feeds || {})[String(f.id)] || {};
+ }
+
+ const NEW_WHERE = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
+
+ function filterWords(live) {
+ return [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`,
live.min_duration && `ab ${live.min_duration} min`].filter(Boolean).join(', ');
- const ask = async (key, title, now) => {
- const v = typeof customPrompt === 'function' ? await customPrompt(title, now || '') : null;
- if (v != null) save({[key]: v.trim()});
- };
- // AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves.
- if ('auto_download' in live && N.podcastDownloads) {
- row.append(chip('Auto-Download', live.auto_download ? 'an' : 'aus', !!live.auto_download, () =>
- openSheet(`Neue Folgen von „${plain(live.title)}“ herunterladen?`, [
- {icon: live.auto_download ? '●' : '○', label: 'Ja, von selbst', hint: 'im WLAN, innerhalb des Download-Limits',
- action: () => save({auto_download: true})},
- {icon: !live.auto_download ? '●' : '○', label: 'Nein', hint: 'nur die Warteschlange wird geladen', action: () => save({auto_download: false})},
- ])));
+ }
+
+ // "1,3× · Intro 20 s · ohne „Trailer“ · neue Folgen → Warteschlange": only what differs from the defaults.
+ function feedSummary(f, live) {
+ const own = N.podcastFeedPrefs ? feedOwn(f) : {};
+ const parts = [];
+ if (own.speed) parts.push(speedLabel(own.speed));
+ if (own.intro) parts.push(`Intro ${own.intro} s`);
+ if (own.outro) parts.push(`Outro ${own.outro} s`);
+ if (volumeLabel(own.volume)) parts.push('Lautstärke ' + volumeLabel(own.volume));
+ if ('skip_silence' in live && live.skip_silence != null) parts.push(live.skip_silence ? 'Stille überspringen' : 'Stille nie überspringen');
+ const words = filterWords(live);
+ if (words) parts.push(words);
+ if (live.new_episodes === 'queue' || (!('new_episodes' in live) && live.auto_queue)) parts.push('neue Folgen → Warteschlange');
+ else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends');
+ if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download');
+ if (live.notify) parts.push('Benachrichtigen');
+ return parts.join(' · ');
+ }
+
+ // All of a podcast's settings in one sheet; after a change it opens again with the new values.
+ function feedSettingsSheet(f) {
+ const live = feeds().find(x => x.id === f.id) || f;
+ const title = plain(live.title || f.title);
+ const again = () => setTimeout(() => { if (feedView && feedView.feed.id === f.id && !preview) feedSettingsSheet(f); }, 180);
+ const save = changes => { N.podcastFeedSettings(live.id, JSON.stringify(changes)); setTimeout(render, 50); again(); };
+ const choose = (head, choices) => () => openSheet(head, choices);
+ const items = [];
+ const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
+ if ('new_episodes' in live && N.podcastFeedSettings) {
+ const ne = live.new_episodes || 'inbox';
+ items.push({icon: '◇', label: 'Neue Folgen', hint: NEW_WHERE[ne] || NEW_WHERE.inbox, active: ne !== 'inbox',
+ action: choose(`Neue Folgen von „${title}“ …`, [
+ {icon: ne === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', active: ne === 'inbox', action: () => save({new_episodes: 'inbox'})},
+ {icon: ne === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, active: ne === 'queue', action: () => save({new_episodes: 'queue'})},
+ {icon: ne === 'ignore' ? '●' : '○', label: 'Nirgends anzeigen', hint: 'nur in der Folgenliste des Podcasts', active: ne === 'ignore',
+ action: () => save({new_episodes: 'ignore'})},
+ ])});
+ // AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves.
+ if ('auto_download' in live && N.podcastDownloads) {
+ items.push({icon: '↓', label: 'Automatisch herunterladen', hint: live.auto_download ? 'an: neue Folgen im WLAN, im Download-Limit' : 'aus',
+ active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})});
+ }
+ // AntennaPod's episode notifications: the phone tells when a new episode is in „Neu“.
+ if ('notify' in live) {
+ items.push({icon: '◔', label: 'Benachrichtigen', hint: live.notify ? 'an: eine Mitteilung bei jeder neuen Folge' : 'aus',
+ active: !!live.notify, action: () => save({notify: !live.notify})});
+ }
+ const words = filterWords(live);
+ const ask = async (key, head, now) => {
+ const v = typeof customPrompt === 'function' ? await customPrompt(head, now || '') : null;
+ if (v != null) save({[key]: v.trim()}); else again();
+ };
+ items.push({icon: '⊻', label: 'Filter für neue Folgen', hint: words || 'keiner', active: !!words,
+ action: choose('Welche neuen Folgen zählen?', [
+ {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle',
+ action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)},
+ {icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine',
+ action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)},
+ {icon: '⏱︎', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine',
+ action: choose('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({
+ icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge',
+ active: (live.min_duration || 0) === m, action: () => save({min_duration: m}),
+ })))},
+ ])});
+ } else if (N.podcastAutoQueue) {
+ items.push({icon: live.auto_queue ? '☑' : '☐', label: 'Neue Folgen automatisch einreihen', hint: pos, active: !!live.auto_queue,
+ action: () => { N.podcastAutoQueue(f.id, !live.auto_queue); again(); }});
}
- // AntennaPod's episode notifications: the phone tells when a new episode is in „Neu“.
- if ('notify' in live) {
- row.append(chip('Benachrichtigen', live.notify ? 'an' : 'aus', !!live.notify, () =>
- openSheet(`Bei neuen Folgen von „${plain(live.title)}“ benachrichtigen?`, [
- {icon: live.notify ? '●' : '○', label: 'Ja', hint: 'eine Mitteilung auf dem Telefon, sobald eine neue Folge in „Neu“ ist',
- action: () => save({notify: true})},
- {icon: !live.notify ? '●' : '○', label: 'Nein', action: () => save({notify: false})},
- ])));
+ if (N.podcastFeedPrefs) {
+ const own = feedOwn(f);
+ const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); again(); };
+ const seconds = (key, head) => choose(head, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({
+ icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', active: (own[key] || 0) === v,
+ action: () => set(key, v),
+ })));
+ items.push(
+ {icon: '»', label: 'Tempo', hint: own.speed ? speedLabel(own.speed) : 'wie alle Podcasts', active: !!own.speed,
+ action: choose(`Geschwindigkeit für „${title}“`, speedChoices(own.speed).map(v => ({
+ icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v), active: Math.abs((own.speed || 0) - v) < 0.01,
+ action: () => set('speed', v)}))
+ .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}])
+ .concat(presetsEditItem()))},
+ {icon: '⇥', label: 'Intro überspringen', hint: own.intro ? `${own.intro} Sekunden` : 'aus', active: !!own.intro,
+ action: seconds('intro', 'Am Anfang jeder Folge überspringen')},
+ {icon: '⇤', label: 'Outro überspringen', hint: own.outro ? `${own.outro} Sekunden` : 'aus', active: !!own.outro,
+ action: seconds('outro', 'Am Ende jeder Folge überspringen')},
+ {icon: '◖', label: 'Lautstärke', hint: volumeLabel(own.volume) || 'normal', active: !!volumeLabel(own.volume),
+ action: choose(`Lautstärke für „${title}“`,
+ [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']]
+ .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, active: (own.volume || 1) === v, action: () => set('volume', v)})))},
+ );
}
- row.append(chip('Filter', words || 'aus', !!words, () => openSheet('Welche neuen Folgen zählen?', [
- {icon: '+', label: 'Nur mit diesen Wörtern im Titel', hint: live.include_words || 'alle',
- action: () => ask('include_words', 'Nur Folgen, deren Titel eines dieser Wörter enthält (mit Komma getrennt):', live.include_words)},
- {icon: '−', label: 'Ohne diese Wörter im Titel', hint: live.exclude_words || 'keine',
- action: () => ask('exclude_words', 'Folgen mit einem dieser Wörter im Titel weglassen (mit Komma getrennt):', live.exclude_words)},
- {icon: '⏱', label: 'Mindestlänge', hint: live.min_duration ? `${live.min_duration} Minuten` : 'keine',
- action: () => openSheet('Kürzere Folgen weglassen', [0, 5, 10, 15, 20, 30, 45, 60].map(m => ({
- icon: (live.min_duration || 0) === m ? '●' : '○', label: m ? `unter ${m} Minuten weglassen` : 'Keine Mindestlänge',
- action: () => save({min_duration: m}),
- })))},
- ])));
- return row;
+ // Skip silence for this podcast (server), where this player can skip silence at all.
+ if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) {
+ const mine = live.skip_silence;
+ const device = !!devicePrefs().skip_silence;
+ items.push({icon: '∿', label: 'Stille', hint: mine === true ? 'überspringen' : mine === false ? 'nie überspringen' : 'wie eingestellt',
+ active: mine != null, action: choose(`Stille in „${title}“ …`, [
+ {icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)',
+ active: mine == null, action: () => save({skip_silence: null})},
+ {icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', active: mine === true, action: () => save({skip_silence: true})},
+ {icon: mine === false ? '●' : '○', label: 'Nie überspringen', active: mine === false, action: () => save({skip_silence: false})},
+ ])});
+ }
+ // The list's order (its filters are the chips above the episodes).
+ const prefs = feedListPrefs(f.id);
+ const sort = EP_SORTS.some(s => s.id === prefs.sort && !s.all) ? prefs.sort : 'new';
+ items.push({icon: '⇅', label: 'Folgen sortieren', hint: sortLabel(sort), active: sort !== 'new',
+ action: () => sortMenuFor(sort, false, id => {
+ saveFeedListPrefs(f.id, {filters: (prefs.filters || []).slice(), sort: id});
+ render();
+ again();
+ })});
+ items.push({custom: el('div', 'nui-pod-sheetsep')});
+ items.push({icon: '✕', label: 'Abo beenden', hint: 'fragt noch einmal nach', action: () => confirmUnsubscribe(f)});
+ openSheet(`Einstellungen für „${title}“`, items);
+ }
+
+ // Unsubscribing (server: POST /podcasts/feeds//remove/). Asked once more; then the
+ // server forgets the podcast with its episodes, and the app deletes their downloads
+ // (not those of favourites).
+ function confirmUnsubscribe(f) {
+ if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
+ openSheet(`„${plain(f.title)}“ nicht mehr abonnieren?`, [
+ {custom: el('div', 'nui-pod-meta nui-pod-sheetnote', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt ' +
+ 'und Favoriten, auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : ''))},
+ {icon: '✕', label: 'Abo beenden', danger: true, action: () => unsubscribe(f)},
+ {icon: '‹', label: 'Abbrechen', action: () => feedSettingsSheet(f)},
+ ]);
}
function renderFeedEpisodes(f) {
@@ -1304,14 +1842,8 @@
const list = el('div', 'nui-pod-list');
for (const e of items) {
const o = e.raw;
- const row = episodeRow(e, {showDate: true});
+ const row = episodeRow(e, {showDate: true, list: downloadsOnly(filters) ? 'downloads' : 'feed', filters});
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
- if (!selected) {
- row.append(
- inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
- : button('+', 'Einreihen', () => { enqueue(e); o.in_queue = true; }),
- button('⋯', 'Mehr', () => episodeMenu(e)));
- }
list.append(row);
}
root.append(list);
@@ -1383,7 +1915,7 @@
const title = el('div', 'nui-pod-main');
title.append(el('div', 'nui-pod-title', plain(live.title)));
if (live.author) title.append(el('div', 'nui-pod-meta', plain(live.author)));
- head.append(art(live.artwork_url, 'big'), title);
+ head.append(art(live.artwork_url, 'big', null, live.title), title);
panel.append(top, head);
// Feed descriptions may carry HTML: only its text.
@@ -1457,99 +1989,9 @@
toast(ok ? 'Kopiert' : 'Kopieren ging nicht');
}
- function volumeLabel(v) {
- if (!v || v === 1) return '';
- return {0.2: 'viel leiser', 0.5: 'leiser', 1.5: '+5 dB', 2: '+10 dB', 2.5: '+15 dB'}[v] || '';
- }
-
- // This podcast's own playback: speed, intro and outro to skip.
- function feedSettings(f) {
- const live = feeds().find(x => x.id === f.id) || f;
- const own = 'skip_intro' in live
- ? {speed: live.playback_speed || 0, intro: live.skip_intro || 0, outro: live.skip_outro || 0, volume: live.volume || 1}
- : (devicePrefs().feeds || {})[String(f.id)] || {};
- const row = el('div', 'nui-pod-feedprefs');
- const chip = (label, value, onClick) => {
- const b = el('button', 'nui-pod-chip' + (value ? ' on' : ''));
- b.type = 'button';
- b.append(el('span', null, label), el('strong', null, value || 'aus'));
- b.addEventListener('click', onClick);
- return b;
- };
- const set = (key, v) => { N.podcastFeedPrefs(f.id, JSON.stringify({[key]: v})); setTimeout(render, 50); };
- const seconds = (key, title) => openSheet(title, [0, 10, 15, 20, 30, 45, 60, 90, 120].map(v => ({
- icon: (own[key] || 0) === v ? '●' : '○', label: v ? `${v} Sekunden` : 'Nichts überspringen', action: () => set(key, v),
- })));
- row.append(
- chip('Tempo', own.speed ? speedLabel(own.speed) : '', () => openSheet(`Geschwindigkeit für „${plain(f.title)}“`,
- speedChoices(own.speed).map(v => ({icon: Math.abs((own.speed || 0) - v) < 0.01 ? '●' : '○', label: speedLabel(v),
- action: () => set('speed', v)}))
- .concat([{icon: '↺', label: 'Wie alle anderen Podcasts', action: () => set('speed', 0)}])
- .concat(presetsEditItem()))),
- chip('Intro', own.intro ? `${own.intro} s` : '', () => seconds('intro', 'Am Anfang jeder Folge überspringen')),
- chip('Outro', own.outro ? `${own.outro} s` : '', () => seconds('outro', 'Am Ende jeder Folge überspringen')),
- chip('Lautstärke', volumeLabel(own.volume), () => openSheet(`Lautstärke für „${plain(f.title)}“`,
- [[0.2, 'Viel leiser'], [0.5, 'Leiser'], [1, 'Normal'], [1.5, 'Lauter (+5 dB)'], [2, 'Viel lauter (+10 dB)'], [2.5, 'Sehr viel lauter (+15 dB)']]
- .map(([v, label]) => ({icon: (own.volume || 1) === v ? '●' : '○', label, action: () => set('volume', v)})))),
- );
- // Skip silence for this podcast (server), where this player can skip silence at all.
- if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) {
- const mine = live.skip_silence;
- const device = !!devicePrefs().skip_silence;
- const b = el('button', 'nui-pod-chip' + (mine != null ? ' on' : ''));
- b.type = 'button';
- b.append(el('span', null, 'Stille'), el('strong', null, mine === true ? 'überspringen' : mine === false ? 'nie überspringen'
- : 'wie eingestellt'));
- const save = v => { N.podcastFeedSettings(f.id, JSON.stringify({skip_silence: v})); setTimeout(render, 50); };
- b.addEventListener('click', () => openSheet(`Stille in „${plain(f.title)}“ …`, [
- {icon: mine == null ? '●' : '○', label: 'Wie eingestellt', hint: device ? 'überspringen (alle Podcasts)' : 'nicht überspringen (alle Podcasts)',
- action: () => save(null)},
- {icon: mine === true ? '●' : '○', label: 'Überspringen', hint: 'Pausen werden ausgelassen', action: () => save(true)},
- {icon: mine === false ? '●' : '○', label: 'Nie überspringen', action: () => save(false)},
- ]));
- row.append(b);
- }
- return row;
- }
-
- // Unsubscribing (server: POST /podcasts/feeds//remove/). Asked once more
- // right here; then the server forgets the podcast with its episodes, and the
- // app deletes their downloads (not those of favourites).
- function unsubscribeBox(f) {
- const box = el('div', 'nui-pod-unsub');
- if (!feedView.confirmRemove) {
- const b = el('button', 'nui-pod-unsub-btn', 'Abo beenden');
- b.type = 'button';
- b.addEventListener('click', () => {
- if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
- feedView.confirmRemove = true;
- render();
- });
- box.append(b);
- return box;
- }
- box.classList.add('nui-pod-unsub-ask');
- box.append(el('div', 'nui-pod-title', `„${plain(f.title)}“ nicht mehr abonnieren?`),
- el('div', 'nui-pod-meta', 'Die Folgen verschwinden aus Warteschlange und Listen, mit Fortschritt und Favoriten, ' +
- 'auch auf dem Server.' + (DL ? ' Die Downloads dieses Podcasts werden gelöscht.' : '')));
- const actions = el('div', 'nui-pod-unsub-actions');
- const yes = el('button', 'btn nui-pod-unsub-yes', feedView.removing ? 'Wird beendet …' : 'Abo beenden');
- yes.type = 'button';
- yes.disabled = !!feedView.removing;
- yes.addEventListener('click', () => unsubscribe(f));
- const no = el('button', 'btn', 'Abbrechen');
- no.type = 'button';
- no.disabled = !!feedView.removing;
- no.addEventListener('click', () => { feedView.confirmRemove = false; render(); });
- actions.append(yes, no);
- box.append(actions);
- return box;
- }
-
async function unsubscribe(f) {
if (!navigator.onLine) { toast('Abo beenden braucht eine Verbindung.'); return; }
- feedView.removing = true;
- render();
+ toast('Abo wird beendet …');
try {
const r = await fetch(`/podcasts/feeds/${f.id}/remove/`, {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}',
@@ -1557,7 +1999,6 @@
// 404: gone already (removed elsewhere) — the same result.
if (!r.ok && r.status !== 404) throw new Error(r.status);
} catch (e) {
- if (feedView && feedView.feed.id === f.id) { feedView.removing = false; render(); }
toast(navigator.onLine ? 'Abo beenden hat nicht geklappt.' : 'Abo beenden braucht eine Verbindung.');
return;
}
@@ -1575,6 +2016,9 @@
else N.podcastRefresh(false);
}
+ // The downloads list: an episode list with just the filter „Geladen“ (its button plays, its swipes delete).
+ const downloadsOnly = filters => DL && filters.length === 1 && filters[0] === 'downloaded';
+
// ---- all episodes, filtered (server: GET /podcasts/episodes/) ----------------------
// Filters combine (all have to match); "Geladen" asks for the episodes downloaded here.
@@ -1668,17 +2112,15 @@
const list = el('div', 'nui-pod-list');
for (const e of items) {
const o = e.raw;
- const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played'});
+ const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
+ filters: epFilters});
if (epSort === 'played' && o.last_played_at) {
- row.querySelector('.nui-pod-meta').textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || '');
+ const m = row.querySelector('.nui-pod-meta');
+ const mark = m.querySelector('.nui-pod-dlmark');
+ m.textContent = 'gehört ' + day(o.last_played_at) + ' · ' + (e.feed || '');
+ if (mark) m.prepend(mark);
}
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
- if (!selected) {
- row.append(
- inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
- : button('+', 'Einreihen', () => enqueue(e)),
- button('⋯', 'Mehr', () => episodeMenu(e)));
- }
list.append(row);
}
root.append(list);
@@ -1700,7 +2142,7 @@
action: () => toggleFavorite(e, star => { if (e.raw) e.raw.favorite = star; })};
}
- async function toggleFavorite(e, done) {
+ async function toggleFavorite(e, done, quiet) {
const o = e.raw || {};
const body = typeof o.favorite === 'boolean' ? {favorite: !o.favorite} : {};
try {
@@ -1709,7 +2151,7 @@
});
if (!r.ok) throw new Error(r.status);
const d = await r.json();
- toast(d.favorite ? '★ Als Favorit markiert' : 'Kein Favorit mehr');
+ if (!quiet) toast(d.favorite ? '★ Als Favorit markiert' : 'Kein Favorit mehr');
// Favourites keep their download (Downloads.kt).
if (N.podcastFavoriteChanged) N.podcastFavoriteChanged(e.id, !!d.favorite);
const cached = storedDetails(e.id);
@@ -1782,7 +2224,7 @@
const max = Math.max(...feedsList.map(f => f.seconds || 0), 1);
for (const f of feedsList) {
const row = el('div', 'nui-pod-stat-row');
- row.append(art(f.artwork_url));
+ row.append(art(f.artwork_url, null, null, f.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(f.title)),
el('div', 'nui-pod-meta', `${hours(f.seconds || 0)} · ${f.episodes_played || 0} Folgen`));
@@ -1924,10 +2366,6 @@
const e = ep(o);
const row = episodeRow(e, {showFeed: true, showDate: true, noSelect: true});
if (o.favorite) row.querySelector('.nui-pod-title').prepend(el('span', 'nui-pod-star', '★ '));
- row.append(
- inQueue(e.id) ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued')
- : button('+', 'Einreihen', () => enqueue(e)),
- button('⋯', 'Mehr', () => episodeMenu(e)));
list.append(row);
}
root.append(list);
@@ -1949,7 +2387,7 @@
const list = el('div', 'nui-pod-list');
for (const r of dir.items) {
const row = el('div', 'nui-pod-row');
- row.append(art(r.artwork_url));
+ row.append(art(r.artwork_url, null, null, r.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', r.title), el('div', 'nui-pod-meta', [r.author, r.genre].filter(Boolean).join(' · ')));
row.append(main);
@@ -2055,7 +2493,7 @@
const p = preview;
const d = p.data || {};
const head = el('div', 'nui-pod-feedhead');
- head.append(button('‹', 'Zurück', closePreview), art(d.artwork, 'big'));
+ head.append(button('‹', 'Zurück', closePreview), art(d.artwork, 'big', null, d.title));
const info = el('div', 'nui-pod-main');
info.append(el('div', 'nui-pod-title', plain(d.title) || 'Podcast'),
el('div', 'nui-pod-meta', plain(d.author) || p.url));
@@ -2108,7 +2546,7 @@
const list = el('div', 'nui-pod-list');
for (const e of eps) {
const row = el('div', 'nui-pod-row');
- row.append(art(e.artwork || d.artwork));
+ row.append(art(e.artwork, null, d.artwork, d.title));
const main = el('div', 'nui-pod-main');
main.append(el('div', 'nui-pod-title', plain(e.title)),
el('div', 'nui-pod-meta', [e.duration && minutes(e.duration), day(e.date)].filter(Boolean).join(' · ')));
@@ -2387,21 +2825,9 @@
if (fav) { fav.textContent = d.favorite ? '★' : '☆'; fav.classList.toggle('on', !!d.favorite); }
}
const chapters = d.chapters || [];
- if (live && chapters.length > 1) {
- // Previous / next chapter, like AntennaPod's chapter buttons.
- const nav = el('div', 'nui-pod-chapnav');
- const jump = dir => {
- const t = (player.pos || 0) / 1000;
- let i = chapters.length - 1;
- while (i > 0 && chapters[i].start > t + 0.5) i--;
- // "Back" within the first seconds of a chapter goes to the one before.
- const target = dir > 0 ? i + 1 : (t - chapters[i].start < 3 ? i - 1 : i);
- if (target < 0 || target >= chapters.length) return;
- N.podcastSeek(Math.round(chapters[target].start * 1000));
- };
- nav.append(button('⏮', 'Voriges Kapitel', () => jump(-1)), el('span', 'nui-pod-chapnow'), button('⏭', 'Nächstes Kapitel', () => jump(1)));
- box.append(nav);
- }
+ // The full player lists the chapters right under the title (chapterBox); here only the notes.
+ const chapBox = live && sheet ? sheet.querySelector('.nui-pod-chapbox') : null;
+ if (chapBox) chapterBox(chapBox, chapters, id);
const tabs = el('div', 'nui-pod-subtabs');
const body = el('div', 'nui-pod-notesbody');
const show = which => {
@@ -2444,11 +2870,48 @@
b.addEventListener('click', () => show(which));
tabs.append(b);
};
- if (chapters.length) tab('chapters', `Kapitel (${chapters.length})`);
+ if (chapters.length && !chapBox) tab('chapters', `Kapitel (${chapters.length})`);
tab('notes', 'Shownotes');
if (d.has_transcript) tab('transcript', 'Transkript');
box.append(tabs, body);
- show(chapters.length ? 'chapters' : 'notes');
+ show(chapters.length && !chapBox ? 'chapters' : 'notes');
+ }
+
+ // The chapters in the full player: which one plays, previous / next (like AntennaPod's
+ // chapter buttons), and the list to tap; it keeps the playing chapter in view.
+ function chapterBox(box, chapters, id) {
+ box.textContent = '';
+ box.dataset.episode = String(id);
+ box.hidden = !chapters.length;
+ // A smaller cover makes room for them.
+ if (box.parentElement) box.parentElement.classList.toggle('nui-pod-withchapters', !!chapters.length);
+ if (!chapters.length) return;
+ const jump = dir => {
+ const t = (player.pos || 0) / 1000;
+ let i = chapters.length - 1;
+ while (i > 0 && chapters[i].start > t + 0.5) i--;
+ // "Back" within the first seconds of a chapter goes to the one before.
+ const target = dir > 0 ? i + 1 : (t - chapters[i].start < 3 ? i - 1 : i);
+ if (target < 0 || target >= chapters.length) return;
+ N.podcastSeek(Math.round(chapters[target].start * 1000));
+ };
+ const head = el('div', 'nui-pod-chaphead');
+ head.append(el('span', 'nui-pod-chapcount', `${chapters.length} Kapitel`));
+ if (chapters.length > 1) {
+ head.append(button('⏮︎', 'Voriges Kapitel', () => jump(-1), 'nui-pod-chapbtn'),
+ button('⏭︎', 'Nächstes Kapitel', () => jump(1), 'nui-pod-chapbtn'));
+ }
+ const ol = el('ol', 'nui-pod-chapters nui-pod-chaplist');
+ chapters.forEach((c, i) => {
+ const li = el('li', 'nui-pod-chapter');
+ li.dataset.start = String(c.start || 0);
+ li.dataset.index = String(i);
+ li.append(el('span', 'nui-pod-chapter-t', clock(c.start || 0)), el('span', 'nui-pod-chapter-title', plain(c.title || `Kapitel ${i + 1}`)));
+ li.addEventListener('click', () => N.podcastSeek(Math.round((c.start || 0) * 1000)));
+ ol.append(li);
+ });
+ box.append(head, ol);
+ markChapter(box, (player.pos || 0) / 1000);
}
function markChapter(scope, seconds) {
@@ -2456,11 +2919,22 @@
let current = -1;
items.forEach((li, i) => { if (parseFloat(li.dataset.start) <= seconds + 0.5) current = i; });
items.forEach((li, i) => li.classList.toggle('current', i === current));
- const now = scope.querySelector('.nui-pod-chapnow');
- if (now) {
- const d = storedDetails(parseInt((scope.querySelector('.nui-pod-notesbox') || scope).dataset.episode, 10));
- const ch = d && d.chapters ? d.chapters.filter(c => c.start <= seconds + 0.5).pop() : null;
- now.textContent = ch ? plain(ch.title || '') : '';
+ const count = scope.querySelector('.nui-pod-chapcount');
+ const list = scope.querySelector('.nui-pod-chaplist');
+ if (count && list) {
+ const n = list.children.length;
+ count.textContent = current >= 0 ? `Kapitel ${current + 1} von ${n}` : `${n} Kapitel`;
+ // The playing chapter stays in view in the short list (unless it was just scrolled by hand).
+ if (current >= 0 && list.dataset.shown !== String(current) && Date.now() - (list.touched || 0) > 6000) {
+ list.dataset.shown = String(current);
+ const li = list.children[current];
+ list.scrollTop = Math.max(0, li.offsetTop - list.offsetTop - li.offsetHeight);
+ }
+ if (!list.watched) {
+ list.watched = true;
+ list.addEventListener('touchstart', () => { list.touched = Date.now(); }, {passive: true});
+ list.addEventListener('wheel', () => { list.touched = Date.now(); }, {passive: true});
+ }
}
markTranscript(scope, seconds);
}
@@ -2558,8 +3032,10 @@
const panel = sheet.firstChild;
const top = el('div', 'nui-pod-player-top');
top.append(button('⌄', 'Player schließen', closePlayer));
- panel.append(top, art(player.artwork, 'player'));
- panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'));
+ panel.append(top, art(player.artwork, 'player', null, player.feed));
+ const chapBox = el('div', 'nui-pod-chapbox');
+ chapBox.hidden = true;
+ panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox);
const range = el('input', 'nui-pod-seek');
range.type = 'range';
range.min = 0;
@@ -3026,15 +3502,15 @@
toast('Wird heruntergeladen');
}
- function dlDelete(e, running) {
+ function dlDelete(e, running, quiet) {
N.podcastDeleteDownload(e.id);
// "Löschen nimmt aus der Warteschlange" (account): the app takes it out too.
const out = !running && settings().delete_dequeue !== false && 'delete_dequeue' in settings() && inQueue(e.id);
- toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht');
+ if (!quiet) toast(running ? 'Download abgebrochen' : out ? 'Download gelöscht, aus der Warteschlange genommen' : 'Download gelöscht');
}
- function dlAppend(row, e) {
- if (!DL || !(e.raw.episode__audio_url || e.raw.audio_url)) return;
+ // The row's download button (the episode lists): ↓, a ring while it loads, ✓ when it's here.
+ function dlButton(e) {
const d = downloads[e.id];
const state = d ? d.state : null;
let b;
@@ -3050,14 +3526,28 @@
}
b.dataset.dl = e.id;
dlRing(b);
- row.append(b);
+ return b;
+ }
+
+ // Elsewhere a small mark in front of the length: an arrow in a circle when it's here,
+ // a filling ring while it loads, ! when it failed.
+ function dlMark(e) {
+ if (!DL) return null;
+ const d = downloads[e.id];
+ if (!d) return null;
+ const m = el('span', 'nui-pod-dlmark nui-pod-dlmark-' + d.state);
+ m.dataset.dlmark = e.id;
+ m.title = d.state === 'done' ? 'Heruntergeladen' : d.state === 'failed' ? 'Download hat nicht geklappt' : 'Wird heruntergeladen';
+ m.textContent = d.state === 'done' ? '↓' : d.state === 'failed' ? '!' : '';
+ dlRing(m);
+ return m;
}
function dlRing(b) {
- const d = downloads[b.dataset.dl];
+ const d = downloads[b.dataset.dl || b.dataset.dlmark];
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 + '';
+ if (b.dataset.dl) b.firstChild.textContent = d.state === 'queued' ? (d.reason === 'paused' ? '‖' : '…') : d.progress + '';
}
// "wartet auf WLAN", "neuer Versuch um 14:05" …
@@ -3124,7 +3614,7 @@
action: () => openSheet('Downloads aufräumen', DL_CLEANUPS.map(c => ({
icon: s.cleanup === c.value ? '●' : '○', label: c.label, hint: c.hint, active: s.cleanup === c.value,
action: () => dlSet({cleanup: c.value})})))},
- done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`, danger: true,
+ done.length > 0 && {icon: '✕', label: `Downloads löschen (${size(bytes)})`,
hint: done.length === 1 ? '1 Folge' : done.length + ' Folgen',
action: () => openSheet(`Alle ${done.length} Downloads löschen?`, [
{icon: '✕', label: 'Löschen', hint: size(bytes), danger: true,
@@ -3143,7 +3633,7 @@
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);
+ document.querySelectorAll('#nui-pod [data-dl], #nui-pod [data-dlmark]').forEach(dlRing);
dlIndicatorText();
} else {
render();
@@ -3225,6 +3715,20 @@
renderDownloads(true);
}
+ // The finished downloads: the episode list with the filter „Geladen“ (▶ plays, swipes delete).
+ function showDownloaded() {
+ closeDownloads();
+ closePlayer();
+ selected = null;
+ feedView = null;
+ preview = null;
+ view = 'episodes';
+ try { localStorage.setItem('nui_pod_view', view); } catch (e) {}
+ if (typeof showTab === 'function') showTab('podcasts');
+ setEpisodeList(['downloaded'], 'new');
+ scrollTop(0);
+ }
+
function dlTitle(d) {
return plain(d.title) || 'Folge ' + d.id;
}
@@ -3249,8 +3753,8 @@
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,
+ ? {icon: '✕', label: 'Löschen', action: () => N.podcastDeleteDownload(d.id)}
+ : {icon: '✕', label: 'Abbrechen', hint: d.bytes ? 'was schon geladen ist, geht mit' : null,
action: () => N.podcastDeleteDownload(d.id)});
return items;
}
@@ -3337,8 +3841,11 @@
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))}`));
+ const row = el('div', 'nui-pod-dls-donerow');
+ row.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))}`),
+ button('Anzeigen ›', 'Die geladenen Folgen als Liste', showDownloaded, 'btn nui-pod-dls-show'));
+ dlBody.append(row);
}
if (!all.length) dlBody.append(el('div', 'nui-pod-empty', 'Keine Downloads. Beim ↓ einer Folge lädst du sie aufs Telefon.'));
panel.scrollTop = scroll;
@@ -3408,6 +3915,7 @@
renderPlayer();
// The current episode changed: the queue view marks it.
if (before.episodeId !== player.episodeId || before.kind !== player.kind) render();
+ else if (!!before.playing !== !!player.playing) playButtons();
};
// The app loads this in ; the browser may load it after the page is parsed.
@@ -3439,12 +3947,23 @@
if (typeof _currentTopTab !== 'undefined' && _currentTopTab === 'podcasts') {
if (closePreview()) return true;
if (feedView) { feedView = null; render(); return true; }
+ // From the search back to the list it was opened from.
+ if (view === 'search') {
+ try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) { view = 'queue'; }
+ if (!VIEWS.some(v => v.id === view)) view = 'queue';
+ render();
+ return true;
+ }
showTab('books');
return true;
}
return false;
};
+ // A swipe waiting for „Rückgängig“ is done when the page goes away.
+ document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') commitSwipe(); });
+ window.addEventListener('pagehide', commitSwipe);
+
// Pull the latest state once per start; the server has done the fetching.
N.podcastRefresh(false);
window.__dioraSubscribe();
diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css
index 8e02e97..0c103dc 100644
--- a/static/podcasts/podcast-ui.css
+++ b/static/podcasts/podcast-ui.css
@@ -1,9 +1,15 @@
-/* Copied from diora-android 99528f2 2026-10-02: the podcast parts of web-native/static/native.css
+/* Copied from diora-android 339de8f 2026-10-02: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */
/* ---- bottom sheet ---------------------------------------------------------------- */
+/* Monospace stays the brand's (headings, tabs, numbers); reading text — titles, episode
+ lines, show notes, descriptions, hints — is the system's sans (no font download). */
+:root {
+ --nui-sans: system-ui, -apple-system, 'Segoe UI', Roboto, 'Noto Sans', 'Helvetica Neue', Arial, sans-serif;
+}
+
#nui-sheet {
position: fixed;
inset: 0;
@@ -81,13 +87,18 @@
}
.nui-row-hint {
- font-size: 12px;
+ font-family: var(--nui-sans);
+ font-size: 13px;
font-weight: 400;
+ letter-spacing: normal;
+ line-height: 1.35;
color: var(--nui-muted);
}
+/* Red only where it's meant: the destructive choice in a confirmation. What is set is bright and bold. */
.nui-danger { color: var(--accent); }
-.nui-active .nui-row-label { color: var(--accent); }
+.nui-active .nui-row-label { color: var(--fg, #fff); font-weight: 700; }
+.nui-active .nui-row-hint { color: var(--fg, #fff); }
.nui-row-split {
padding: 0 16px 0 0;
@@ -150,84 +161,103 @@ html.nui-one-player #nui-mini { display: none !important; }
scrollbar-width: none;
}
.nui-pod-tab {
- flex: 1 0 auto;
+ flex: 1 1 auto;
+ min-width: 0;
min-height: var(--nui-tap);
- padding: 0 10px;
+ padding: 0 5px;
display: inline-flex;
align-items: center;
justify-content: center;
- gap: 6px;
+ gap: 4px;
border: none;
border-bottom: 2px solid transparent;
background: none;
color: var(--nui-muted);
font: inherit;
- font-size: 14px;
+ font-size: 13px;
+ letter-spacing: 0;
white-space: nowrap;
}
-.nui-pod-tab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
+.nui-pod-tab.active { color: var(--fg, #fff); font-weight: 700; border-bottom-color: var(--fg, #fff); }
.nui-pod-count {
- min-width: 20px;
- padding: 1px 6px;
- border-radius: 10px;
+ min-width: 18px;
+ padding: 1px 5px;
+ border-radius: 9px;
background: #2a2a2a;
- font-size: 11px;
+ color: #ddd;
+ font-size: 10.5px;
font-weight: 700;
+ letter-spacing: 0;
text-align: center;
+ font-variant-numeric: tabular-nums;
}
-.nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
+.nui-pod-count-queue { background: var(--accent); color: #fff; }
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
-.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; }
+.nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; }
.nui-pod-list { display: flex; flex-direction: column; }
.nui-pod-row {
display: flex;
align-items: center;
- gap: 12px;
- min-height: 72px;
- padding: 8px 0;
+ gap: 10px;
+ min-height: 58px;
+ padding: 5px 0;
border-bottom: 1px solid var(--border);
background: var(--bg);
-webkit-tap-highlight-color: transparent;
}
.nui-pod-row:active { background: #141414; }
.nui-pod-row.nui-pod-played .nui-pod-title { color: var(--nui-muted); font-weight: 400; }
+.nui-pod-row.nui-pod-gone { display: none; }
.nui-pod-row.nui-pod-current .nui-pod-title { color: var(--accent); }
.nui-pod-row.nui-pod-current .nui-pod-title::before { content: '▶ '; }
.nui-pod-art {
flex: none;
- width: 52px;
- height: 52px;
+ width: 46px;
+ height: 46px;
border-radius: 4px;
overflow: hidden;
background: #1c1c1c;
+ display: flex;
+ align-items: center;
+ justify-content: center;
}
+/* No cover anywhere: the podcast's initial on a neutral tile. */
+.nui-pod-initial { font-size: 20px; font-weight: 700; color: #6a6a6a; line-height: 1; }
+.nui-pod-art-big .nui-pod-initial { font-size: 30px; }
+.nui-pod-art-player .nui-pod-initial { font-size: 96px; }
.nui-pod-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.nui-pod-art-big { width: 72px; height: 72px; }
+.nui-pod-art-big { width: 64px; height: 64px; }
.nui-pod-art-mini { width: 44px; height: 44px; margin-left: 10px; }
.nui-pod-art-player { width: min(72vw, 320px); height: min(72vw, 320px); border-radius: 8px; align-self: center; }
-.nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
+.nui-pod-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.nui-pod-title {
- font-size: 15px;
- font-weight: 700;
- line-height: 1.3;
+ font-family: var(--nui-sans);
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: normal;
+ line-height: 1.22;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
+ overflow-wrap: anywhere;
}
.nui-pod-meta {
+ font-family: var(--nui-sans);
font-size: 12px;
font-weight: 400;
+ letter-spacing: normal;
+ font-variant-numeric: tabular-nums;
color: var(--nui-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
-.nui-pod-progress { height: 3px; margin-top: 4px; border-radius: 2px; background: #2a2a2a; overflow: hidden; max-width: 160px; }
+.nui-pod-progress { height: 3px; margin-top: 2px; border-radius: 2px; background: #2a2a2a; overflow: hidden; max-width: 160px; }
.nui-pod-progress > span { display: block; height: 100%; background: var(--accent); }
.nui-pod-badge {
flex: none;
@@ -260,18 +290,10 @@ html.nui-one-player #nui-mini { display: none !important; }
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.6);
}
-.nui-pod-feedhead { display: flex; align-items: center; gap: 10px; margin: 4px 0 10px; }
-.nui-pod-switch {
- display: grid;
- grid-template-columns: auto 1fr;
- column-gap: 10px;
- align-items: center;
- padding: 10px 0;
- border-bottom: 1px solid var(--border);
- font-size: 14px;
-}
-.nui-pod-switch input { width: 20px; height: 20px; accent-color: var(--accent); }
-.nui-pod-switch .nui-pod-meta { grid-column: 2; }
+.nui-pod-feedhead { display: flex; align-items: center; gap: 10px; margin: 2px 0 6px; }
+.nui-pod-feedhead > .nui-icon:first-child { min-width: 32px; margin-left: -6px; }
+.nui-pod-feedhead .nui-pod-title { font-size: 16px; }
+.nui-pod-feedhead .nui-pod-infobtn { min-width: 38px; }
.nui-pod-search { display: flex; gap: 8px; margin: 4px 0 10px; }
.nui-pod-search input { flex: 1; min-width: 0; min-height: var(--nui-tap); font-size: 16px; }
@@ -316,8 +338,8 @@ html.nui-one-player #nui-mini { display: none !important; }
}
.nui-pod-player-top { display: flex; justify-content: center; }
.nui-pod-player-top .nui-icon { font-size: 26px; color: var(--nui-muted); }
-.nui-pod-player-title { font-size: 18px; font-weight: 700; line-height: 1.3; text-align: center; }
-.nui-pod-player-feed { margin-top: -6px; font-size: 13px; color: var(--nui-muted); text-align: center; }
+.nui-pod-player-title { font-family: var(--nui-sans); font-size: 18px; font-weight: 600; letter-spacing: normal; line-height: 1.3; text-align: center; }
+.nui-pod-player-feed { margin-top: -6px; font-family: var(--nui-sans); font-size: 13.5px; font-weight: 400; letter-spacing: normal; color: var(--nui-muted); text-align: center; }
.nui-pod-seek { width: 100%; accent-color: var(--accent); height: 28px; }
.nui-pod-times { display: flex; justify-content: space-between; margin-top: -8px; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
.nui-pod-controls { display: flex; align-items: center; justify-content: center; gap: 28px; }
@@ -366,7 +388,7 @@ body.eink .nui-pod-art { background: #eee; }
.nui-pod-head .nui-pod-summary { padding: 6px 2px; }
.nui-pod-tools { display: flex; align-items: center; margin-right: -6px; }
.nui-pod-tools .nui-icon { font-size: 18px; color: var(--nui-muted); }
-.nui-pod-lock.on { color: var(--accent) !important; }
+.nui-pod-lock.on { color: var(--fg, #fff) !important; }
.nui-pod-selbar {
position: sticky;
top: 0;
@@ -385,14 +407,13 @@ body.eink .nui-pod-art { background: #eee; }
.nui-pod-selactions .btn { min-height: 38px; flex: 1 0 auto; }
.nui-pod-selactions .btn:disabled { opacity: 0.4; }
.nui-pod-check { flex: none; width: 28px; font-size: 22px; color: var(--nui-muted); text-align: center; }
-.nui-pod-check.on { color: var(--accent); }
+.nui-pod-check.on { color: var(--fg, #fff); }
.nui-pod-row.nui-pod-selecting { user-select: none; -webkit-user-select: none; }
body.eink .nui-pod-check.on,
body.eink .nui-pod-lock.on { color: #000 !important; }
body.eink .nui-pod-selbar { background: #fff; }
-/* podcast playback: per-podcast chips, sleep timer */
-.nui-pod-feedprefs { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; border-bottom: 1px solid var(--border); }
+/* podcast chips (the list filters), sleep timer */
.nui-pod-chip {
display: inline-flex;
align-items: baseline;
@@ -407,28 +428,9 @@ body.eink .nui-pod-selbar { background: #fff; }
font-size: 13px;
}
.nui-pod-chip strong { color: inherit; font-weight: 700; }
-.nui-pod-chip.on { border-color: var(--accent); color: inherit; }
-.nui-pod-chip.on strong { color: var(--accent); }
+.nui-pod-chip.on { border-color: #8a8a8a; color: var(--fg, #fff); font-weight: 700; }
.nui-pod-sleep { font-size: 15px !important; white-space: nowrap; }
.nui-pod-sleep.on { color: var(--accent) !important; font-weight: 700; }
-/* unsubscribing: a quiet link, asked once more in place */
-.nui-pod-unsub { display: flex; justify-content: flex-end; padding: 6px 0; border-bottom: 1px solid var(--border); }
-.nui-pod-unsub-btn {
- min-height: var(--nui-tap, 44px);
- padding: 0 4px;
- border: none;
- background: none;
- color: var(--nui-muted);
- font: inherit;
- font-size: 13px;
-}
-.nui-pod-unsub-ask { display: block; padding: 12px 0; }
-.nui-pod-unsub-ask .nui-pod-title { display: block; overflow: visible; }
-.nui-pod-unsub-ask .nui-pod-meta { margin-top: 4px; line-height: 1.4; white-space: normal; overflow: visible; }
-.nui-pod-unsub-actions { display: flex; gap: 8px; margin-top: 10px; }
-.nui-pod-unsub-actions .btn { min-height: var(--nui-tap, 44px); }
-.nui-pod-unsub-yes { color: var(--accent); border-color: var(--accent); font-weight: 700; }
-body.eink .nui-pod-unsub-yes { color: #000; border-color: #000; }
body.eink .nui-pod-chip.on { border-color: #000; }
body.eink .nui-pod-chip.on strong,
body.eink .nui-pod-sleep.on { color: #000 !important; }
@@ -446,7 +448,7 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
font: inherit;
font-size: 14px;
}
-.nui-pod-subtab.active { color: inherit; font-weight: 700; border-bottom-color: var(--accent); }
+.nui-pod-subtab.active { color: inherit; font-weight: 700; border-bottom-color: var(--fg, #fff); }
.nui-pod-chapters { list-style: none; margin: 0; padding: 0; }
.nui-pod-chapter {
display: grid;
@@ -458,12 +460,14 @@ body.eink .nui-pod-sleep.on { color: #000 !important; }
font-size: 14px;
cursor: pointer;
}
+.nui-pod-chapter-title { font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14.5px; }
+.nui-pod-chapter.current .nui-pod-chapter-title { font-weight: 600; }
.nui-pod-chapter-t { color: var(--nui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
.nui-pod-chapter.current { color: var(--accent); font-weight: 700; }
.nui-pod-chapter.current .nui-pod-chapter-t { color: var(--accent); }
-.nui-pod-notes { font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
+.nui-pod-notes { font-family: var(--nui-sans); font-size: 14.5px; font-weight: 400; letter-spacing: normal; line-height: 1.55; overflow-wrap: anywhere; }
.nui-pod-notes img { max-width: 100%; height: auto; }
-.nui-pod-notes a { color: var(--accent); }
+.nui-pod-notes a { color: inherit; text-decoration: underline; text-decoration-color: #777; }
.nui-pod-notes .nui-pod-stamp { font-weight: 700; text-decoration: none; border-bottom: 1px dotted currentColor; }
.nui-pod-notes table { display: block; overflow-x: auto; }
.nui-pod-notes-sheet .nui-pod-player-panel { max-height: 88%; }
@@ -506,7 +510,7 @@ body.eink .nui-pod-subtab.active { border-bottom-color: #000; color: #000; }
color: var(--nui-muted);
background:
radial-gradient(closest-side, var(--bg) 78%, transparent 80% 100%),
- conic-gradient(var(--accent) var(--p, 0%), #2a2a2a 0);
+ conic-gradient(#d0d0d0 var(--p, 0%), #2a2a2a 0);
}
body.eink .nui-pod-dl-done { color: #000; }
body.eink .nui-pod-dl-done::after { border-color: #000; }
@@ -518,7 +522,7 @@ body.eink .nui-pod-dl-ring > span {
}
/* 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 { min-width: 0 !important; padding: 0 8px; font-size: 13px !important; font-weight: 700; color: var(--fg, #fff); 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; }
@@ -558,13 +562,11 @@ 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 { padding: 10px 12px; margin: 8px 0; border: 1px solid var(--nui-muted); border-radius: 6px; line-height: 1.45; font-size: 14px; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; }
.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; }
.nui-pod-transcript { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
-.nui-pod-seg { margin: 0; padding: 6px 4px; font-size: 14px; line-height: 1.5; color: var(--nui-muted); border-radius: 4px; cursor: pointer; }
+.nui-pod-seg { margin: 0; padding: 6px 4px; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14.5px; line-height: 1.5; color: var(--nui-muted); border-radius: 4px; cursor: pointer; }
.nui-pod-seg.current { color: inherit; background: #1a1a1a; }
.nui-pod-speaker { color: inherit; }
body.eink .nui-pod-feederr { color: #000; }
@@ -579,13 +581,164 @@ body.eink .nui-pod-seg.current { color: #000; background: #e6e6e6; }
.nui-pod-sec-note { padding: 10px 2px; font-size: 13px; color: var(--nui-muted); }
.nui-pod-feedcount { flex: none; }
.nui-pod-infobtn { flex: none; font-size: 20px; color: var(--nui-muted); }
-.nui-pod-info-desc { font-size: 14px; font-weight: 400; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; padding: 4px 0 10px; border-bottom: 1px solid var(--border); }
+.nui-pod-info-desc { font-family: var(--nui-sans); letter-spacing: normal; font-size: 14.5px; font-weight: 400; line-height: 1.5; white-space: pre-line; overflow-wrap: anywhere; padding: 4px 0 10px; border-bottom: 1px solid var(--border); }
.nui-pod-facts { font-weight: 400; display: grid; grid-template-columns: max-content 1fr; gap: 8px 14px; margin: 10px 0 4px; font-size: 14px; }
.nui-pod-facts dt { color: var(--nui-muted); }
.nui-pod-facts dd { margin: 0; min-width: 0; font-weight: 400; overflow-wrap: anywhere; }
-.nui-pod-facts a { color: var(--accent); }
+.nui-pod-facts a { color: inherit; text-decoration: underline; text-decoration-color: #777; }
.nui-pod-info-url { display: block; font-size: 12px; color: var(--nui-muted); }
.nui-pod-info-actions { display: flex; gap: 8px; margin-top: 6px; }
.nui-pod-info-actions .btn { min-height: 36px; padding: 0 14px; font-size: 14px; }
body.eink .nui-pod-sec { color: #000; border-bottom-color: #000; }
body.eink .nui-pod-facts a { color: #000; text-decoration: underline; }
+
+/* ---- UI round: rows, swipes, the feed page's line, the player's chapters ------------------ */
+
+/* the row's one action, its menu, the download mark */
+.nui-pod-row > .nui-icon { min-width: 40px; }
+.nui-pod-row > .nui-pod-menu { min-width: 34px; font-size: 18px; color: var(--nui-muted); margin-right: -4px; }
+.nui-pod-playbtn { font-size: 17px !important; color: var(--accent); }
+.nui-pod-row > .nui-pod-dl { min-width: 40px !important; margin-left: 0; }
+.nui-pod-dlmark {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ margin-right: 5px;
+ vertical-align: -2px;
+ border-radius: 50%;
+ border: 1px solid #8a8a8a;
+ font-size: 9px;
+ font-weight: 700;
+ line-height: 1;
+ color: #cfcfcf;
+}
+.nui-pod-dlmark-done { background: #3a3a3a; border-color: #3a3a3a; color: #fff; }
+.nui-pod-dlmark-failed { border-color: var(--yellow); color: var(--yellow); }
+.nui-pod-dlmark-queued,
+.nui-pod-dlmark-downloading {
+ border: none;
+ background:
+ radial-gradient(closest-side, var(--bg) 62%, transparent 66% 100%),
+ conic-gradient(#d0d0d0 var(--p, 0%), #3a3a3a 0);
+}
+.nui-pod-queued { font-size: 18px !important; }
+
+/* swiping a row: it moves, what the swipe does shows underneath */
+.nui-pod-list { position: relative; }
+.nui-pod-list > .nui-pod-row { touch-action: pan-y; }
+.nui-pod-row.nui-pod-swiping { position: relative; z-index: 1; will-change: transform; }
+.nui-pod-swipebg {
+ position: absolute;
+ left: 0;
+ right: 0;
+ z-index: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 18px;
+ background: #161616;
+ color: var(--nui-muted);
+ font-size: 14px;
+ white-space: nowrap;
+ pointer-events: none;
+}
+.nui-pod-swipebg.armed { background: #2c2c2c; color: var(--fg, #fff); font-weight: 700; }
+#nui-pod-undo {
+ position: fixed;
+ left: 50%;
+ bottom: calc(var(--nui-dock-h, 124px) + 12px + env(safe-area-inset-bottom, 0px));
+ transform: translateX(-50%);
+ z-index: 950;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ max-width: calc(100% - 24px);
+ padding: 4px 6px 4px 16px;
+ border-radius: 8px;
+ background: #262626;
+ color: #fff;
+ font-size: 14px;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.18s;
+}
+#nui-pod-undo.nui-show { opacity: 1; pointer-events: auto; }
+.nui-pod-undo-text { font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.nui-pod-undo-btn {
+ min-height: 40px;
+ padding: 0 10px;
+ border: none;
+ background: none;
+ color: #fff;
+ font: inherit;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ cursor: pointer;
+}
+
+/* the top bar's search */
+.nui-pod-searchbtn svg { display: block; }
+.nui-pod-searchbtn.on { color: var(--fg, #fff); }
+
+/* the feed page: one quiet line instead of the settings, the ⚙ sheet's parts */
+.nui-pod-feedsum {
+ display: block;
+ width: 100%;
+ margin: -2px 0 6px;
+ padding: 2px 0 8px;
+ border: none;
+ border-bottom: 1px solid var(--border);
+ background: none;
+ color: var(--nui-muted);
+ font-family: var(--nui-sans);
+ font-size: 13px;
+ font-weight: 400;
+ letter-spacing: normal;
+ line-height: 1.4;
+ text-align: left;
+ cursor: pointer;
+}
+.nui-pod-gear { font-size: 22px !important; }
+.nui-pod-sheetsep { height: 1px; margin: 6px 20px; background: var(--border); }
+.nui-pod-sheetnote { padding: 2px 20px 10px; white-space: normal; line-height: 1.45; font-size: 13px; }
+
+/* the player's chapters, right under the title */
+.nui-pod-chapbox { margin-top: -4px; border: 1px solid var(--border); border-radius: 8px; padding: 2px 4px 2px 10px; }
+.nui-pod-chapbox[hidden] { display: none; }
+.nui-pod-chaphead { display: flex; align-items: center; gap: 2px; min-height: 40px; }
+.nui-pod-chapcount { flex: 1; min-width: 0; font-size: 12px; color: var(--nui-muted); font-variant-numeric: tabular-nums; }
+.nui-pod-chapbtn { min-width: 40px !important; min-height: 40px !important; font-size: 16px !important; }
+.nui-pod-chaplist { position: relative; max-height: 168px; overflow-y: auto; margin: 0 -4px 4px -10px; padding: 0 4px 0 10px; border-top: 1px solid var(--border); }
+.nui-pod-chaplist .nui-pod-chapter { padding: 8px 4px; }
+.nui-pod-chaplist .nui-pod-chapter:last-child { border-bottom: none; }
+.nui-pod-art-player { width: min(64vw, 300px); height: min(64vw, 300px); }
+.nui-pod-withchapters .nui-pod-art-player { width: min(46vw, 220px); height: min(46vw, 220px); }
+
+/* the downloads overview: the finished ones as a list */
+.nui-pod-dls-donerow { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
+.nui-pod-dls-show { min-height: 36px; padding: 0 12px; font-size: 14px !important; }
+
+body.eink .nui-pod-count-queue { background: #000; color: #fff; }
+body.eink .nui-pod-tab.active .nui-pod-count { background: #000; color: #fff; }
+body.eink .nui-pod-playbtn { color: #000; }
+body.eink .nui-pod-dlmark { border-color: #000; color: #000; }
+body.eink .nui-pod-dlmark-done { background: #000; color: #fff; }
+body.eink .nui-pod-dlmark-queued,
+body.eink .nui-pod-dlmark-downloading {
+ background:
+ radial-gradient(closest-side, #fff 62%, transparent 66% 100%),
+ conic-gradient(#000 var(--p, 0%), #ccc 0);
+}
+body.eink .nui-pod-swipebg { background: #eee; color: #444; }
+body.eink .nui-pod-swipebg.armed { background: #000; color: #fff; }
+body.eink #nui-pod-undo { background: #000; box-shadow: none; }
+body.eink .nui-pod-initial { color: #555; }
+body.eink .nui-pod-feedsum { color: #444; border-bottom-color: #000; }
+body.eink .nui-pod-chapbox { border-color: #000; }
+body.eink .nui-pod-chip.on { border-color: #000; color: #000; }
+body.eink .nui-pod-notes a,
+body.eink .nui-pod-facts a { text-decoration-color: #000; }
diff --git a/static/podcasts/podcasts-web.css b/static/podcasts/podcasts-web.css
index 384160e..b33310b 100644
--- a/static/podcasts/podcasts-web.css
+++ b/static/podcasts/podcasts-web.css
@@ -161,6 +161,9 @@ html.dpw .nui-mini-toggle { font-size: 22px; color: var(--accent); }
transition: opacity 0.2s;
}
#nui-radio-toast.nui-show { opacity: 1; }
+/* The swipe's „Rückgängig“ bar: above the radio bar, like the toast. */
+html.dpw #nui-pod-undo { bottom: calc(var(--dpw-bar-h, 72px) + 80px); }
+html.dpw.nui-pod-mini-open:not(.dpw-in-bar) #nui-pod-undo { bottom: calc(var(--dpw-bar-h, 72px) + 64px + 16px); }
/* ---- a mouse: hover states the phone doesn't need --------------------------------- */
@@ -231,5 +234,11 @@ html.dpw body.bright-bg .nui-pod-art { background: #e4e4e4; }
html.dpw body.bright-bg .nui-pod-count,
html.dpw body.bright-bg .nui-pod-progress,
html.dpw body.bright-bg .nui-pod-mini-bar { background: #d4d4d4; }
-html.dpw body.bright-bg .nui-pod-tab.active .nui-pod-count { background: var(--accent); color: #fff; }
+html.dpw body.bright-bg .nui-pod-count { color: var(--fg); }
+html.dpw body.bright-bg .nui-pod-count-queue { background: var(--accent); color: #fff; }
+html.dpw body.bright-bg .nui-pod-swipebg { background: #ececec; }
+html.dpw body.bright-bg .nui-pod-swipebg.armed { background: #d6d6d6; }
+html.dpw body.bright-bg .nui-pod-dlmark-done { background: #d4d4d4; border-color: #d4d4d4; color: #000; }
+html.dpw body.bright-bg .nui-pod-initial { color: #888; }
+html.dpw body.bright-bg .nui-pod-chip.on { border-color: #555; }
html.dpw body.bright-bg #nui-pod-mini { box-shadow: 0 -4px 14px rgba(0, 0, 0, 0.12); }