diff --git a/docs/sources.md b/docs/sources.md new file mode 100644 index 0000000..c85342a --- /dev/null +++ b/docs/sources.md @@ -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). diff --git a/e2e/navigation.spec.js b/e2e/navigation.spec.js index 817602b..840e44c 100644 --- a/e2e/navigation.spec.js +++ b/e2e/navigation.spec.js @@ -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'); await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']); 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 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.goto('/#news'); 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/); const segs = page.locator('#nav-segments'); 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(); 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'); +}); diff --git a/feeds/tests.py b/feeds/tests.py index 5464e39..2ec8086 100644 --- a/feeds/tests.py +++ b/feeds/tests.py @@ -395,3 +395,29 @@ class FolderAndProgressTest(TestCase): self.assertEqual(r, {'progress': 0.4, 'read': False}) 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']) + + +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 = ('X' + ''.join( + f'Folge {i}g{i}{extra}' for i, extra in enumerate(items)) + + '') + 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 = '' + 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')) diff --git a/feeds/views.py b/feeds/views.py index 055b4b0..3f2efa0 100644 --- a/feeds/views.py +++ b/feeds/views.py @@ -241,20 +241,33 @@ def discover(request): found = [feed] except requests.RequestException: return JsonResponse({'error': 'Die Adresse ist nicht erreichbar.'}, status=502) + from podcasts.models import PodcastFeed out = [] for u, title, parsed in found: entries = parsed.entries[:5] 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({ 'url': u, 'title': title, '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], # 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}) +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): """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).""" diff --git a/static/js/app/20-tabs.js b/static/js/app/20-tabs.js index 6ea6a78..fb1d079 100644 --- a/static/js/app/20-tabs.js +++ b/static/js/app/20-tabs.js @@ -4,7 +4,7 @@ // 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']; // --------------------------------------------------------------------------- diff --git a/static/js/audiobooks.js b/static/js/audiobooks.js index 29a3fca..b6cd249 100644 --- a/static/js/audiobooks.js +++ b/static/js/audiobooks.js @@ -505,6 +505,8 @@ // Home's "Weiterhören" card (home.js) plays the book from where it was left. window.__dioraAudiobookGo = feedId => goOn(feedId); + // Abos & Quellen (sources.js): open a book. + window.__dioraAudiobookOpen = feedId => openBook(Number(feedId)); // ---- following the player ------------------------------------------------------------ diff --git a/static/js/feeds.js b/static/js/feeds.js index 431f322..c86d83a 100644 --- a/static/js/feeds.js +++ b/static/js/feeds.js @@ -824,6 +824,13 @@ return load(); } 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.found = null; window.scrollTo(0, 0); @@ -834,6 +841,7 @@ try { st.view = localStorage.getItem('diora_feeds_view') || 'news'; } catch (err) { st.view = 'news'; } st.editing = null; st.opml = null; + if (st.fromSources && window.DioraNav) { st.fromSources = false; window.DioraNav.go('sources'); return; } return load(); } if (act === 'seen') { @@ -994,6 +1002,24 @@ // ---- showing the tab 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 // tab is shown; its own switch is then hidden. window.__dioraFeedsView = function (view) { @@ -1054,7 +1080,7 @@ const dock = document.createElement('div'); dock.id = 'nui-dock'; dock.innerHTML = ''; diff --git a/static/js/nav.js b/static/js/nav.js index 6918fa1..b0af37b 100644 --- a/static/js/nav.js +++ b/static/js/nav.js @@ -25,6 +25,9 @@ {id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'}, // The focus timer: the web page's own (the apps never had it). {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 = [ {id: 'home', label: 'Home', icon: '⌂'}, @@ -46,19 +49,19 @@ if (s.account && !authed) return false; if (s.webOnly && nativeUi()) 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 return true; } 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() { const list = sections(); 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) { @@ -74,7 +77,7 @@ // A section of `group`: the one used last there, else its first. 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 found = list.filter(s => s.id === last)[0]; return (found || list[0] || {}).id || null; @@ -99,7 +102,7 @@ current = id; const s = byId[id]; save('diora_nav_section', id); - save('diora_nav_last_' + s.group, id); + if (!s.footer) save('diora_nav_last_' + s.group, id); syncHash(); render(); listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} }); @@ -188,15 +191,19 @@ function render() { const cur = current && byId[current]; const gs = groups(); + const foot = sections().filter(s => s.footer); if (tabs && !nativeUi()) { // Wide: every section under its group; narrow: the groups (their sections on top). tabs.innerHTML = '' + + g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + + (foot.length ? '' + foot.map(s => button(s.id, s.label, 'tab-btn', current === s.id)).join('') + '' : '') + + '' + ''; + 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('') + ''; } if (segs) { const g = cur ? gs.filter(x => x.id === cur.group)[0] : null; @@ -224,6 +231,9 @@ tabs.addEventListener('click', onClick); } 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) { @@ -257,6 +267,8 @@ style.textContent = [ '#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }', '#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); }', '@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; }', diff --git a/static/js/sources.js b/static/js/sources.js new file mode 100644 index 0000000..8f049cc --- /dev/null +++ b/static/js/sources.js @@ -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 => + ({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[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 = '
'; + h += '

Abos & Quellen

'; + h += addHtml(); + h += '
' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0])) + .map(f => '').join('') + '
'; + h += ''; + if (st.feeds === null && !all.length) h += '

Wird geladen …

'; + else if (!list.length) h += '

' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '

'; + h += '
' + list.map(rowHtml).join('') + '
'; + h += ioHtml(); + h += '
'; + 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 = '
' + + '
'; + if (!a) return h; + if (a.error) h += '

' + esc(a.error) + '

'; + if (a.found && a.found.length) { + h += '
' + a.found.map((c, n) => + '
' + art(c.art, c.title, 'src-art') + + '
' + esc(c.title) + '
' + + '
' + esc(c.sub || c.url) + '
' + + (c.samples && c.samples.length ? '
' + c.samples.slice(0, 2).map(esc).join(' · ') + '
' : '') + + (c.subscribed ? '
✓ schon abonniert
' : + '
' + c.kinds.map(k => + '').join('') + '
') + + '
').join('') + '
'; + } + return h; + } + + function art(url, title, cls) { + const u = String(url || '').replace(/^http:/, 'https:'); + return u ? '' + : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; + } + + function rowHtml(r) { + const open = st.open === r.key; + let h = '
'; + if (open) h += actionsHtml(r); + return h + '
'; + } + + function actionsHtml(r) { + const a = (act, label, cls) => ''; + 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 '
' + esc(r.url) + '
' + list.join('') + '
'; + } + + function ioHtml() { + const h = host(); + const web = !window.DioraNative; + return '
Import & Export' + + (h ? '' : '') + + (h && web ? 'Podcasts als OPML' : '') + + '' + + (web ? 'Feeds als OPML' : '') + + '

Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).

'; + } + + // ---- 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); +})(); diff --git a/static/js/sw.js b/static/js/sw.js index 98d1f66..3b2f9bc 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-v116'; +const CACHE = 'diora-v117'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', @@ -42,6 +42,7 @@ const SHELL = [ '/static/js/feeds.js', '/static/js/audiobooks.js', '/static/js/nav.js', + '/static/js/sources.js', '/static/podcasts/native-podcast.js', '/static/podcasts/podcast-ui.css', '/static/podcasts/podcasts-web.css', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index 0dda5ff..c082c04 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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 // 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)); } 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. const isBook = f => !!f && f.kind === 'audiobook'; const podcastFeeds = () => feeds().filter(f => !isBook(f)); @@ -494,7 +497,8 @@ 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}, - ]), + 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}, ]); } @@ -1805,7 +1809,10 @@ } const head = el('div', 'nui-pod-head'); 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); const list = el('div', 'nui-pod-list'); for (const f of items) list.append(feedRow(f)); @@ -1813,6 +1820,22 @@ 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) { feedView = {feed: f, episodes: null}; selected = null; @@ -2542,6 +2565,12 @@ search(input.value.trim()); }); 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; if (!s) { empty('Sucht in deinen Abos und ihren Folgen (auch in den Shownotes) und im Podcast-Verzeichnis von Apple. ' + diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index dede26a..52389e4 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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); } /* 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; } +/* 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-list { display: flex; flex-direction: column; } diff --git a/templates/radio/player.html b/templates/radio/player.html index dcfef80..afcba7e 100644 --- a/templates/radio/player.html +++ b/templates/radio/player.html @@ -265,6 +265,8 @@ {% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %} {% if user.is_authenticated %}{% endif %} +{% comment %}Abos & Quellen, every subscription in one place (docs/sources.md): filled by static/js/sources.js.{% endcomment %} +{% if user.is_authenticated %}{% endif %} {% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %} @@ -387,4 +389,5 @@ + {% endblock %}