From 6e5092ab3ffcbe86f06ed448418716af71fe15b4 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Mon, 19 Oct 2026 19:17:33 +0200 Subject: [PATCH] =?UTF-8?q?Podcasts=20im=20Browser:=20St=C3=B6bern,=20Lieb?= =?UTF-8?q?lings-Abos,=20Suche=20=E2=86=92=20Folgenansicht=20(diora-androi?= =?UTF-8?q?d=201c1ec4e);=20podcasts-web.js=20speichert=20folders,=20refres?= =?UTF-8?q?h=5Finterval,=20favorite;=20sw=20v140?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- static/js/podcasts-web.js | 9 +- static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 343 +++++++++++++++++++++++++++++- static/podcasts/podcast-ui.css | 13 +- 4 files changed, 354 insertions(+), 13 deletions(-) diff --git a/static/js/podcasts-web.js b/static/js/podcasts-web.js index 27ac1b8..efa622a 100644 --- a/static/js/podcasts-web.js +++ b/static/js/podcasts-web.js @@ -76,7 +76,9 @@ const SORT_RULES = ['', 'new', 'old', 'short', 'long', 'feed']; const FEED_SETTINGS = ['playback_speed', 'skip_intro', 'skip_outro', 'volume', 'new_episodes', 'include_words', 'exclude_words', 'min_duration', 'auto_download', 'notify', - 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder']; + 'episode_sort', 'episode_filter', 'skip_silence', 'kind', 'folder', + // Folders (any number), the fetch interval and a favourite subscription (docs/library.md, docs/podcasts.md). + 'folders', 'refresh_interval', 'favorite']; // The account's playback settings (diora-web PodcastSettings) and what each may be. const SKIPS = [5, 10, 15, 20, 30, 45, 60]; const MIN_SPEED = 0.5; @@ -455,7 +457,10 @@ if (FEED_SETTINGS.indexOf(key) < 0) continue; f[key] = body[key] = changes[key]; } - if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue'; + // Stamm (into the Merkliste, or straight into the Wiedergabeliste) queues by itself. + if ('new_episodes' in body) f.auto_queue = body.new_episodes === 'queue' || body.new_episodes === 'play'; + // As the server does: the first folder is also `folder`. + if (Array.isArray(body.folders)) f.folder = body.folders[0] || ''; if (Object.keys(body).length) queueOp('/podcasts/feeds/' + feedId + '/settings/', body); }); if (cur && cur.episode__feed__id === feedId) { applyKind(); report(); } diff --git a/static/js/sw.js b/static/js/sw.js index 55749ef..db480c8 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-v139'; +const CACHE = 'diora-v140'; const SHELL = [ '/static/css/app.css', '/static/js/app/01-core.js', diff --git a/static/podcasts/native-podcast.js b/static/podcasts/native-podcast.js index b21d24c..c3c9b9e 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android ef914bd 2026-10-19: web-native/static/native-podcast.js +// Copied from diora-android 1c1ec4e 2026-10-19: 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. @@ -40,7 +40,7 @@ {id: 'stream', label: 'Strom'}, {id: 'feeds', label: 'Abos'}, ]; - const VIEW_IDS = VIEWS.map(v => v.id).concat('later', 'stream', 'search'); + const VIEW_IDS = VIEWS.map(v => v.id).concat('later', 'stream', 'browse', 'search'); // The app's start page (this device; native.js opens it, the browser host too). const IN_APP = !window.DioraPodcastHost; // A function: the app loads this file in , before #tab-home exists. @@ -116,6 +116,7 @@ const views = () => (v2() ? VIEWS_V2 : VIEWS); // A view of the other model (a start page, a notice, an older choice): its counterpart. function fixView() { + if (!v2() && view === 'browse') view = 'inbox'; if (v2() && (view === 'inbox' || view === 'episodes')) view = 'stream'; if (!v2() && view === 'stream') view = 'inbox'; if (!v2() && view === 'later') view = 'queue'; @@ -123,6 +124,23 @@ // The play list's name. const QNAME = () => (v2() ? 'Wiedergabeliste' : 'Warteschlange'); const inLater = e => later().some(x => x.episode__id === e.id) || !!(e.raw && e.raw.in_later); + // Favourite subscriptions (feed.favorite, docs/podcasts.md): ★ in Abos, the „★ Faves“ chips. + const isFave = feedId => { const f = feeds().find(x => x.id === feedId); return !!(f && f.favorite); }; + let podFaves = false; + try { podFaves = localStorage.getItem('nui_pod_faves') === '1'; } catch (e) {} + function setFaves(on) { + podFaves = !!on; + try { localStorage.setItem('nui_pod_faves', podFaves ? '1' : '0'); } catch (e) {} + render(); + scrollTop(0); + } + function toggleFeedFave(f) { + const on = !f.favorite; + f.favorite = on; + N.podcastFeedSettings(f.id, JSON.stringify({favorite: on})); + toast(on ? '★ Lieblings-Abo' : 'Kein Lieblings-Abo mehr'); + render(); + } // All new episodes: the server sends the first ones (inbox_limit) and how many there are // (inbox_total); what left „Neu“ here since then counts off. function inboxTotal() { @@ -417,6 +435,8 @@ function play(e) { const r = remote(); if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); return; } + // An audiobook's chapter: heard in the library (its place, the rest of the book), never queued. + if (isBook(feeds().find(f => f.id === e.feedId)) && openAudiobook(e.feedId)) return; // Two lists: ▶ puts it first in the Wiedergabeliste (in the Merkliste it stays until heard). if (v2()) { if (inQueue(e.id)) N.podcastMove(e.id, 0); @@ -431,6 +451,15 @@ toast('In der ' + QNAME() + (label ? ': ' + label.label.toLowerCase() : '')); } + function openAudiobook(feedId) { + if (!feedId || typeof window.__dioraAudiobookOpen !== 'function') return false; + closeNotes(); + closePlayer(); + if (window.DioraNav) DioraNav.go('audiobooks'); + window.__dioraAudiobookOpen(feedId); + return true; + } + // +: onto the Merkliste, or off it again. function toggleLater(e) { if (!N.podcastLaterAdd) return; @@ -1102,6 +1131,7 @@ {id: 'queued', get label() { return v2() ? 'Wiedergabeliste' : 'Warteschlange'; }, test: o => inQueue(o.id), get none() { return 'Keine davon steht in der ' + QNAME() + '.'; }}, {id: 'later', label: 'Merkliste', v2: true, test: o => inLater({id: o.id, raw: o}), none: 'Keine davon steht auf der Merkliste.'}, + {id: 'fave_feeds', label: '★ Faves', v2: true, test: o => isFave(o.feed__id), none: 'Keine Folgen deiner Lieblings-Abos.'}, {id: 'downloaded', label: 'Geladen', app: true, test: o => dlDone(o.id), none: 'Keine Folgen auf dem Gerät.'}, {id: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0, none: 'Noch nichts gehört.'}, @@ -1333,12 +1363,13 @@ if (view === 'queue') folderChips(queue().map(ep), e => e.feedId); else if (view === 'inbox') folderChips(inbox().map(ep), e => e.feedId); else if (view === 'later') folderChips(later().map(ep), e => e.feedId); - else if (view === 'feeds') folderChips(podcastFeeds(), f => f.id); + else if (view === 'feeds') folderChips(podcastFeeds(), f => f.id, v2()); else if ((view === 'episodes' || view === 'stream') && !selected) folderChips(podcastFeeds(), f => f.id); if (view === 'queue') renderQueue(); else if (view === 'inbox') renderInbox(); else if (view === 'later') renderLater(); else if (view === 'episodes' || view === 'stream') renderEpisodes(); + else if (view === 'browse') renderBrowse(); else if (view === 'feeds') renderFeeds(); else renderSearch(); } @@ -2035,6 +2066,243 @@ root.append(list); } + // ---- Stöbern (diora-web docs/podcasts.md): within the subscriptions, and a step out --------- + // Search in titles and shownotes, the week's topics, people, forgotten episodes, + // recommendations; a person's episodes elsewhere go onto the Merkliste (their podcast + // becomes „Auf Abruf“). Everything here needs a connection. + + let browse = {mode: 'home'}; + const browseCache = new Map(); // path → {at, data} + + async function browseGet(path, maxAge) { + const hit = browseCache.get(path); + if (hit && Date.now() - hit.at < (maxAge || 10 * 60000)) return hit.data; + const data = await getJson(path); + browseCache.set(path, {at: Date.now(), data}); + return data; + } + + function openBrowse() { + selected = null; + browse = {mode: 'home'}; + view = 'browse'; + render(); + scrollTop(0); + } + + function browseBack() { + if (browse.mode !== 'home') { browse = {mode: 'home', q: browse.q && browse.mode === 'search' ? '' : ''}; render(); scrollTop(0); return true; } + view = 'stream'; + render(); + scrollTop(0); + return true; + } + + // A section that loads by itself: its heading, „Wird geladen …“, then fill(box, data). + function browseSection(title, path, fill, maxAge) { + const sec = el('div', 'nui-pod-browsesec'); + if (title) sec.append(el('div', 'nui-pod-when', title)); + const box = el('div', 'nui-pod-browsebox'); + sec.append(box); + const hit = browseCache.get(path); + if (hit && Date.now() - hit.at < (maxAge || 10 * 60000)) { + fill(box, hit.data); + } else { + box.append(el('div', 'nui-pod-meta', 'Wird geladen …')); + browseGet(path, maxAge).then(d => { box.textContent = ''; fill(box, d); }) + .catch(() => { box.textContent = ''; box.append(el('div', 'nui-pod-meta', navigator.onLine ? 'Ließ sich gerade nicht laden.' : 'Stöbern braucht eine Verbindung.')); }); + } + return sec; + } + + function browseRows(box, rows, limit) { + const list = el('div', 'nui-pod-list'); + const shownRows = limit ? rows.slice(0, limit) : rows; + for (const o of shownRows) list.append(episodeRow(ep(o), {showFeed: true, showDate: true, list: 'stream', noSelect: true})); + box.append(list); + if (!rows.length) box.append(el('div', 'nui-pod-meta', 'Nichts gefunden.')); + return rows.length > shownRows.length; + } + + function moreLink(box, label, onClick) { + const b = el('button', 'nui-pod-bundle', label); + b.type = 'button'; + b.addEventListener('click', onClick); + box.append(b); + } + + function chipRow(items) { + const row = el('div', 'nui-pod-filters nui-pod-browsechips'); + for (const it of items) { + const b = el('button', 'nui-pod-chip', it.label); + b.type = 'button'; + if (it.count) b.append(el('span', 'nui-pod-chipcount', ' ' + it.count)); + b.addEventListener('click', it.onClick); + row.append(b); + } + return row; + } + + function renderBrowse() { + const head = el('div', 'nui-pod-head nui-pod-browsehead'); + const titles = {home: 'Stöbern', search: 'Suche in deinen Podcasts', topic: browse.label, person: browse.name, + forgotten: 'Vergessen', recommend: 'Empfehlungen'}; + head.append(button('‹', browse.mode === 'home' ? 'Zurück zum Strom' : 'Zurück zum Stöbern', browseBack, 'nui-pod-back'), + el('div', 'nui-pod-browsetitle', titles[browse.mode] || 'Stöbern')); + root.append(head); + if (browse.mode === 'home' || browse.mode === 'search') { + const form = el('form', 'nui-pod-search'); + const input = el('input', 'search-input'); + input.type = 'search'; + input.id = 'nui-pod-q'; + input.placeholder = 'In Titeln und Shownotes deiner Podcasts …'; + input.setAttribute('enterkeyhint', 'search'); + input.value = browse.q || ''; + const go = el('button', 'btn', 'Suchen'); + go.type = 'submit'; + form.append(input, go); + form.addEventListener('submit', ev => { + ev.preventDefault(); + const q = input.value.trim(); + browse = q ? {mode: 'search', q} : {mode: 'home'}; + render(); + }); + root.append(form); + } + const body = el('div', 'nui-pod-browse'); + root.append(body); + if (browse.mode === 'search') { + body.append(browseSection('', `/podcasts/discover/search/?q=${encodeURIComponent(browse.q)}&stream=1`, + (box, d) => browseRows(box, d.episodes || []))); + return; + } + if (browse.mode === 'topic') { + body.append(browseSection('', `/podcasts/episodes/?ids=${browse.ids.join(',')}&limit=100`, + (box, d) => browseRows(box, (d.episodes || []).sort((a, b) => (Date.parse(b.pub_date) || 0) - (Date.parse(a.pub_date) || 0))))); + return; + } + if (browse.mode === 'person') { + body.append(browseSection('In deinen Podcasts', `/podcasts/discover/person/?name=${encodeURIComponent(browse.name)}`, + (box, d) => browseRows(box, d.episodes || []))); + body.append(browseSection('Anderswo', `/podcasts/discover/elsewhere/?name=${encodeURIComponent(browse.name)}`, + (box, d) => elsewhereRows(box, d.episodes || []), 60 * 60000)); + return; + } + if (browse.mode === 'forgotten') { + body.append(browseSection('Von den Podcasts, die du am meisten hörst', '/podcasts/discover/forgotten/', + (box, d) => browseRows(box, d.loved || []))); + body.append(browseSection('Von den seltenen', '/podcasts/discover/forgotten/', (box, d) => browseRows(box, d.rare || []))); + return; + } + if (browse.mode === 'recommend') { + body.append(browseSection('', '/podcasts/discover/recommend/', (box, d) => recommendCards(box, d.podcasts || []), 60 * 60000)); + return; + } + // home + body.append(browseSection('Themen der Woche', '/podcasts/discover/topics/?days=7', (box, d) => { + const ts = d.topics || []; + if (!ts.length) { box.append(el('div', 'nui-pod-meta', 'Diese Woche keine gemeinsamen Themen.')); return; } + box.append(chipRow(ts.map(t => ({label: t.label, count: t.ids.length, + onClick: () => { browse = {mode: 'topic', label: t.label, ids: t.ids}; render(); scrollTop(0); }})))); + })); + body.append(browseSection('Personen', '/podcasts/discover/people/', (box, d) => { + const ps = (d.people || []).slice(0, 24); + if (!ps.length) { box.append(el('div', 'nui-pod-meta', 'Noch keine Namen gefunden.')); return; } + box.append(chipRow(ps.map(x => ({label: x.name, count: x.episodes, + onClick: () => { browse = {mode: 'person', name: x.name}; render(); scrollTop(0); }})))); + }, 60 * 60000)); + body.append(browseSection('Vergessen', '/podcasts/discover/forgotten/', (box, d) => { + const rows = (d.loved || []).slice(0, 3).concat((d.rare || []).slice(0, 2)); + browseRows(box, rows); + moreLink(box, 'Alles Vergessene ›', () => { browse = {mode: 'forgotten'}; render(); scrollTop(0); }); + })); + body.append(browseSection('Empfohlen', '/podcasts/discover/recommend/', (box, d) => { + const ps = d.podcasts || []; + recommendCards(box, ps.slice(0, 3)); + if (ps.length > 3) moreLink(box, `Alle ${ps.length} Empfehlungen ›`, () => { browse = {mode: 'recommend'}; render(); scrollTop(0); }); + }, 60 * 60000)); + } + + // Episodes naming a person in podcasts not subscribed: + puts one on the Merkliste and + // subscribes its podcast „Auf Abruf“ (POST /podcasts/discover/take/). + function elsewhereRows(box, items) { + if (!items.length) { box.append(el('div', 'nui-pod-meta', 'Anderswo nichts gefunden.')); return; } + const list = el('div', 'nui-pod-list'); + for (const it of items) { + const row = el('div', 'nui-pod-row'); + row.append(art(it.artwork_url, null, null, it.podcast)); + const main = el('div', 'nui-pod-main'); + main.append(el('div', 'nui-pod-title', plain(it.title))); + const meta = []; + if (it.duration_seconds) meta.push(minutes(it.duration_seconds)); + if (it.pub_date) meta.push(day(it.pub_date)); + if (it.podcast) meta.push(plain(it.podcast)); + main.append(el('div', 'nui-pod-meta', meta.join(' · '))); + row.append(main); + const take = button('+', 'Auf die Merkliste (der Podcast kommt „Auf Abruf“ in die Abos)', async () => { + take.disabled = true; + try { + const r = await fetch('/podcasts/discover/take/', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(it)}); + const d = await r.json(); + if (!r.ok || !d.ok) throw new Error(d.error || r.status); + take.textContent = '✓'; + take.classList.add('on'); + toast(d.subscribed ? `Auf der Merkliste · „${plain(it.podcast)}“ auf Abruf abonniert` : 'Auf der Merkliste'); + refresh(false); + } catch (e) { + take.disabled = false; + toast(navigator.onLine ? 'Ließ sich nicht merken' : 'Braucht eine Verbindung'); + } + }, 'nui-pod-mini'); + row.append(take); + list.append(row); + } + box.append(list); + } + + // Podcasts like the ones heard most (fyyd): subscribe „Auf Abruf“ (or open the preview). + function recommendCards(box, items) { + if (!items.length) { box.append(el('div', 'nui-pod-meta', 'Gerade keine Empfehlungen.')); return; } + const have = new Set(feeds().map(f => f.rss_url)); + for (const p of items) { + const card = el('div', 'nui-pod-row nui-pod-reco'); + card.append(art(p.artwork_url, null, null, p.title)); + const main = el('div', 'nui-pod-main'); + main.append(el('div', 'nui-pod-title', plain(p.title))); + if (p.because && p.because.length) main.append(el('div', 'nui-pod-meta', 'weil du ' + p.because.slice(0, 2).map(plain).join(' und ') + ' hörst')); + if (p.description) main.append(el('div', 'nui-pod-meta nui-pod-reco-desc', plain(p.description).slice(0, 160))); + card.append(main); + if (have.has(p.feed_url)) { + card.append(el('span', 'nui-pod-badge', 'Abo')); + } else { + const sub = button('+', 'Abonnieren – „Auf Abruf“: nicht im Strom, nur auf seiner Seite', async () => { + sub.disabled = true; + if (await subscribeOnDemand(p.feed_url, p.title)) { sub.textContent = '✓'; sub.classList.add('on'); } else sub.disabled = false; + }, 'nui-pod-mini'); + card.append(sub); + } + if (N.podcastPreview) main.addEventListener('click', () => openPreview(p.feed_url)); + box.append(card); + } + } + + async function subscribeOnDemand(url, title) { + try { + const r = await fetch('/podcasts/feeds/add/', {method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({rss_url: url, title})}); + const d = await r.json(); + if (!r.ok || !d.ok) throw new Error(d.error || r.status); + await fetch(`/podcasts/feeds/${d.feed_id}/settings/`, {method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({new_episodes: 'ignore'})}); + toast(`„${plain(d.title || title)}“ auf Abruf abonniert`); + refresh(false); + return true; + } catch (e) { + toast(navigator.onLine ? 'Ließ sich nicht abonnieren' : 'Abonnieren braucht eine Verbindung'); + return false; + } + } + function renderInbox() { const all = inbox().map(ep); if (!all.length) { @@ -2125,7 +2393,7 @@ } // The chips (folders.js) above a list: each folder with how many of the list are in it. - function folderChips(items, feedIdOf) { + function folderChips(items, feedIdOf, faves) { if (!hasFolders()) return; const names = namesOf(podcastFeeds()); if (!names.length || (podFolder && !names.includes(podFolder))) podFolder = null; @@ -2139,11 +2407,24 @@ for (const n of fs) counts.set(n, (counts.get(n) || 0) + 1); } const box = el('div', 'nui-pod-folders'); + const favesOn = faves && podFaves; + const nFaves = faves ? items.filter(x => isFave(feedIdOf(x))).length : 0; DioraFolders.chips(box, { folders: names.map(name => ({name, count: counts.get(name) || 0})), active: podFolder, none: none || (podFolder === '' ? '0' : 0), - onPick: name => setFolder(typeof name === 'string' ? name : null), + lead: faves && nFaves ? [{id: 'all', label: 'Alle', on: podFolder == null && !favesOn}, + {id: 'faves', label: '★ Faves', count: nFaves, on: favesOn}] : null, + onPick: name => { + if (name && typeof name === 'object') { + if (name.lead === 'faves') { setFaves(!podFaves); return; } + podFaves = false; + try { localStorage.setItem('nui_pod_faves', '0'); } catch (e) {} + setFolder(null); + return; + } + setFolder(typeof name === 'string' ? name : null); + }, onManage: manageFolders, }); root.append(box); @@ -2321,6 +2602,12 @@ row.addEventListener('click', () => toggleSelected(f.id)); return row; } + if (v2() && 'favorite' in f && N.podcastFeedSettings) { + const star = button(f.favorite ? '★' : '☆', f.favorite ? 'Lieblings-Abo – antippen nimmt es heraus' : 'Als Lieblings-Abo markieren', + () => toggleFeedFave(f), 'nui-pod-mini nui-pod-starbtn' + (f.favorite ? ' on' : '')); + star.addEventListener('click', ev => ev.stopPropagation()); + row.append(star); + } if (v2()) { if (f.new_episodes === 'queue' || f.new_episodes === 'play') row.append(el('span', 'nui-pod-badge', 'Stamm')); else if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'Abruf')); @@ -2338,7 +2625,8 @@ function renderFeeds() { const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0]; - const items = podcastFeeds().filter(f => inFolder(f.id)).sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); + const items = podcastFeeds().filter(f => inFolder(f.id) && (!podFaves || !v2() || f.favorite)) + .sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); const books = feeds().length - items.length; const booksHint = () => el('div', 'nui-pod-hint', `${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`); @@ -2375,6 +2663,15 @@ feedSettingsSheet(f); return true; }; + // The search (diora-web docs/search.md): a row as the episode lists give it; true when opened. + window.__dioraPodcastOpenEpisode = function (row) { + if (!row || row.id == null) return false; + const e = ep(row); + if (isBook(feeds().find(f => f.id === e.feedId)) && openAudiobook(e.feedId)) return true; + openEpisode(e, {list: 'search'}); + return true; + }; + window.__dioraPodcastOpenFeed = function (feedId) { const f = feeds().find(x => x.id === Number(feedId)); if (!f) return false; @@ -2933,9 +3230,10 @@ const head = el('div', 'nui-pod-head'); head.append(el('div', 'nui-pod-summary', `${nEpisodes(total)} · ${sortLabel(epSort).toLowerCase()}`), listTools([ + view === 'stream' && button('✦', 'Stöbern: suchen, Themen, Personen, Vergessenes, Empfehlungen', openBrowse, 'nui-pod-browsebtn'), button('⇅', 'Sortieren', () => sortMenuFor(epSort, true, id => setEpisodeList(epFilters, id))), button('☐', 'Mehrere auswählen', () => startSelecting()), - ])); + ].filter(Boolean))); root.append(head); } const list = el('div', 'nui-pod-list'); @@ -4077,6 +4375,19 @@ const actions = el('div', 'nui-pod-epactions'); const box = el('div', 'nui-pod-notesbox'); panel.append(top, head, playBtn, actions, box); + // Similar episodes from the other podcasts (docs/podcasts.md, Stöbern). + if (v2() && e.id > 0) { + const sim = el('div', 'nui-pod-similar'); + panel.append(sim); + browseGet(`/podcasts/discover/similar/${e.id}/`, 60 * 60000).then(d => { + const rows = (d.episodes || []).slice(0, 6); + if (!rows.length || !sim.isConnected) return; + sim.append(el('div', 'nui-pod-when', 'Ähnliche Folgen')); + const list = el('div', 'nui-pod-list'); + for (const o of rows) list.append(episodeRow(ep(o), {showFeed: true, showDate: true, list: 'stream', noSelect: true})); + sim.append(list); + }).catch(() => {}); + } document.body.append(notesSheet); epScreen = {e, ctx, actions, playBtn, facts: info.querySelector('.nui-pod-epfacts')}; episodeScreenParts(); @@ -4251,7 +4562,20 @@ panel.append(top, cover); const chapBox = el('div', 'nui-pod-chapbox'); chapBox.hidden = true; - panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox); + const feedLine = el('div', 'nui-pod-player-feed nui-pod-player-link'); + feedLine.setAttribute('role', 'button'); + feedLine.title = 'Zur Seite des Podcasts'; + // Tapping the podcast's (or the audiobook's) name: its page. + feedLine.addEventListener('click', () => { + const id = player.feedId; + if (!id) return; + if (isBook(feeds().find(f => f.id === id)) || player.book) { + if (openAudiobook(id)) return; + } + closePlayer(); + window.__dioraPodcastOpenFeed(id); + }); + panel.append(el('div', 'nui-pod-player-title'), feedLine, chapBox); const range = el('input', 'nui-pod-seek'); range.type = 'range'; range.min = 0; @@ -5323,10 +5647,11 @@ // From the search back to the list it was opened from. if (view === 'search') { try { view = localStorage.getItem('nui_pod_view') || 'queue'; } catch (e) { view = 'queue'; } - if (!VIEWS.some(v => v.id === view)) view = 'queue'; + if (!views().some(v => v.id === view)) view = 'queue'; render(); return true; } + if (view === 'browse') return browseBack(); } if (back && back()) return true; if (onPodcasts) { diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 3bfdcfc..6df2a67 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android ef914bd 2026-10-19: the podcast parts of web-native/static/native.css +/* Copied from diora-android 1c1ec4e 2026-10-19: 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. */ @@ -232,6 +232,8 @@ button.nui-pod-link { display: block; width: 100%; border: none; background: non /* Headings by time in the episode lists (Heute, Gestern, Diese Woche, …). */ .nui-pod-when { padding: 14px 2px 4px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--nui-muted); font-family: var(--nui-sans); } .nui-pod-when:first-child { padding-top: 4px; } +.nui-pod-player-link { cursor: pointer; } +.nui-pod-player-link:active { opacity: 0.6; } /* The two lists (docs/podcasts.md): four tabs that fit a phone, the Merkliste by podcast, the Strom bundled. */ .nui-pod-tabs-v2 .nui-pod-tab { font-size: 13px; padding-left: 4px; padding-right: 4px; } .nui-pod-group { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 2px; background: none; border: none; border-bottom: 1px solid var(--nui-line, rgba(127,127,127,.2)); color: inherit; font: inherit; text-align: left; cursor: pointer; } @@ -248,6 +250,15 @@ button.nui-pod-link { display: block; width: 100%; border: none; background: non .nui-pod-bundle.nui-pod-fresh::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; } .nui-pod-bundle { align-self: flex-start; margin: -2px 0 6px 56px; padding: 4px 0; background: none; border: none; color: var(--nui-muted); font: inherit; font-size: 12px; cursor: pointer; } .nui-pod-bundled { padding-left: 14px; } +/* Stöbern (docs/podcasts.md). */ +.nui-pod-browsehead { align-items: center; gap: 6px; justify-content: flex-start; } +.nui-pod-browsetitle { font-weight: 600; font-size: 15px; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.nui-pod-browsechips { flex-wrap: wrap; overflow: visible; } +.nui-pod-chipcount { opacity: 0.6; font-size: 11px; } +.nui-pod-browsesec { margin-bottom: 6px; } +.nui-pod-reco .nui-pod-main { cursor: pointer; } +.nui-pod-reco-desc { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.nui-pod-similar { margin-top: 12px; } .nui-pod-row { display: flex; align-items: center;