Podcasts im Browser: Wiedergabeliste · Merkliste · Strom · Abos (diora-android ef914bd), sw v137
Some checks failed
Build and push Docker image / build (push) Successful in 16s
Test / test (push) Successful in 9m44s
Test / e2e (push) Failing after 1m24s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-10-19 18:21:03 +02:00
parent 3ef2bdcb61
commit 3c9185689a
3 changed files with 395 additions and 41 deletions

View file

@ -2,7 +2,7 @@
* diora service worker — caches the app shell for offline use. * diora service worker — caches the app shell for offline use.
*/ */
const CACHE = 'diora-v136'; const CACHE = 'diora-v137';
const SHELL = [ const SHELL = [
'/static/css/app.css', '/static/css/app.css',
'/static/js/app/01-core.js', '/static/js/app/01-core.js',

View file

@ -1,4 +1,4 @@
// Copied from diora-android bd98557 2026-10-19: web-native/static/native-podcast.js // Copied from diora-android ef914bd 2026-10-19: web-native/static/native-podcast.js
// by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android
// and copy again. The browser host is static/js/podcasts-web.js. // and copy again. The browser host is static/js/podcasts-web.js.
@ -31,7 +31,16 @@
{id: 'episodes', label: 'Folgen'}, {id: 'episodes', label: 'Folgen'},
{id: 'feeds', label: 'Abos'}, {id: 'feeds', label: 'Abos'},
]; ];
const VIEW_IDS = VIEWS.map(v => v.id).concat('search'); // With the two lists (diora-web docs/podcasts.md, state.lists_v2): the queue is the
// Wiedergabeliste, the Merkliste holds what to hear some day, the Strom replaces „Neu“
// and „Folgen“.
const VIEWS_V2 = [
{id: 'queue', label: 'Wiedergabeliste'},
{id: 'later', label: 'Merkliste'},
{id: 'stream', label: 'Strom'},
{id: 'feeds', label: 'Abos'},
];
const VIEW_IDS = VIEWS.map(v => v.id).concat('later', 'stream', 'search');
// The app's start page (this device; native.js opens it, the browser host too). // The app's start page (this device; native.js opens it, the browser host too).
const IN_APP = !window.DioraPodcastHost; const IN_APP = !window.DioraPodcastHost;
// A function: the app loads this file in <head>, before #tab-home exists. // A function: the app loads this file in <head>, before #tab-home exists.
@ -72,6 +81,13 @@
let state = parse(N.podcastState(), {}); let state = parse(N.podcastState(), {});
let player = {kind: null}; let player = {kind: null};
let view = 'queue'; let view = 'queue';
let streamSeen = false; // podcastStreamSeen() sent for this visit (until the app was away 10 min)
let lastView = '';
let hiddenAt = 0;
document.addEventListener('visibilitychange', () => {
if (document.hidden) { hiddenAt = Date.now(); return; }
if (hiddenAt && Date.now() - hiddenAt > 10 * 60000) { streamSeen = false; lastView = ''; if (view === 'stream') render(); }
});
let feedView = null; // {feed, episodes} while one podcast is open let feedView = null; // {feed, episodes} while one podcast is open
let searchResults = null; let searchResults = null;
let preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing let preview = null; // {id, url, data | choices | error} while a feed is looked at before subscribing
@ -95,6 +111,18 @@
const queue = () => state.queue || []; const queue = () => state.queue || [];
const inbox = () => state.inbox || []; const inbox = () => state.inbox || [];
const later = () => state.later || [];
const v2 = () => !!state.lists_v2;
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 === 'inbox' || view === 'episodes')) view = 'stream';
if (!v2() && view === 'stream') view = 'inbox';
if (!v2() && view === 'later') view = 'queue';
}
// 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);
// All new episodes: the server sends the first ones (inbox_limit) and how many there are // 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. // (inbox_total); what left „Neu“ here since then counts off.
function inboxTotal() { function inboxTotal() {
@ -389,13 +417,48 @@
function play(e) { function play(e) {
const r = remote(); const r = remote();
if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); return; } if (r ? r.kind === 'podcast' && r.episodeId === e.id : currentId() === e.id) { openPlayer(); 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);
else N.podcastEnqueue(JSON.stringify([e.raw]), 'front', currentId());
}
N.podcastPlayEpisode(JSON.stringify(e.raw), currentId()); N.podcastPlayEpisode(JSON.stringify(e.raw), currentId());
} }
function enqueue(e, where) { function enqueue(e, where) {
N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId()); N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId());
const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position)); const label = POSITIONS.find(p => p.value === (where || settings().enqueue_position));
toast('In der Warteschlange' + (label ? ': ' + label.label.toLowerCase() : '')); toast('In der ' + QNAME() + (label ? ': ' + label.label.toLowerCase() : ''));
}
// +: onto the Merkliste, or off it again.
function toggleLater(e) {
if (!N.podcastLaterAdd) return;
if (inLater(e)) {
N.podcastLaterRemove(JSON.stringify([e.id]));
if (e.raw) e.raw.in_later = false;
toast('Aus der Merkliste genommen');
} else {
N.podcastLaterAdd(JSON.stringify([e.raw]));
if (e.raw) e.raw.in_later = true;
toast('Auf der Merkliste');
}
for (const l of [epList && epList.items, feedView && feedView.episodes]) {
for (const o of l || []) if (o.id === e.id) o.in_later = e.raw.in_later;
}
render();
}
function laterButton(e) {
const on = inLater(e);
return button(on ? '✓' : '+', on ? 'Auf der Merkliste – antippen nimmt es heraus' : 'Auf die Merkliste', () => toggleLater(e),
'nui-pod-mini' + (on ? ' on' : ''));
}
function starButton(e) {
const on = !!(e.raw && e.raw.favorite);
return button(on ? '★' : '☆', on ? 'Favorit' : 'Als Favorit markieren', () => toggleFavorite(e, star => { e.raw.favorite = star; }),
'nui-pod-mini nui-pod-starbtn' + (on ? ' on' : ''));
} }
// Move within the queue: "next" is right after the episode that plays. // Move within the queue: "next" is right after the episode that plays.
@ -420,13 +483,15 @@
// „Folge löschen“: out of the queue and „Neu“, its download goes here and on every other // „Folge löschen“: out of the queue and „Neu“, its download goes here and on every other
// device (the server tells them), but not played: it keeps its place and stays in „Folgen“. // device (the server tells them), but not played: it keeps its place and stays in „Folgen“.
const DISCARD_HINT = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört'; const DISCARD_HINT_OLD = 'aus Warteschlange und Neu, Datei auf allen Geräten löschen – nicht als gehört';
const DISCARD_HINT_V2 = 'aus Wiedergabeliste und Merkliste, Datei auf allen Geräten löschen – nicht als gehört';
const discardHint = () => (v2() ? DISCARD_HINT_V2 : DISCARD_HINT_OLD);
const discardItem = (e, opts) => N.podcastDiscard && const discardItem = (e, opts) => N.podcastDiscard &&
{icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: DISCARD_HINT, action: () => discardOne(e, (opts && opts.list) || '', (opts && opts.filters) || [])}; {icon: '⊠', svg: 'trash', label: 'Folge löschen', hint: discardHint(), action: () => discardOne(e, (opts && opts.list) || '', (opts && opts.filters) || [])};
// One episode: no question, 4 s „Rückgängig“ like a swipe; sent only then. False when its row leaves the list. // One episode: no question, 4 s „Rückgängig“ like a swipe; sent only then. False when its row leaves the list.
function discardOne(e, list, filters) { function discardOne(e, list, filters) {
const hide = list === 'queue' || list === 'inbox' || list === 'downloads' || const hide = list === 'queue' || list === 'inbox' || list === 'later' || list === 'downloads' ||
filters.includes('downloaded') || filters.includes('queued'); filters.includes('downloaded') || filters.includes('queued');
offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => { offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => {
discardNow([e.id]); discardNow([e.id]);
@ -445,7 +510,7 @@
// Several: asked first. // Several: asked first.
function discardMany(ids) { function discardMany(ids) {
openSheet(`${nEpisodes(ids.length)} löschen? Nicht als gehört markiert.`, [ openSheet(`${nEpisodes(ids.length)} löschen? Nicht als gehört markiert.`, [
{icon: '⊠', svg: 'trash', label: 'Löschen', hint: DISCARD_HINT, danger: true, action: () => { {icon: '⊠', svg: 'trash', label: 'Löschen', hint: discardHint(), danger: true, action: () => {
discardNow(ids); discardNow(ids);
toast(`${nEpisodes(ids.length)} gelöscht`); toast(`${nEpisodes(ids.length)} gelöscht`);
stopSelecting(); stopSelecting();
@ -465,9 +530,10 @@
!playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')}, !playing && {icon: '⤴', label: 'Als Nächstes', hint: 'direkt nach der laufenden Folge', action: () => moveTo(e, 'next')},
{icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')}, {icon: '⇈', label: 'Ganz nach oben', action: () => moveTo(e, 'top')},
{icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')}, {icon: '⇊', label: 'Ganz nach unten', action: () => moveTo(e, 'bottom')},
{icon: '✓', label: 'Als gehört markieren', hint: 'verlässt die Warteschlange', action: () => markPlayed(e)}, {icon: '✓', label: 'Als gehört markieren', hint: v2() ? 'verlässt beide Listen' : 'verlässt die Warteschlange', action: () => markPlayed(e)},
...dlMenuItems(e), ...dlMenuItems(e),
{icon: '✕', svg: 'listx', label: 'Aus der Warteschlange nehmen', hint: dlDone(e.id) ? 'Datei bleibt' : null, v2() && N.podcastLaterAdd && !inLater(e) && {icon: '+', label: 'Auf die Merkliste', hint: 'für später', action: () => toggleLater(e)},
{icon: '✕', svg: 'listx', label: 'Aus der ' + QNAME() + ' nehmen', hint: dlDone(e.id) && !v2() ? 'Datei bleibt' : null,
action: () => N.podcastDequeue(e.id)}, action: () => N.podcastDequeue(e.id)},
discardItem(e, opts), discardItem(e, opts),
selectItem(e, opts), selectItem(e, opts),
@ -475,6 +541,7 @@
} }
function episodeMenu(e, opts) { function episodeMenu(e, opts) {
if (v2()) { listsMenu(e, opts); return; }
const queued = inQueue(e.id); const queued = inQueue(e.id);
openSheet(e.title, [ openSheet(e.title, [
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)}, {icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt dafür in die Warteschlange', action: () => play(e)},
@ -495,6 +562,27 @@
]); ]);
} }
// An episode's menu with the two lists (Merkliste, Strom, a podcast's page).
function listsMenu(e, opts) {
const queued = inQueue(e.id);
const kept = inLater(e);
openSheet(e.title, [
{icon: '▶', label: 'Abspielen', hint: queued ? null : 'kommt an den Anfang der Wiedergabeliste', action: () => play(e)},
{icon: '☰', label: 'Folge ansehen', hint: 'Shownotes, Kapitel, alles zu dieser Folge', action: () => openEpisode(e, opts)},
favoriteItem(e),
N.podcastLaterAdd && {icon: kept ? '✕' : '+', svg: kept ? 'listx' : null, label: kept ? 'Aus der Merkliste nehmen' : 'Auf die Merkliste',
hint: kept ? null : 'für später; wird geladen', action: () => toggleLater(e)},
!queued && {icon: '⤴', label: 'Als Nächstes hören', hint: 'in die Wiedergabeliste nach der laufenden Folge', action: () => enqueue(e, 'after_current')},
!queued && {icon: '⇊', label: 'Ans Ende der Wiedergabeliste', action: () => enqueue(e, 'back')},
queued && {icon: '✕', svg: 'listx', label: 'Aus der Wiedergabeliste nehmen', action: () => N.podcastDequeue(e.id)},
!e.played && {icon: '✓', label: 'Als gehört markieren', hint: kept || queued ? 'verlässt die Listen' : null, action: () => markPlayed(e)},
e.played && {icon: '↺', label: 'Als ungehört markieren', action: () => N.podcastMarkPlayed(e.id, false)},
...dlMenuItems(e),
discardItem(e, opts),
selectItem(e, opts),
]);
}
function settingsMenu() { function settingsMenu() {
const s = settings(); const s = settings();
const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({ const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({
@ -510,7 +598,10 @@
openSheet('Podcasts', [ openSheet('Podcasts', [
{icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, hier kommt nur das Ergebnis an', {icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, hier kommt nur das Ergebnis an',
action: () => refresh(true)}, action: () => refresh(true)},
group('≡', 'Warteschlange', 'wohin neue Folgen kommen, gehörte entfernen', () => [ 'lists_v2' in state && N.podcastListsSwitch && {icon: '☷', label: 'Wiedergabeliste und Merkliste',
hint: v2() ? 'an: kurze Wiedergabeliste, lange Merkliste, der Strom statt „Neu“' : 'aus: eine Warteschlange, „Neu“ und „Folgen“',
action: () => listsSwitch(!v2())},
group('≡', QNAME(), 'wohin neue Folgen kommen, gehörte entfernen', () => [
{icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position), {icon: '+', label: 'Neue Folgen von Hand einreihen', hint: label(s.enqueue_position),
action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')}, action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')},
{icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position),
@ -561,6 +652,26 @@
{value: 240, label: 'Alle 4 Stunden'}, {value: 240, label: 'Alle 4 Stunden'},
]; ];
function listsSwitch(on) {
N.podcastListsSwitch(!!on);
try { localStorage.setItem('nui_pod_lists', '1'); } catch (e) {}
state.lists_v2 = !!on; // until the state comes anew
view = on ? 'later' : 'queue';
toast(on ? 'Die Warteschlange ist jetzt deine Merkliste; die Wiedergabeliste beginnt leer'
: 'Die Merkliste steht jetzt am Ende der Warteschlange');
render();
}
// The two lists come on by themselves once per device, when the server has them (the
// plan of 2026-10-19, docs/podcasts.md); the settings switch turns them off again.
function listsOnOnce() {
if (!('lists_v2' in state) || state.lists_v2 || !N.podcastListsSwitch) return;
let asked = false;
try { asked = localStorage.getItem('nui_pod_lists') === '1'; } catch (e) {}
if (asked) return;
listsSwitch(true);
}
function backgroundSyncItem() { function backgroundSyncItem() {
if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null; if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null;
const now = N.podcastBackgroundSync(); const now = N.podcastBackgroundSync();
@ -988,7 +1099,9 @@
none: 'Keine angefangenen Folgen.'}, none: 'Keine angefangenen Folgen.'},
{id: 'favorites', label: 'Favoriten', test: o => !!o.favorite, {id: 'favorites', label: 'Favoriten', test: o => !!o.favorite,
none: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.'}, none: 'Noch keine Favoriten. Im ⋯-Menü einer Folge markierst du sie mit ★.'},
{id: 'queued', label: 'Warteschlange', test: o => inQueue(o.id), none: 'Keine davon steht in der Warteschlange.'}, {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: 'downloaded', label: 'Geladen', app: true, test: o => dlDone(o.id), none: 'Keine Folgen auf dem Gerät.'}, {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, {id: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0,
none: 'Noch nichts gehört.'}, none: 'Noch nichts gehört.'},
@ -1099,7 +1212,7 @@
}; };
chip('Alle', !active.length, () => onChange([])); chip('Alle', !active.length, () => onChange([]));
for (const f of FILTERS) { for (const f of FILTERS) {
if ((f.app && !DL) || (f.all && !allEpisodes)) continue; if ((f.app && !DL) || (f.all && !allEpisodes) || (f.v2 && !v2())) continue;
const on = active.includes(f.id); const on = active.includes(f.id);
chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id) chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id)
: active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id))); : active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id)));
@ -1190,8 +1303,16 @@
} else if (feedView) { } else if (feedView) {
renderFeed(); renderFeed();
} else { } else {
const tabs = el('div', 'nui-pod-tabs'); fixView();
for (const v of VIEWS) { if (view === 'stream' && lastView !== 'stream') epList = null; // fetched anew: what came since shows
if (view === 'stream' && !streamSeen) {
// The Strom looked at: what came since the last visit keeps its mark for this one.
streamSeen = true;
if (N.podcastStreamSeen) N.podcastStreamSeen();
}
lastView = view;
const tabs = el('div', 'nui-pod-tabs' + (v2() ? ' nui-pod-tabs-v2' : ''));
for (const v of views()) {
const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : '')); const b = el('button', 'nui-pod-tab' + (v.id === view ? ' active' : ''));
b.type = 'button'; b.type = 'button';
b.append(el('span', null, v.label)); b.append(el('span', null, v.label));
@ -1211,11 +1332,13 @@
if (offer) root.append(offer); if (offer) root.append(offer);
if (view === 'queue') folderChips(queue().map(ep), e => e.feedId); if (view === 'queue') folderChips(queue().map(ep), e => e.feedId);
else if (view === 'inbox') folderChips(inbox().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);
else if (view === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id); else if ((view === 'episodes' || view === 'stream') && !selected) folderChips(podcastFeeds(), f => f.id);
if (view === 'queue') renderQueue(); if (view === 'queue') renderQueue();
else if (view === 'inbox') renderInbox(); else if (view === 'inbox') renderInbox();
else if (view === 'episodes') renderEpisodes(); else if (view === 'later') renderLater();
else if (view === 'episodes' || view === 'stream') renderEpisodes();
else if (view === 'feeds') renderFeeds(); else if (view === 'feeds') renderFeeds();
else renderSearch(); else renderSearch();
} }
@ -1318,7 +1441,15 @@
} }
const how = {select: !opts.noSelect, list, filters: opts.filters || []}; const how = {select: !opts.noSelect, list, filters: opts.filters || []};
const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how)); const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how));
if (list === 'stream') {
// The Strom: ★, + (Merkliste), ▶ (first in the Wiedergabeliste); the rest by a long press.
if (opts.fresh) row.classList.add('nui-pod-fresh');
row.append(...[starButton(e), N.podcastLaterAdd && laterButton(e), playButton(e)].filter(Boolean));
row.querySelectorAll(':scope > .nui-pod-mini').forEach(b => b.addEventListener('click', ev => ev.stopPropagation()));
} else {
if (v2() && list === 'feed' && N.podcastLaterAdd) row.append(laterButton(e));
row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu')); row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu'));
}
row.addEventListener('click', () => openEpisode(e, how)); row.addEventListener('click', () => openEpisode(e, how));
// Swipes: touch only, not while the queue is locked. // Swipes: touch only, not while the queue is locked.
const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : ''; const swipes = list && SWIPE_LISTS[list] && !(list === 'queue' && locked) ? list : '';
@ -1329,20 +1460,24 @@
// The one action at hand: the queue and the downloads play, „Neu“ queues, // The one action at hand: the queue and the downloads play, „Neu“ queues,
// the episode lists download (or queue, where nothing downloads). // the episode lists download (or queue, where nothing downloads).
function dlPrimary(e, list) { function dlPrimary(e, list) {
// Two lists: downloads follow the lists; a podcast's page plays (and + keeps).
if (v2() && list === 'feed') return false;
return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url); return (list === 'episodes' || list === 'feed') && DL && !!(e.raw.audio_url || e.raw.episode__audio_url);
} }
function primaryButton(e, list) { function playButton(e) {
if (list === 'queue' || list === 'downloads') {
const now = currentId() === e.id; const now = currentId() === e.id;
const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen', const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen',
() => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn'); () => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn');
b.dataset.play = e.id; b.dataset.play = e.id;
return b; return b;
} }
function primaryButton(e, list) {
if (list === 'queue' || list === 'downloads' || list === 'later' || (v2() && list === 'feed')) return playButton(e);
if (dlPrimary(e, list)) return dlButton(e); if (dlPrimary(e, list)) return dlButton(e);
const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue); const queued = inQueue(e.id) || (list === 'inbox' && e.inQueue);
return queued ? button('✓', 'In der Warteschlange', () => toast('Schon in der Warteschlange'), 'nui-pod-queued') return queued ? button('✓', 'In der ' + QNAME(), () => toast('Schon in der ' + QNAME()), 'nui-pod-queued')
: button('+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; render(); }); : button('+', 'Einreihen', () => { enqueue(e); e.raw.in_queue = true; render(); });
} }
@ -1523,6 +1658,9 @@
episodes: {label: 'Folgen', left: 'download', right: 'favorite'}, episodes: {label: 'Folgen', left: 'download', right: 'favorite'},
feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'}, feed: {label: 'Podcast-Seite', hint: 'die Folgen eines Abos', left: 'delete', right: 'download'},
downloads: {label: 'Downloads', hint: 'Folgen mit dem Filter „Geladen“', left: 'delete', right: 'delete'}, downloads: {label: 'Downloads', hint: 'Folgen mit dem Filter „Geladen“', left: 'delete', right: 'delete'},
// The two lists (docs/podcasts.md).
later: {label: 'Merkliste', v2: true, left: 'unlater', right: 'queue'},
stream: {label: 'Strom', v2: true, left: 'favorite', right: 'later'},
}; };
const SWIPE_ACTIONS = [ const SWIPE_ACTIONS = [
{id: 'none', label: 'Nichts', icon: ''}, {id: 'none', label: 'Nichts', icon: ''},
@ -1535,6 +1673,8 @@
{id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'}, {id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'},
{id: 'favorite', label: 'Favorit an / aus', icon: '★'}, {id: 'favorite', label: 'Favorit an / aus', icon: '★'},
{id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘', svg: 'listx'}, {id: 'dismiss', label: 'Aus „Neu“ entfernen', icon: '⊘', svg: 'listx'},
{id: 'later', label: 'Auf die Merkliste', icon: '+', host: 'podcastLaterAdd'},
{id: 'unlater', label: 'Aus der Merkliste', icon: '✕', svg: 'listx', host: 'podcastLaterRemove'},
{id: 'discard', label: 'Folge löschen', hint: 'aus Warteschlange und Neu, Datei auf allen Geräten weg – nicht gehört', {id: 'discard', label: 'Folge löschen', hint: 'aus Warteschlange und Neu, Datei auf allen Geräten weg – nicht gehört',
icon: '⊠', svg: 'trash', host: 'podcastDiscard'}, icon: '⊠', svg: 'trash', host: 'podcastDiscard'},
]; ];
@ -1576,6 +1716,8 @@
case 'delete': return {title: 'Datei löschen', hint: id && deleteDequeues(id) ? 'auch aus Warteschlange' : 'nur auf diesem Gerät'}; case 'delete': return {title: 'Datei löschen', hint: id && deleteDequeues(id) ? 'auch aus Warteschlange' : 'nur auf diesem Gerät'};
case 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null}; case 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null};
case 'dismiss': return {title: 'Aus Neu entfernen', hint: 'nicht gehört'}; case 'dismiss': return {title: 'Aus Neu entfernen', hint: 'nicht gehört'};
case 'later': return {title: 'Merkliste'};
case 'unlater': return {title: 'Aus der Merkliste', hint: 'nicht gehört'};
case 'queue': return {title: 'Einreihen'}; case 'queue': return {title: 'Einreihen'};
case 'download': return {title: 'Laden'}; case 'download': return {title: 'Laden'};
case 'favorite': return {title: 'Favorit'}; case 'favorite': return {title: 'Favorit'};
@ -1688,8 +1830,27 @@
function swipeDone(e, list, act, filters) { function swipeDone(e, list, act, filters) {
const o = e.raw || {}; const o = e.raw || {};
const has = d => DL && downloads[e.id] && downloads[e.id].state === d; const has = d => DL && downloads[e.id] && downloads[e.id].state === d;
if (act === 'later') {
if (inLater(e)) { toast('Schon auf der Merkliste'); return true; }
offerUndo(e, 'Auf der Merkliste', {run: () => {
N.podcastLaterAdd(JSON.stringify([o]));
o.in_later = true;
render();
}});
return true;
}
if (act === 'unlater') {
if (!inLater(e)) { toast('Nicht auf der Merkliste'); return true; }
const hide = list === 'later';
offerUndo(e, 'Aus der Merkliste', {hide, svg: 'listx', run: () => {
N.podcastLaterRemove(JSON.stringify([e.id]));
o.in_later = false;
if (!hide) render();
}});
return !hide;
}
if (act === 'queue') { if (act === 'queue') {
if (inQueue(e.id)) { toast('Schon in der Warteschlange'); return true; } if (inQueue(e.id)) { toast('Schon in der ' + QNAME()); return true; }
// Queued episodes leave „Neu“ for good (AntennaPod's rule): sent only after the 4 s. // Queued episodes leave „Neu“ for good (AntennaPod's rule): sent only after the 4 s.
const hide = list === 'inbox'; const hide = list === 'inbox';
offerUndo(e, 'Eingereiht', {hide, run: () => { offerUndo(e, 'Eingereiht', {hide, run: () => {
@ -1729,7 +1890,7 @@
if (act === 'played') { if (act === 'played') {
const played = !(o.played || e.played); const played = !(o.played || e.played);
const hide = played const hide = played
? list === 'inbox' || (list === 'queue' && devicePrefs().remove_played !== false) || ? list === 'inbox' || list === 'later' || (list === 'queue' && devicePrefs().remove_played !== false) ||
filters.includes('unplayed') || filters.includes('in_progress') filters.includes('unplayed') || filters.includes('in_progress')
: filters.includes('played'); : filters.includes('played');
offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => { offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => {
@ -1761,18 +1922,19 @@
function renderQueue() { function renderQueue() {
const all = queue().map(ep); const all = queue().map(ep);
if (!all.length) { if (!all.length) {
empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.'); empty(v2() ? 'Die Wiedergabeliste ist leer. ▶ bei einer Folge – in der Merkliste, im Strom oder bei einem Abo – spielt sie und stellt sie hierher; ist die Liste durch, hört der Player auf.'
: 'Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
return; return;
} }
const items = all.filter(e => inFolder(e.feedId)); const items = all.filter(e => inFolder(e.feedId));
if (!items.length) { empty(folderEmpty('in der Warteschlange')); return; } if (!items.length) { empty(folderEmpty('in der ' + QNAME())); return; }
const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0); const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0);
if (selected) { if (selected) {
root.append(selectionBar(items, [ root.append(selectionBar(items, [
{label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))}, {label: 'Nach oben', run: ids => N.podcastReorder(JSON.stringify(ids.concat(queue().map(q => q.episode__id).filter(id => !ids.includes(id)))))},
{label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))}, {label: 'Nach unten', run: ids => N.podcastReorder(JSON.stringify(queue().map(q => q.episode__id).filter(id => !ids.includes(id)).concat(ids)))},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }}, {label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }}, {label: v2() ? 'Aus der Liste' : 'Aus Warteschlange', svg: 'listx', run: ids => { N.podcastDequeueAll(JSON.stringify(ids)); stopSelecting(); }},
discardAction(), discardAction(),
])); ]));
} else { } else {
@ -1805,6 +1967,74 @@
root.append(list); root.append(list);
} }
// The Merkliste: by podcast, each one folds (this device remembers which are open); in a
// podcast its newest episode first. Heard or „Folge löschen“ takes an episode off.
let laterOpen = new Set();
try { laterOpen = new Set(JSON.parse(localStorage.getItem('nui_pod_later_open') || '[]')); } catch (e) {}
function toggleLaterOpen(feedId) {
if (laterOpen.has(feedId)) laterOpen.delete(feedId); else laterOpen.add(feedId);
try { localStorage.setItem('nui_pod_later_open', JSON.stringify([...laterOpen])); } catch (e) {}
render();
}
function renderLater() {
const all = later().map(ep);
if (!all.length) {
empty('Die Merkliste ist leer. Mit + im Strom oder bei einem Abo merkst du dir Folgen; neue Folgen deiner Stamm-Podcasts kommen von selbst hierher (und werden geladen).');
return;
}
const items = all.filter(e => inFolder(e.feedId));
if (!items.length) { empty(folderEmpty('auf der Merkliste')); return; }
if (selected) {
root.append(selectionBar(items, [
{label: 'Abspielen', run: ids => {
N.podcastEnqueue(JSON.stringify(items.filter(e => ids.includes(e.id)).map(e => e.raw)), 'front', currentId());
toast(`${nEpisodes(ids.length)} vorn in der Wiedergabeliste`);
stopSelecting();
}},
{label: 'In die Liste', run: ids => {
N.podcastEnqueue(JSON.stringify(items.filter(e => ids.includes(e.id)).map(e => e.raw)), 'back', currentId());
toast(`${nEpisodes(ids.length)} am Ende der Wiedergabeliste`);
stopSelecting();
}},
{label: 'Gehört', run: ids => { N.podcastMarkPlayedAll(JSON.stringify(ids), true); stopSelecting(); }},
{label: 'Herausnehmen', svg: 'listx', run: ids => { N.podcastLaterRemove(JSON.stringify(ids)); stopSelecting(); }},
DL && {label: 'Laden', run: ids => downloadMany(items.filter(e => ids.includes(e.id)))},
discardAction(),
]));
} else {
const total = items.reduce((n, e) => n + shown(Math.max(0, (e.duration || 0) - (e.pos || 0)), e.feedId), 0);
const head = el('div', 'nui-pod-head');
head.append(el('div', 'nui-pod-summary', `${nEpisodes(items.length)} · ${minutes(total)}` +
(podFolder != null && items.length < all.length ? ` · von ${all.length}` : '')),
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
root.append(head);
}
// By podcast, in the order of their newest episode (the list comes newest first).
const groups = new Map();
for (const e of items) {
if (!groups.has(e.feedId)) groups.set(e.feedId, []);
groups.get(e.feedId).push(e);
}
const list = el('div', 'nui-pod-list nui-pod-later');
for (const [feedId, eps] of groups) {
const open = !!selected || laterOpen.has(feedId) || groups.size === 1;
const head = el('button', 'nui-pod-group' + (open ? ' open' : ''));
head.type = 'button';
head.setAttribute('aria-expanded', open ? 'true' : 'false');
head.append(art(eps[0].feedArt, null, null, eps[0].feed));
const text = el('span', 'nui-pod-group-text');
text.append(el('span', 'nui-pod-group-title', eps[0].feed || ''),
el('span', 'nui-pod-meta', open ? nEpisodes(eps.length) : `${nEpisodes(eps.length)} · ${eps[0].title}`));
head.append(text, el('span', 'nui-pod-group-count', String(eps.length)), el('span', 'nui-pod-chev', open ? '▾' : '›'));
head.addEventListener('click', () => { if (!selected) toggleLaterOpen(feedId); });
list.append(head);
if (open) for (const e of eps) list.append(episodeRow(e, {showDate: true, list: 'later'}));
}
root.append(list);
}
function renderInbox() { function renderInbox() {
const all = inbox().map(ep); const all = inbox().map(ep);
if (!all.length) { if (!all.length) {
@ -2091,7 +2321,10 @@
row.addEventListener('click', () => toggleSelected(f.id)); row.addEventListener('click', () => toggleSelected(f.id));
return row; return row;
} }
if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still')); 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'));
} else if (f.new_episodes === 'ignore') row.append(el('span', 'nui-pod-badge', 'still'));
else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto')); else if (f.auto_queue || f.new_episodes === 'queue') row.append(el('span', 'nui-pod-badge', 'auto'));
const n = counter().value(f); const n = counter().value(f);
if (n) { if (n) {
@ -2220,6 +2453,20 @@
} }
const NEW_WHERE = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'}; const NEW_WHERE = {inbox: 'in „Neu“', queue: 'in die Warteschlange', ignore: 'nirgends'};
// With the two lists: the three levels of a subscription (docs/podcasts.md).
const LEVELS = [
{value: 'queue', label: 'Stamm', hint: 'neue Folgen auf die Merkliste, geladen'},
{value: 'play', label: 'Stamm, gleich in die Wiedergabeliste', hint: 'neue Folgen spielen als Nächstes, geladen'},
{value: 'inbox', label: 'Strom', hint: 'neue Folgen im Strom – nichts abzuarbeiten, nichts geladen'},
{value: 'ignore', label: 'Auf Abruf', hint: 'nicht im Strom; auf der Seite des Podcasts und in der Suche'},
];
const levelOf = f => LEVELS.find(l => l.value === (f.new_episodes || 'inbox')) || LEVELS[2];
const REFRESH = [
{value: '', label: 'Automatisch', hint: 'nach seinem Rhythmus: wer täglich kommt, stündlich; seltene täglich'},
{value: 'hourly', label: 'Stündlich'},
{value: 'daily', label: 'Täglich'},
{value: 'weekly', label: 'Wöchentlich'},
];
function filterWords(live) { function filterWords(live) {
return [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`, return [live.include_words && `nur „${live.include_words}“`, live.exclude_words && `ohne „${live.exclude_words}“`,
@ -2237,9 +2484,15 @@
if ('skip_silence' in live && live.skip_silence != null) parts.push(live.skip_silence ? 'Stille überspringen' : 'Stille nie überspringen'); if ('skip_silence' in live && live.skip_silence != null) parts.push(live.skip_silence ? 'Stille überspringen' : 'Stille nie überspringen');
const words = filterWords(live); const words = filterWords(live);
if (words) parts.push(words); if (words) parts.push(words);
if (v2() && 'new_episodes' in live) {
parts.push(levelOf(live).label);
const r = REFRESH.find(x => x.value === (live.refresh_interval || ''));
if (live.refresh_interval && r) parts.push('abrufen: ' + r.label.toLowerCase());
} else {
if (live.new_episodes === 'queue' || (!('new_episodes' in live) && live.auto_queue)) parts.push('neue Folgen → Warteschlange'); if (live.new_episodes === 'queue' || (!('new_episodes' in live) && live.auto_queue)) parts.push('neue Folgen → Warteschlange');
else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends'); else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends');
if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download'); if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download');
}
if (live.notify) parts.push('Benachrichtigen'); if (live.notify) parts.push('Benachrichtigen');
return parts.join(' · '); return parts.join(' · ');
} }
@ -2253,9 +2506,25 @@
const choose = (head, choices) => () => openSheet(head, choices); const choose = (head, choices) => () => openSheet(head, choices);
const items = []; const items = [];
const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase(); const pos = (POSITIONS.find(p => p.value === settings().auto_queue_position) || POSITIONS[1]).label.toLowerCase();
if (v2() && 'new_episodes' in live && N.podcastFeedSettings) {
const lv = levelOf(live);
items.push({icon: '◇', label: 'Stufe: ' + lv.label, hint: lv.hint, active: lv.value !== 'inbox',
action: choose(`„${title}“ ist …`, LEVELS.map(l => ({
icon: l.value === lv.value ? '●' : '○', label: l.label, hint: l.hint, active: l.value === lv.value,
action: () => save({new_episodes: l.value}),
})))});
if ('refresh_interval' in live) {
const r = REFRESH.find(x => x.value === (live.refresh_interval || '')) || REFRESH[0];
items.push({icon: '⟳', label: 'Abrufen: ' + r.label, hint: r.hint || null, active: !!live.refresh_interval,
action: choose('Wie oft der Server nachsieht', REFRESH.map(x => ({
icon: x.value === r.value ? '●' : '○', label: x.label, hint: x.hint || null, active: x.value === r.value,
action: () => save({refresh_interval: x.value}),
})))});
}
}
if ('new_episodes' in live && N.podcastFeedSettings) { if ('new_episodes' in live && N.podcastFeedSettings) {
const ne = live.new_episodes || 'inbox'; const ne = live.new_episodes || 'inbox';
items.push({icon: '◇', label: 'Neue Folgen', hint: NEW_WHERE[ne] || NEW_WHERE.inbox, active: ne !== 'inbox', if (!v2()) items.push({icon: '◇', label: 'Neue Folgen', hint: NEW_WHERE[ne] || NEW_WHERE.inbox, active: ne !== 'inbox',
action: choose(`Neue Folgen von „${title}“ …`, [ action: choose(`Neue Folgen von „${title}“ …`, [
{icon: ne === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', active: ne === 'inbox', action: () => save({new_episodes: 'inbox'})}, {icon: ne === 'inbox' ? '●' : '○', label: 'In „Neu“', hint: 'du entscheidest selbst', active: ne === 'inbox', action: () => save({new_episodes: 'inbox'})},
{icon: ne === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, active: ne === 'queue', action: () => save({new_episodes: 'queue'})}, {icon: ne === 'queue' ? '●' : '○', label: 'Gleich in die Warteschlange', hint: pos, active: ne === 'queue', action: () => save({new_episodes: 'queue'})},
@ -2263,7 +2532,7 @@
action: () => save({new_episodes: 'ignore'})}, action: () => save({new_episodes: 'ignore'})},
])}); ])});
// AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves. // AntennaPod's per-podcast auto-download: new episodes land on the phone by themselves.
if ('auto_download' in live && N.podcastDownloads) { if ('auto_download' in live && N.podcastDownloads && !v2()) {
items.push({icon: '↓', label: 'Automatisch herunterladen', hint: live.auto_download ? 'an: neue Folgen im WLAN, im Download-Limit' : 'aus', items.push({icon: '↓', label: 'Automatisch herunterladen', hint: live.auto_download ? 'an: neue Folgen im WLAN, im Download-Limit' : 'aus',
active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})}); active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})});
} }
@ -2321,7 +2590,7 @@
if ('kind' in live && N.podcastFeedSettings) { if ('kind' in live && N.podcastFeedSettings) {
const book = isBook(live); const book = isBook(live);
items.push({icon: '▤', label: book ? 'Hörbuch (unter Hören → Hörbücher)' : 'Als Hörbuch', items.push({icon: '▤', label: book ? 'Hörbuch (unter Hören → Hörbücher)' : 'Als Hörbuch',
hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht in „Neu“', hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht ' + (v2() ? 'im Strom' : 'in „Neu“'),
active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})}); active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})});
} }
if (hasFolders() && N.podcastFeedSettings) { if (hasFolders() && N.podcastFeedSettings) {
@ -2595,7 +2864,7 @@
let epList = null; // {key, items, total, loading, error} let epList = null; // {key, items, total, loading, error}
// The folder too: "Folgen" with a folder chosen is all episodes of that category. // The folder too: "Folgen" with a folder chosen is all episodes of that category.
const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort + '|' + JSON.stringify(podFolder); const epKey = () => epFilters.slice().sort().join(',') + '|' + epSort + '|' + JSON.stringify(podFolder) + '|' + (view === 'stream' ? 's' : '');
function setEpisodeList(filters, sort) { function setEpisodeList(filters, sort) {
// The history goes by the last listening; leaving it, by date again. // The history goes by the last listening; leaving it, by date again.
@ -2620,8 +2889,10 @@
else epList.loading = true; else epList.loading = true;
if (!quiet) render(); if (!quiet) render();
const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0]; const sorting = EP_SORTS.find(s => s.id === epSort) || EP_SORTS[0];
// The Strom: every subscription but the „Auf Abruf“ ones (docs/podcasts.md).
const base = view === 'stream' ? ['stream'] : [];
const params = new URLSearchParams({ const params = new URLSearchParams({
filter: epFilters.filter(id => id !== 'downloaded').join(','), sort: sorting.server || sorting.id, limit: '100', filter: base.concat(epFilters.filter(id => id !== 'downloaded')).join(','), sort: sorting.server || sorting.id, limit: '100',
offset: String(offset), offset: String(offset),
}); });
if (podFolder != null && hasFolders()) params.set('folder', podFolder); if (podFolder != null && hasFolders()) params.set('folder', podFolder);
@ -2668,6 +2939,12 @@
root.append(head); root.append(head);
} }
const list = el('div', 'nui-pod-list'); const list = el('div', 'nui-pod-list');
if (view === 'stream' && !selected && (epSort === 'new' || epSort === 'rare')) {
streamRows(list, items);
root.append(list);
moreButton();
return;
}
appendRows(list, items, epSort, e => { appendRows(list, items, epSort, e => {
const o = e.raw; const o = e.raw;
const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes', const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes',
@ -2682,6 +2959,10 @@
return row; return row;
}); });
root.append(list); root.append(list);
moreButton();
}
function moreButton() {
if (epList.items.length < epList.total) { if (epList.items.length < epList.total) {
const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden');
more.type = 'button'; more.type = 'button';
@ -2691,6 +2972,61 @@
} }
} }
// The Strom (docs/podcasts.md): by period; in one, the podcasts most listened to first
// (heard_365: episodes heard in 12 months), every one heard at all before the others,
// among those the rarely publishing first. Each podcast once per period: its newest
// episode and „+n ›“ for the others. New since the last look: a small mark.
const streamOpen = new Set();
function streamRank(feedId) {
const f = feeds().find(x => x.id === feedId) || {};
return [f.heard_365 || 0, typeof f.interval_days === 'number' ? f.interval_days : 0];
}
function streamRows(list, items) {
const before = Date.parse(state.stream_seen_before || '') || 0;
const fresh = e => !!before && Date.parse(e.raw.discovered_at || '') > before;
const rank = new Map();
const rankOf = id => { if (!rank.has(id)) rank.set(id, streamRank(id)); return rank.get(id); };
for (const sec of timeSections(items, false)) {
list.append(el('div', 'nui-pod-when', sec.label));
const groups = new Map();
sec.items.forEach((e, i) => {
if (!groups.has(e.feedId)) groups.set(e.feedId, {first: i, eps: []});
groups.get(e.feedId).eps.push(e);
});
const ordered = [...groups.entries()].sort((a, b) => {
const [ha, ia] = rankOf(a[0]);
const [hb, ib] = rankOf(b[0]);
if (ha !== hb) return hb - ha;
if (!ha && ia !== ib) return ib - ia;
return a[1].first - b[1].first;
});
for (const [feedId, g] of ordered) {
const key = sec.label + '|' + feedId;
const open = streamOpen.has(key);
const shownEps = open ? g.eps : g.eps.slice(0, 1);
shownEps.forEach((e, i) => {
const row = episodeRow(e, {showFeed: true, showDate: true, list: 'stream', fresh: fresh(e), filters: epFilters});
if (i > 0) row.classList.add('nui-pod-bundled');
list.append(row);
});
if (g.eps.length > 1) {
const rest = g.eps.length - 1;
const freshRest = !open && g.eps.slice(1).some(fresh);
const b = el('button', 'nui-pod-bundle' + (freshRest ? ' nui-pod-fresh' : ''),
open ? 'weniger ‹' : `+${rest} ${rest === 1 ? 'weitere' : 'weitere'} von ${g.eps[0].feed || 'diesem Podcast'} ›`);
b.type = 'button';
b.addEventListener('click', () => {
if (open) streamOpen.delete(key); else streamOpen.add(key);
render();
});
list.append(b);
}
}
}
}
// ---- favourites ------------------------------------------------------------------------- // ---- favourites -------------------------------------------------------------------------
function favoriteItem(e) { function favoriteItem(e) {
@ -4876,8 +5212,10 @@
window.__dioraPodcasts = function (json) { window.__dioraPodcasts = function (json) {
state = typeof json === 'string' ? parse(json, state) : json; state = typeof json === 'string' ? parse(json, state) : json;
moveFeedListsToServer(); moveFeedListsToServer();
listsOnOnce();
render(); render();
}; };
setTimeout(listsOnOnce, 2000);
// The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream). // The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream).
window.__dioraAskStream = function (id, title) { window.__dioraAskStream = function (id, title) {

View file

@ -1,4 +1,4 @@
/* Copied from diora-android bd98557 2026-10-19: the podcast parts of web-native/static/native.css /* Copied from diora-android ef914bd 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. (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here.
The web's own additions are in podcasts-web.css. */ The web's own additions are in podcasts-web.css. */
@ -232,6 +232,22 @@ button.nui-pod-link { display: block; width: 100%; border: none; background: non
/* Headings by time in the episode lists (Heute, Gestern, Diese Woche, …). */ /* 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 { 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-when:first-child { padding-top: 4px; }
/* 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; }
.nui-pod-group .nui-pod-art, .nui-pod-group img { width: 40px; height: 40px; flex: none; }
.nui-pod-group-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
.nui-pod-group-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nui-pod-group .nui-pod-meta { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nui-pod-group-count { font-size: 12px; color: var(--nui-muted); flex: none; }
.nui-pod-chev { flex: none; width: 18px; text-align: center; color: var(--nui-muted); }
.nui-pod-later .nui-pod-row { padding-left: 14px; }
.nui-pod-mini { min-width: 34px; padding: 0 4px; font-size: 17px; color: var(--nui-muted); flex: none; }
.nui-pod-mini.on { color: var(--accent, #e63946); }
.nui-pod-fresh .nui-pod-title::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--accent, #e63946); margin-right: 6px; vertical-align: middle; }
.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; }
.nui-pod-row { .nui-pod-row {
display: flex; display: flex;
align-items: center; align-items: center;