Abos & Quellen: alle Abos an einem Ort (Navigation Phase 2)
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m58s
Test / e2e (push) Successful in 1m58s

Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts,
YouTube-Kanäle, Hörbücher, Nachrichten und Blogs:
- ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines
  Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog);
  /feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video);
- eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche;
- je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern,
  abbestellen; OPML-Import und -Export.
In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt
hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt
auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal).
Podcast-UI aus diora-android 50c6f9c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-16 15:23:12 +02:00
parent a48dca07ea
commit 143950df94
13 changed files with 672 additions and 17 deletions

47
docs/sources.md Normal file
View file

@ -0,0 +1,47 @@
# Abos & Quellen (navigation phase 2)
One place to manage every subscription: podcasts, YouTube channels, audiobooks,
news sources and blogs. The everyday views (Neu, Warteschlange, Nachrichten,
Blogs, Hörbücher) show content; adding, changing and removing sources happens
here (their own small entry points lead here).
## Where
`DioraNav` section `sources` ("Abos & Quellen", icon ⚙), panel `#tab-sources`,
`group: 'more'`, `footer: true`:
- not in `groups()` (no tab of its own on a phone);
- in `sections()` with `footer: true`: the sidebar puts it at the bottom, above
"Menü"; the phone's app menu has an entry; diora Reader's bar leaves it out;
- the web page's bar: at the end (wide), as ⚙ (narrow).
## The page (`static/js/sources.js`, diora-web, every platform)
- **Hinzufügen**: one field for an address or search words.
- a YouTube address → its channel (`/podcasts/search/?q=`), as Podcast or Hörbuch;
- another address → `/feeds/discover/`: every feed found, with the kind it
looks like (`suggested_kind`: `podcast` when most entries carry sound or
video, else `news`/`blog`) and whether it's subscribed already
(`subscribed` as a feed, `podcast_subscribed`); the kind can be changed:
Podcast · Hörbuch · Nachrichten · Blog;
- words → the podcast directory (`/podcasts/search/`).
Podcast/Hörbuch subscribe with `POST /podcasts/feeds/add/` (then
`settings {kind: 'audiobook'}`), Nachrichten/Blog with `POST /feeds/sources/add/`.
- **The list**: all sources, filter chips Alle · Podcasts · YouTube · Hörbücher ·
Nachrichten · Blogs · Probleme (fetching fails), and a search over the names.
- **A source's actions** (tapping it): open its content, its settings (the
existing editors), change its kind (Podcast ↔ Hörbuch, Nachrichten ↔ Blog),
unsubscribe.
- **Import & Export**: OPML for podcasts and feeds.
## Hooks it uses (each optional: without it, it goes to the section instead)
| Hook | From | Does |
|------|------|------|
| `__dioraPodcastFeedSettings(feedId)` | native-podcast.js | opens the podcast's settings sheet |
| `__dioraPodcastOpenFeed(feedId)` | native-podcast.js | shows the podcast's episode list (Podcasts section) |
| `__dioraFeedsEdit(sourceId)` | feeds.js | opens the source's editor (Nachrichten/Blogs, manage view) |
| `__dioraFeedsStale()` | feeds.js | its state is old: load again when shown |
| `__dioraAudiobookOpen(feedId)` | audiobooks.js | opens the book |
The host calls it uses: `podcastState`, `podcastFeedSettings`, `podcastRefresh`
(after subscribing), `podcastFeedRemoved` (after unsubscribing).

View file

@ -14,7 +14,7 @@ test('wide: every section in the bar under its group, each shows its panel and v
const wide = page.locator('#tabs .nav-wide'); const wide = page.locator('#tabs .nav-wide');
await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']); await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']);
await expect(wide.locator('[data-nav]')).toHaveText( await expect(wide.locator('[data-nav]')).toHaveText(
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus']); ['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus', 'Abos & Quellen']);
await wide.locator('[data-nav="blogs"]').click(); await wide.locator('[data-nav="blogs"]').click();
await expect(page).toHaveURL(/#blogs$/); await expect(page).toHaveURL(/#blogs$/);
@ -33,7 +33,7 @@ test('narrow: three groups, the sections as a row on top, each group remembers i
await page.setViewportSize({ width: 390, height: 800 }); await page.setViewportSize({ width: 390, height: 800 });
await page.goto('/#news'); await page.goto('/#news');
const narrow = page.locator('#tabs .nav-narrow'); const narrow = page.locator('#tabs .nav-narrow');
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören']); await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören', '⚙']);
await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/); await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/);
const segs = page.locator('#nav-segments'); const segs = page.locator('#nav-segments');
await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']); await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']);
@ -59,3 +59,16 @@ test('old links and direct panel switches land in their section', async ({ page
await page.reload(); await page.reload();
expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered
}); });
test('Abos & Quellen: one page for every subscription, reached from the feeds too', async ({ page }) => {
await page.setViewportSize({ width: 1200, height: 800 });
await page.goto('/#news');
await page.locator('#tab-feeds [data-act="manage"]').first().click();
await expect(page).toHaveURL(/#sources$/);
const tab = page.locator('#tab-sources');
await expect(tab.locator('.src-h1')).toHaveText('Abos & Quellen');
await expect(tab.locator('.src-add-q')).toBeVisible();
await expect(tab.locator('.src-chip.on')).toContainText('Nachrichten'); // came from the news
await tab.locator('[data-filter="all"]').click();
await expect(tab.locator('.src-chip.on')).toContainText('Alle');
});

View file

@ -395,3 +395,29 @@ class FolderAndProgressTest(TestCase):
self.assertEqual(r, {'progress': 0.4, 'read': False}) self.assertEqual(r, {'progress': 0.4, 'read': False})
self.assertEqual(self.client.get('/feeds/blogs/').json()['items'][0]['progress'], 0.4) self.assertEqual(self.client.get('/feeds/blogs/').json()['items'][0]['progress'], 0.4)
self.assertTrue(self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.95}).json()['read']) self.assertTrue(self.post(f'/feeds/items/{self.post_a.id}/progress/', {'progress': 0.95}).json()['read'])
class DiscoverKindTest(TestCase):
"""Abos & Quellen: a feed whose entries carry sound is offered as a podcast."""
def setUp(self):
self.u = User.objects.create_user('u', password='pw12345678')
self.client.force_login(self.u)
def discover(self, items):
xml = ('<?xml version="1.0"?><rss version="2.0"><channel><title>X</title>' + ''.join(
f'<item><title>Folge {i}</title><guid>g{i}</guid>{extra}</item>' for i, extra in enumerate(items)) +
'</channel></rss>')
with mock.patch('feeds.views._probe', lambda url: (('https://ex.org/feed', 'X', feedparser.parse(xml)), None)):
r = self.client.post('/feeds/discover/', json.dumps({'url': 'https://ex.org/feed'}),
content_type='application/json')
return r.json()['feeds'][0]
def test_sound_files_make_a_podcast(self):
enc = '<enclosure url="https://ex.org/a.mp3" type="audio/mpeg" length="1"/>'
f = self.discover([enc] * 4)
self.assertEqual(f['suggested_kind'], 'podcast')
self.assertFalse(f['podcast_subscribed'])
def test_text_entries_stay_news_or_a_blog(self):
self.assertIn(self.discover([''] * 4)['suggested_kind'], ('news', 'blog'))

View file

@ -241,20 +241,33 @@ def discover(request):
found = [feed] found = [feed]
except requests.RequestException: except requests.RequestException:
return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502) return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502)
from podcasts.models import PodcastFeed
out = [] out = []
for u, title, parsed in found: for u, title, parsed in found:
entries = parsed.entries[:5] entries = parsed.entries[:5]
teaser = sum(1 for e in entries if fetch.is_teaser(fetch.parse_entry(e, u)['content'])) teaser = sum(1 for e in entries if fetch.is_teaser(fetch.parse_entry(e, u)['content']))
# Most entries with a sound or video file: a podcast (Abos & Quellen offers it as one).
media = sum(1 for e in parsed.entries[:10] if _has_media(e))
podcast = media and media * 2 >= min(len(parsed.entries), 10)
out.append({ out.append({
'url': u, 'title': title, 'url': u, 'title': title,
'subscribed': Source.objects.filter(user=request.user, url=u).exists(), 'subscribed': Source.objects.filter(user=request.user, url=u).exists(),
'podcast_subscribed': PodcastFeed.objects.filter(user=request.user, rss_url=u).exists(),
'samples': [fetch.plain(e.get('title') or '') for e in entries], 'samples': [fetch.plain(e.get('title') or '') for e in entries],
# Many entries a day suggests news, few long ones a blog. # Many entries a day suggests news, few long ones a blog.
'suggested_kind': Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG, 'suggested_kind': 'podcast' if podcast
else Source.NEWS if len(parsed.entries) >= 25 or teaser >= 3 else Source.BLOG,
}) })
return JsonResponse({'feeds': out}) return JsonResponse({'feeds': out})
def _has_media(entry):
for enc in entry.get('enclosures') or []:
if (enc.get('type') or '').lower().startswith(('audio/', 'video/')):
return True
return False
def _place(user, kind, category, folders, **more): def _place(user, kind, category, folders, **more):
"""Where a new source goes: news into a section (category), a blog into """Where a new source goes: news into a section (category), a blog into
folders (the category of an OPML folder or the add dialog is its folder).""" folders (the category of an OPML folder or the add dialog is its folder)."""

View file

@ -4,7 +4,7 @@
// Tabs // Tabs
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks']; const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks', 'sources'];
const RADIO_SUB_TABS = ['search', 'saved', 'history']; const RADIO_SUB_TABS = ['search', 'saved', 'history'];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View file

@ -505,6 +505,8 @@
// Home's "Weiterhören" card (home.js) plays the book from where it was left. // Home's "Weiterhören" card (home.js) plays the book from where it was left.
window.__dioraAudiobookGo = feedId => goOn(feedId); window.__dioraAudiobookGo = feedId => goOn(feedId);
// Abos & Quellen (sources.js): open a book.
window.__dioraAudiobookOpen = feedId => openBook(Number(feedId));
// ---- following the player ------------------------------------------------------------ // ---- following the player ------------------------------------------------------------

View file

@ -824,6 +824,13 @@
return load(); return load();
} }
if (act === 'manage') { if (act === 'manage') {
// Managing every subscription has its place (Abos & Quellen, docs/sources.md).
const nav = window.DioraNav;
if (nav && nav.sections().some(s => s.id === 'sources')) {
try { localStorage.setItem('diora_sources_filter', st.view === 'blogs' ? 'blog' : 'news'); } catch (err) {}
nav.go('sources');
return;
}
st.view = 'manage'; st.view = 'manage';
st.found = null; st.found = null;
window.scrollTo(0, 0); window.scrollTo(0, 0);
@ -834,6 +841,7 @@
try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; } try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; }
st.editing = null; st.editing = null;
st.opml = null; st.opml = null;
if (st.fromSources && window.DioraNav) { st.fromSources = false; window.DioraNav.go('sources'); return; }
return load(); return load();
} }
if (act === 'seen') { if (act === 'seen') {
@ -994,6 +1002,24 @@
// ---- showing the tab // ---- showing the tab
let shownAt = 0; let shownAt = 0;
// Abos & Quellen (sources.js): a source's editor (null: the management page itself, with
// the OPML import), and "the sources changed, load again".
window.__dioraFeedsEdit = async function (id) {
if (!st.state) await loadState().catch(() => {});
const src = id == null ? null : sources().find(x => x.id === Number(id));
const section = src && src.kind === 'blog' ? 'blogs' : 'news';
if (window.DioraNav) window.DioraNav.go(section); else showTab('feeds');
st.view = 'manage';
st.editing = src ? src.id : null;
st.found = null;
st.fromSources = true;
window.scrollTo(0, 0);
render();
};
window.__dioraFeedsStale = function () {
st.state = null;
shownAt = 0;
};
// Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the // Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the
// tab is shown; its own switch is then hidden. // tab is shown; its own switch is then hidden.
window.__dioraFeedsView = function (view) { window.__dioraFeedsView = function (view) {
@ -1054,7 +1080,7 @@
const dock = document.createElement('div'); const dock = document.createElement('div');
dock.id = 'nui-dock'; dock.id = 'nui-dock';
dock.innerHTML = '<nav id="nui-tabbar">' + (NAV dock.innerHTML = '<nav id="nui-tabbar">' + (NAV
? NAV.sections().map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' + ? NAV.sections().filter(s => !s.footer).map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
esc(s.icon) + '</span>' + esc(s.label) + '</button>').join('') esc(s.icon) + '</span>' + esc(s.label) + '</button>').join('')
: '<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' + : '<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button>') + '</nav>'; '<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button>') + '</nav>';

View file

@ -25,6 +25,9 @@
{id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'}, {id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'},
// The focus timer: the web page's own (the apps never had it). // The focus timer: the web page's own (the apps never had it).
{id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true}, {id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true},
// Managing every subscription (docs/sources.md): at the foot of the sidebar, in the app
// menu on a phone — not a tab of its own.
{id: 'sources', group: 'more', label: 'Abos & Quellen', icon: '⚙', panel: 'sources', account: true, footer: true},
]; ];
const GROUPS = [ const GROUPS = [
{id: 'home', label: 'Home', icon: '⌂'}, {id: 'home', label: 'Home', icon: '⌂'},
@ -46,19 +49,19 @@
if (s.account && !authed) return false; if (s.account && !authed) return false;
if (s.webOnly && nativeUi()) return false; if (s.webOnly && nativeUi()) return false;
if (s.needsPodcasts && !podcastHost()) return false; if (s.needsPodcasts && !podcastHost()) return false;
if (readerApp() && s.group !== 'lesen') return false; // diora Reader: reading only if (readerApp() && s.group !== 'lesen' && s.id !== 'sources') return false; // diora Reader: reading only
if (noRead() && s.group === 'lesen') return false; // TV: no reading if (noRead() && s.group === 'lesen') return false; // TV: no reading
return true; return true;
} }
function sections() { function sections() {
return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon})); return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon, footer: !!s.footer}));
} }
function groups() { function groups() {
const list = sections(); const list = sections();
return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon, return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon,
sections: list.filter(s => s.group === g.id).map(s => s.id)})).filter(g => g.sections.length); sections: list.filter(s => s.group === g.id && !s.footer).map(s => s.id)})).filter(g => g.sections.length);
} }
function load(key) { function load(key) {
@ -74,7 +77,7 @@
// A section of `group`: the one used last there, else its first. // A section of `group`: the one used last there, else its first.
function lastOf(group) { function lastOf(group) {
const list = sections().filter(s => s.group === group); const list = sections().filter(s => s.group === group && !s.footer);
const last = load('diora_nav_last_' + group); const last = load('diora_nav_last_' + group);
const found = list.filter(s => s.id === last)[0]; const found = list.filter(s => s.id === last)[0];
return (found || list[0] || {}).id || null; return (found || list[0] || {}).id || null;
@ -99,7 +102,7 @@
current = id; current = id;
const s = byId[id]; const s = byId[id];
save('diora_nav_section', id); save('diora_nav_section', id);
save('diora_nav_last_' + s.group, id); if (!s.footer) save('diora_nav_last_' + s.group, id);
syncHash(); syncHash();
render(); render();
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} }); listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
@ -188,15 +191,19 @@
function render() { function render() {
const cur = current && byId[current]; const cur = current && byId[current];
const gs = groups(); const gs = groups();
const foot = sections().filter(s => s.footer);
if (tabs && !nativeUi()) { if (tabs && !nativeUi()) {
// Wide: every section under its group; narrow: the groups (their sections on top). // Wide: every section under its group; narrow: the groups (their sections on top).
tabs.innerHTML = tabs.innerHTML =
'<div class="nav-wide">' + gs.map(g => g.id === 'home' '<div class="nav-wide">' + gs.map(g => g.id === 'home'
? button('home', 'Home', 'tab-btn', current === 'home') ? button('home', 'Home', 'tab-btn', current === 'home')
: '<span class="nav-group-label">' + esc(g.label) + '</span>' + : '<span class="nav-group-label">' + esc(g.label) + '</span>' +
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + '</div>' + g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') +
(foot.length ? '<span class="nav-foot">' + foot.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '</span>' : '') +
'</div>' +
'<div class="nav-narrow">' + gs.map(g => '<div class="nav-narrow">' + gs.map(g =>
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') + '</div>'; button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') +
foot.map(s => button(s.id, s.icon, 'tab-btn nav-icon', current === s.id)).join('') + '</div>';
} }
if (segs) { if (segs) {
const g = cur ? gs.filter(x => x.id === cur.group)[0] : null; const g = cur ? gs.filter(x => x.id === cur.group)[0] : null;
@ -224,6 +231,9 @@
tabs.addEventListener('click', onClick); tabs.addEventListener('click', onClick);
} }
render(); render();
// The section was restored while the page was still loading (99-init.js), before the
// areas' scripts (feeds.js, audiobooks.js …) were there to fill it: once more, now.
if (current) go(current);
} }
function onClick(e) { function onClick(e) {
@ -257,6 +267,8 @@
style.textContent = [ style.textContent = [
'#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }', '#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }',
'#tabs .nav-narrow { display: none; }', '#tabs .nav-narrow { display: none; }',
'#tabs .nav-foot { margin-left: auto; }',
'#tabs .nav-narrow .nav-icon { margin-left: auto; }',
'#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', '#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
'@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }', '@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }',
'#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }', '#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }',

481
static/js/sources.js Normal file
View file

@ -0,0 +1,481 @@
/*
* Abos & Quellen (docs/sources.md): every subscription in one place — podcasts, YouTube
* channels, audiobooks, news sources, blogs. One field adds any of them (an address or
* search words), one list shows them all, a source's actions open its content, its
* settings (the existing editors, through their hooks), change its kind or unsubscribe.
* Runs in the web page, the desktop app and the Android apps (diora Reader: news and
* blogs only, there are no podcasts). Chrome-61-safe: no ?. / ??.
*/
(function () {
'use strict';
const tab = document.getElementById('tab-sources');
if (!tab) return;
const st = {
filter: 'all',
q: '',
feeds: null, // /feeds/state/ sources
adding: null, // {loading, error, found: [candidate…]}
open: null, // key of the source whose actions are shown
busy: false,
};
try {
const f = localStorage.getItem('diora_sources_filter');
if (f) st.filter = f;
} catch (e) {}
const FILTERS = [
['all', 'Alle'], ['podcast', 'Podcasts'], ['youtube', 'YouTube'], ['audiobook', 'Hörbücher'],
['news', 'Nachrichten'], ['blog', 'Blogs'], ['problem', 'Probleme'],
];
const KIND_LABEL = {podcast: 'Podcast', youtube: 'YouTube', audiobook: 'Hörbuch', news: 'Nachrichten', blog: 'Blog'};
// ---- helpers -------------------------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[c]));
}
function host() {
const n = window.DioraNative;
if (n && typeof n.podcastState === 'function') return n;
return window.DioraPodcastHost || null;
}
function podcastFeeds() {
const h = host();
if (!h) return [];
try { return (JSON.parse(h.podcastState()) || {}).feeds || []; } catch (e) { return []; }
}
async function api(path, body) {
const opts = {credentials: 'same-origin', cache: 'no-store'};
if (body !== undefined) {
opts.method = 'POST';
opts.headers = {'Content-Type': 'application/json'};
opts.body = JSON.stringify(body);
}
const r = await fetch(path, opts);
const d = await r.json().catch(() => null);
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
return d;
}
function toast(text) {
let t = document.getElementById('src-toast');
if (!t) {
t = document.createElement('div');
t.id = 'src-toast';
document.body.appendChild(t);
}
t.textContent = text;
t.classList.add('src-show');
clearTimeout(toast.timer);
toast.timer = setTimeout(() => t.classList.remove('src-show'), 2800);
}
const isYouTubeFeed = url => /^https?:\/\/(www\.)?youtube\.com\/feeds\/videos\.xml/.test(url || '');
const isYouTubeAddress = s => /^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(s.trim());
const looksLikeAddress = s => !/\s/.test(s.trim()) && /\.[a-z]{2,}/i.test(s);
function go(id) {
if (window.DioraNav) window.DioraNav.go(id);
else if (typeof showTab === 'function') showTab(id);
}
// ---- all sources as one list -----------------------------------------------------------
function rows() {
const out = [];
podcastFeeds().forEach(f => {
const type = f.kind === 'audiobook' ? 'audiobook' : isYouTubeFeed(f.rss_url) ? 'youtube' : 'podcast';
out.push({
key: 'p' + f.id, type, id: f.id, title: f.title || f.rss_url, sub: f.author || '',
art: f.artwork_url || '', url: f.rss_url,
meta: (f.episode_count ? f.episode_count + ' Folgen' : '') + (f.new_count ? ' · ' + f.new_count + ' neu' : ''),
problem: (f.error_count || 0) >= 3 ? (f.last_error || 'Abruf schlägt fehl') : '',
});
});
(st.feeds || []).forEach(s => {
out.push({
key: 'f' + s.id, type: s.kind === 'news' ? 'news' : 'blog', id: s.id, title: s.title || s.url,
sub: s.kind === 'news' ? (s.category || 'Allgemein') : (s.folders || []).join(', '),
art: s.icon_url || '', url: s.url,
meta: s.unread ? s.unread + ' ungelesen' : '',
problem: (s.error_count || 0) >= 3 ? (s.last_error || 'Abruf schlägt fehl') : '',
});
});
return out.sort((a, b) => a.title.localeCompare(b.title, 'de', {sensitivity: 'base'}));
}
function shown(list) {
const q = st.q.trim().toLowerCase();
return list.filter(r =>
(st.filter === 'all' || (st.filter === 'problem' ? !!r.problem : r.type === st.filter)) &&
(!q || (r.title + ' ' + r.sub + ' ' + r.url).toLowerCase().indexOf(q) >= 0));
}
// ---- drawing ---------------------------------------------------------------------------
function render() {
const all = rows();
const list = shown(all);
const count = id => id === 'all' ? all.length
: id === 'problem' ? all.filter(r => r.problem).length : all.filter(r => r.type === id).length;
let h = '<div class="src">';
h += '<h2 class="src-h1">Abos &amp; Quellen</h2>';
h += addHtml();
h += '<div class="src-chips">' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0]))
.map(f => '<button type="button" class="src-chip' + (st.filter === f[0] ? ' on' : '') + '" data-filter="' + f[0] + '">' +
esc(f[1]) + ' <span class="src-count">' + count(f[0]) + '</span></button>').join('') + '</div>';
h += '<input type="search" class="src-q" placeholder="In ' + all.length + ' Abos suchen …" value="' + esc(st.q) + '">';
if (st.feeds === null && !all.length) h += '<p class="src-note">Wird geladen …</p>';
else if (!list.length) h += '<p class="src-note">' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '</p>';
h += '<div class="src-list">' + list.map(rowHtml).join('') + '</div>';
h += ioHtml();
h += '</div>';
const q = tab.querySelector('.src-q');
const focused = q && document.activeElement === q;
tab.innerHTML = h;
if (focused) {
const n = tab.querySelector('.src-q');
n.focus();
n.setSelectionRange(n.value.length, n.value.length);
}
}
function addHtml() {
const a = st.adding;
let h = '<form class="src-add" data-form="add"><input class="src-add-q" name="q" autocomplete="off" ' +
'placeholder="Adresse (Seite, Feed, YouTube) oder Name eines Podcasts" value="' + esc(a && a.query || '') + '">' +
'<button class="src-btn src-primary" type="submit"' + (a && a.loading ? ' disabled' : '') + '>' +
(a && a.loading ? '…' : 'Suchen') + '</button></form>';
if (!a) return h;
if (a.error) h += '<p class="src-warn">' + esc(a.error) + '</p>';
if (a.found && a.found.length) {
h += '<div class="src-found">' + a.found.map((c, n) =>
'<div class="src-cand">' + art(c.art, c.title, 'src-art') +
'<div class="src-text"><div class="src-title">' + esc(c.title) + '</div>' +
'<div class="src-meta">' + esc(c.sub || c.url) + '</div>' +
(c.samples && c.samples.length ? '<div class="src-meta">' + c.samples.slice(0, 2).map(esc).join(' · ') + '</div>' : '') +
(c.subscribed ? '<div class="src-meta">✓ schon abonniert</div>' :
'<div class="src-kinds">' + c.kinds.map(k =>
'<button type="button" class="src-btn' + (k === c.kind ? ' src-primary' : '') + '" data-sub="' + n + '" data-kind="' + k + '">' +
(k === c.kind ? '+ ' : '') + esc(KIND_LABEL[k]) + '</button>').join('') + '</div>') +
'</div></div>').join('') + '</div>';
}
return h;
}
function art(url, title, cls) {
const u = String(url || '').replace(/^http:/, 'https:');
return u ? '<img class="' + cls + '" src="' + esc(u) + '" alt="" loading="lazy">'
: '<span class="' + cls + ' src-noart">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
}
function rowHtml(r) {
const open = st.open === r.key;
let h = '<div class="src-row' + (open ? ' src-open' : '') + '"><button type="button" class="src-main" data-row="' + r.key + '">' +
art(r.art, r.title, 'src-art') + '<span class="src-text"><span class="src-title">' + esc(r.title) + '</span>' +
'<span class="src-meta"><span class="src-kind src-kind-' + r.type + '">' + esc(KIND_LABEL[r.type]) + '</span>' +
(r.sub ? ' ' + esc(r.sub) : '') + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' +
(r.problem ? '<span class="src-warn">⚠ ' + esc(r.problem) + '</span>' : '') + '</span></button>';
if (open) h += actionsHtml(r);
return h + '</div>';
}
function actionsHtml(r) {
const a = (act, label, cls) => '<button type="button" class="src-btn' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" data-key="' + r.key + '">' + label + '</button>';
const list = [];
if (r.type === 'audiobook') list.push(a('open', '▶ Hörbuch öffnen'));
else if (r.type === 'news' || r.type === 'blog') list.push(a('open', r.type === 'news' ? 'Nachrichten lesen' : 'Blog lesen'));
else list.push(a('open', 'Folgen ansehen'));
list.push(a('settings', 'Einstellungen …'));
if (r.type === 'podcast' || r.type === 'youtube') list.push(a('to-audiobook', 'Als Hörbuch führen'));
if (r.type === 'audiobook') list.push(a('to-podcast', 'Als Podcast führen'));
if (r.type === 'news') list.push(a('to-blog', 'Als Blog führen'));
if (r.type === 'blog') list.push(a('to-news', 'Als Nachrichten führen'));
list.push(a('remove', 'Abbestellen', 'src-danger'));
return '<div class="src-actions"><div class="src-meta src-url">' + esc(r.url) + '</div>' + list.join('') + '</div>';
}
function ioHtml() {
const h = host();
const web = !window.DioraNative;
return '<details class="src-io"><summary>Import &amp; Export</summary>' +
(h ? '<label class="src-btn src-file">Podcasts aus OPML …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-io="podcasts" hidden></label>' : '') +
(h && web ? '<a class="src-btn" href="/podcasts/feeds/export.opml">Podcasts als OPML</a>' : '') +
'<button type="button" class="src-btn" data-io="feeds-opml">Feeds aus OPML …</button>' +
(web ? '<a class="src-btn" href="/feeds/export.opml">Feeds als OPML</a>' : '') +
'<p class="src-note">Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).</p></details>';
}
// ---- adding --------------------------------------------------------------------------------
async function find(query) {
const q = query.trim();
if (!q) return;
st.adding = {query: q, loading: true};
render();
try {
let found = [];
const podcasts = !!host();
if (isYouTubeAddress(q) && podcasts) {
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
found = (d.results || []).map(r => ({title: r.title, sub: 'YouTube', url: r.rss_url, art: r.artwork_url,
kind: 'podcast', kinds: ['podcast', 'audiobook'], subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
if (!found.length) throw new Error('Dort ist kein YouTube-Kanal zu finden.');
} else if (looksLikeAddress(q)) {
const d = await api('/feeds/discover/', {url: q});
found = (d.feeds || []).map(f => {
const kind = f.suggested_kind === 'podcast' && !podcasts ? 'blog' : f.suggested_kind;
return {title: f.title, url: f.url, samples: f.samples, kind,
kinds: podcasts ? ['podcast', 'audiobook', 'news', 'blog'] : ['news', 'blog'],
subscribed: kind === 'podcast' || kind === 'audiobook' ? f.podcast_subscribed : f.subscribed};
});
} else if (podcasts) {
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
found = (d.results || []).filter(r => r.rss_url).slice(0, 12).map(r => ({title: r.title, sub: r.author,
url: r.rss_url, art: r.artwork_url, kind: 'podcast', kinds: ['podcast', 'audiobook'],
subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
if (!found.length) throw new Error('Kein Podcast dieses Namens gefunden.');
} else {
throw new Error('Bitte die Adresse einer Seite oder eines Feeds eingeben.');
}
st.adding = {query: q, found};
} catch (e) {
st.adding = {query: q, error: navigator.onLine ? (e.message || 'Hat nicht geklappt.') : 'Das braucht eine Verbindung.'};
}
render();
}
async function subscribe(c, kind) {
if (st.busy) return;
st.busy = true;
toast('Wird abonniert …');
try {
if (kind === 'podcast' || kind === 'audiobook') {
const d = await api('/podcasts/feeds/add/', {rss_url: c.url, title: c.title});
if (kind === 'audiobook') await api('/podcasts/feeds/' + d.feed_id + '/settings/', {kind: 'audiobook'});
refreshPodcasts();
toast('„' + (d.title || c.title) + '“ abonniert' + (kind === 'audiobook' ? ' (Hörbuch)' : ''));
} else {
await api('/feeds/sources/add/', {url: c.url, title: c.title, kind});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
toast('„' + c.title + '“ abonniert');
await loadFeeds();
}
c.subscribed = true;
} catch (e) {
toast(navigator.onLine ? 'Das ließ sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
}
st.busy = false;
render();
}
function refreshPodcasts() {
const h = host();
// The server read the new feed already: the host only needs its state again.
if (h && typeof h.podcastRefresh === 'function') h.podcastRefresh(false);
// The host's state comes a moment later.
setTimeout(render, 1500);
setTimeout(render, 4000);
}
// ---- a source's actions -----------------------------------------------------------------------
async function act(name, r) {
const h = host();
if (name === 'open') {
if (r.type === 'audiobook') {
go('audiobooks');
if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(r.id);
} else if (r.type === 'news' || r.type === 'blog') {
go(r.type === 'news' ? 'news' : 'blogs');
} else if (window.__dioraPodcastOpenFeed) {
window.__dioraPodcastOpenFeed(r.id);
} else {
go('podcasts');
}
return;
}
if (name === 'settings') {
if (r.type === 'news' || r.type === 'blog') {
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(r.id);
else go(r.type === 'news' ? 'news' : 'blogs');
} else if (window.__dioraPodcastFeedSettings) {
window.__dioraPodcastFeedSettings(r.id);
} else if (window.__dioraPodcastOpenFeed) {
window.__dioraPodcastOpenFeed(r.id);
} else {
go('podcasts');
}
return;
}
if (name === 'to-audiobook' || name === 'to-podcast') {
if (!h) return;
h.podcastFeedSettings(r.id, JSON.stringify({kind: name === 'to-audiobook' ? 'audiobook' : 'podcast'}));
toast(name === 'to-audiobook' ? '„' + r.title + '“ ist jetzt ein Hörbuch' : '„' + r.title + '“ ist wieder ein Podcast');
st.open = null;
render();
setTimeout(render, 600);
return;
}
if (name === 'to-blog' || name === 'to-news') {
try {
await api('/feeds/sources/' + r.id + '/', {kind: name === 'to-blog' ? 'blog' : 'news'});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
await loadFeeds();
} catch (e) { toast('Das braucht eine Verbindung.'); }
return;
}
if (name === 'remove') {
const ok = typeof customConfirm === 'function'
? await customConfirm('„' + r.title + '“ abbestellen?', {danger: true})
: window.confirm('„' + r.title + '“ abbestellen?');
if (!ok) return;
try {
if (r.type === 'news' || r.type === 'blog') {
await api('/feeds/sources/' + r.id + '/delete/', {});
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
await loadFeeds();
} else {
await api('/podcasts/feeds/' + r.id + '/remove/', {});
if (h && typeof h.podcastFeedRemoved === 'function') h.podcastFeedRemoved();
setTimeout(render, 1500);
}
st.open = null;
toast('„' + r.title + '“ abbestellt');
} catch (e) { toast('Das braucht eine Verbindung.'); }
render();
}
}
async function importPodcasts(file) {
const form = new FormData();
form.append('file', file);
toast('Wird übernommen …');
try {
const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form, credentials: 'same-origin'});
const d = await r.json();
if (!r.ok) throw new Error(d.error || r.status);
toast((d.added || 0) + ' Podcasts übernommen');
refreshPodcasts();
} catch (e) {
toast('Die Datei ließ sich nicht übernehmen.');
}
}
// ---- events ---------------------------------------------------------------------------------
tab.addEventListener('submit', e => {
const form = e.target.closest('[data-form="add"]');
if (!form) return;
e.preventDefault();
find(form.q.value);
});
tab.addEventListener('input', e => {
if (!e.target.classList.contains('src-q')) return;
st.q = e.target.value;
render();
});
tab.addEventListener('change', e => {
const io = e.target.dataset && e.target.dataset.io;
if (io === 'podcasts' && e.target.files && e.target.files[0]) importPodcasts(e.target.files[0]);
});
tab.addEventListener('click', e => {
const t = e.target.closest('button, label');
if (!t) return;
if (t.dataset.filter) {
st.filter = t.dataset.filter;
try { localStorage.setItem('diora_sources_filter', st.filter); } catch (err) {}
render();
} else if (t.dataset.row) {
st.open = st.open === t.dataset.row ? null : t.dataset.row;
render();
} else if (t.dataset.act) {
const r = rows().filter(x => x.key === t.dataset.key)[0];
if (r) act(t.dataset.act, r);
} else if (t.dataset.sub != null) {
const c = st.adding && st.adding.found && st.adding.found[Number(t.dataset.sub)];
if (c) subscribe(c, t.dataset.kind);
} else if (t.dataset.io === 'feeds-opml') {
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(null);
else go('news');
}
});
// ---- loading, showing --------------------------------------------------------------------------
async function loadFeeds() {
try {
const d = await api('/feeds/state/');
st.feeds = d.sources || [];
} catch (e) {
if (st.feeds === null) st.feeds = [];
}
render();
}
function show() {
// Another view may have chosen what to show (feeds.js: its own kind).
try {
const f = localStorage.getItem('diora_sources_filter');
if (f && FILTERS.some(x => x[0] === f)) st.filter = f;
} catch (e) {}
render();
loadFeeds();
}
const showTab = window.showTab;
if (typeof showTab === 'function') {
window.showTab = function (name) {
const r = showTab.apply(this, arguments);
if (name === 'sources') show();
return r;
};
}
if (tab.style.display !== 'none') show();
// ---- styles --------------------------------------------------------------------------------------
const style = document.createElement('style');
style.textContent = [
'#tab-sources .src { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
'#tab-sources .src-h1 { font-size: 18px; margin: 4px 0 12px; }',
'#tab-sources .src-add { display: flex; gap: 8px; }',
'#tab-sources .src-add-q, #tab-sources .src-q { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
'#tab-sources .src-q { width: 100%; margin: 8px 0 4px; }',
'#tab-sources .src-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; margin: 4px 6px 0 0; }',
'#tab-sources .src-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-danger { color: var(--accent, #e63946); }',
'#tab-sources .src-btn[disabled] { opacity: .5; }',
'#tab-sources .src-found { margin: 10px 0 4px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
'#tab-sources .src-cand { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-sources .src-cand:last-child { border-bottom: none; }',
'#tab-sources .src-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 4px; }',
'#tab-sources .src-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
'#tab-sources .src-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-count { opacity: .7; font-size: 12px; }',
'#tab-sources .src-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
'#tab-sources .src-main { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
'#tab-sources .src-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
'#tab-sources .src-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
'#tab-sources .src-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); }',
'#tab-sources .src-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
'#tab-sources .src-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
'#tab-sources .src-meta, #tab-sources .src-note { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }',
'#tab-sources .src-url { white-space: nowrap; margin-bottom: 4px; }',
'#tab-sources .src-warn { color: var(--accent, #e63946); font-size: 12px; }',
'#tab-sources .src-kind { display: inline-block; padding: 0 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); color: var(--fg, #eee); font-size: 11px; }',
'#tab-sources .src-kind-audiobook, #tab-sources .src-kind-youtube { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
'#tab-sources .src-actions { padding: 0 0 12px 50px; }',
'#tab-sources .src-io { margin-top: 22px; }',
'#tab-sources .src-io summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
'#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
'#src-toast.src-show { opacity: 1; }',
'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }',
].join('\n');
document.head.appendChild(style);
})();

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v116'; const CACHE = 'diora-v117';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',
@ -42,6 +42,7 @@ const SHELL = [
'/static/js/feeds.js', '/static/js/feeds.js',
'/static/js/audiobooks.js', '/static/js/audiobooks.js',
'/static/js/nav.js', '/static/js/nav.js',
'/static/js/sources.js',
'/static/podcasts/native-podcast.js', '/static/podcasts/native-podcast.js',
'/static/podcasts/podcast-ui.css', '/static/podcasts/podcast-ui.css',
'/static/podcasts/podcasts-web.css', '/static/podcasts/podcasts-web.css',

View file

@ -1,4 +1,4 @@
// Copied from diora-android 26f5019 2026-10-16: web-native/static/native-podcast.js // Copied from diora-android 50c6f9c 2026-10-16: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js. // and copy again. The browser host is static/js/podcasts-web.js.
@ -105,6 +105,9 @@
return Math.max(n, total - Math.max(0, sent - n)); return Math.max(n, total - Math.max(0, sent - n));
} }
const feeds = () => state.feeds || []; const feeds = () => state.feeds || [];
// "Abos & Quellen" (diora-web docs/sources.md, sources.js): where subscriptions are managed, if the page has it.
const hasSources = () => !!window.DioraNav && DioraNav.sections().some(s => s.id === 'sources');
const toSources = () => DioraNav.go('sources');
// Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here. // Audiobooks (diora-web docs/audiobooks.md) are listened to in the library ("Bücher"), not here.
const isBook = f => !!f && f.kind === 'audiobook'; const isBook = f => !!f && f.kind === 'audiobook';
const podcastFeeds = () => feeds().filter(f => !isBook(f)); const podcastFeeds = () => feeds().filter(f => !isBook(f));
@ -494,7 +497,8 @@
group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [ group('⇅', 'Abos umziehen', 'OPML exportieren und importieren', () => [
{icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml}, {icon: '⇩', label: 'Abos exportieren (OPML)', hint: 'zum Umziehen in eine andere App', action: exportOpml},
{icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml}, {icon: '⇧', label: 'Abos importieren (OPML)', action: importOpml},
]), hasSources() && {icon: '⚙', label: 'Abos & Quellen', hint: 'alle Abos an einem Ort: Podcasts, YouTube, Nachrichten, Blogs', action: toSources},
].filter(Boolean)),
{icon: '▤', label: 'Hörstatistik', action: openStats}, {icon: '▤', label: 'Hörstatistik', action: openStats},
]); ]);
} }
@ -1805,7 +1809,10 @@
} }
const head = el('div', 'nui-pod-head'); const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`), head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
listTools([button('⇅', 'Sortieren und Zahl wählen', feedSortMenu)])); listTools([
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
].filter(Boolean)));
root.append(head); root.append(head);
const list = el('div', 'nui-pod-list'); const list = el('div', 'nui-pod-list');
for (const f of items) list.append(feedRow(f)); for (const f of items) list.append(feedRow(f));
@ -1813,6 +1820,22 @@
if (books) root.append(booksHint()); if (books) root.append(booksHint());
} }
// For Abos & Quellen (sources.js): a podcast's settings sheet, its episode list.
window.__dioraPodcastFeedSettings = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false;
feedSettingsSheet(f);
return true;
};
window.__dioraPodcastOpenFeed = function (feedId) {
const f = feeds().find(x => x.id === Number(feedId));
if (!f) return false;
if (window.DioraNav) DioraNav.go('podcasts');
else if (typeof showTab === 'function') showTab('podcasts');
openFeed(f);
return true;
};
async function openFeed(f) { async function openFeed(f) {
feedView = {feed: f, episodes: null}; feedView = {feed: f, episodes: null};
selected = null; selected = null;
@ -2542,6 +2565,12 @@
search(input.value.trim()); search(input.value.trim());
}); });
root.append(form); root.append(form);
if (hasSources()) {
const more = el('button', 'nui-pod-hint nui-pod-link', 'Alles abonnieren und verwalten – auch YouTube, Nachrichten, Blogs: Abos & Quellen ›');
more.type = 'button';
more.addEventListener('click', toSources);
root.append(more);
}
const s = searchResults; const s = searchResults;
if (!s) { if (!s) {
empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' + empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' +

View file

@ -1,4 +1,4 @@
/* Copied from diora-android 26f5019 2026-10-16: the podcast parts of web-native/static/native.css /* Copied from diora-android 50c6f9c 2026-10-16: the podcast parts of web-native/static/native.css
(bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -196,6 +196,8 @@ html.nui-one-player #nui-mini { display: none !important; }
.nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); } .nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); }
/* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */ /* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */
.nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; } .nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; }
/* A hint that leads somewhere (Abos & Quellen). */
button.nui-pod-link { display: block; width: 100%; border: none; background: none; text-align: left; cursor: pointer; padding: 4px 2px 12px; }
.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-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-list { display: flex; flex-direction: column; }

View file

@ -265,6 +265,8 @@
<!-- ===== FEEDS TAB ===== --> <!-- ===== FEEDS TAB ===== -->
{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %} {% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %}
{% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %} {% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %}
{% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %}
{% if user.is_authenticated %}<section class="tab-panel" id="tab-sources" style="display:none;"></section>{% endif %}
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} {% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
<section class="tab-panel" id="tab-feeds" style="display:none;"></section> <section class="tab-panel" id="tab-feeds" style="display:none;"></section>
@ -387,4 +389,5 @@
<script src="/static/js/home.js"></script> <script src="/static/js/home.js"></script>
<script src="/static/js/feeds.js"></script> <script src="/static/js/feeds.js"></script>
<script src="/static/js/audiobooks.js"></script> <script src="/static/js/audiobooks.js"></script>
<script src="/static/js/sources.js"></script>
{% endblock %} {% endblock %}