Podcasts im Browser: Stöbern, Lieblings-Abos, Suche → Folgenansicht (diora-android 1c1ec4e); podcasts-web.js speichert folders, refresh_interval, favorite; sw v140
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 10m8s
Test / e2e (push) Failing after 1m19s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 19:17:33 +02:00
parent 8df8ae2be3
commit 6e5092ab3f
4 changed files with 354 additions and 13 deletions

View file

@ -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(); }

View file

@ -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',

View file

@ -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 <head>, 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) {

View file

@ -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;