diff --git a/static/js/sw.js b/static/js/sw.js index 1aafb82..461a9dd 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-v136'; +const CACHE = 'diora-v137'; 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 2ef135b..b21d24c 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -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 // and copy again. The browser host is static/js/podcasts-web.js. @@ -31,7 +31,16 @@ {id: 'episodes', label: 'Folgen'}, {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). const IN_APP = !window.DioraPodcastHost; // A function: the app loads this file in , before #tab-home exists. @@ -72,6 +81,13 @@ let state = parse(N.podcastState(), {}); let player = {kind: null}; 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 searchResults = null; 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 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 // (inbox_total); what left „Neu“ here since then counts off. function inboxTotal() { @@ -389,13 +417,48 @@ function play(e) { const r = remote(); 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()); } function enqueue(e, where) { N.podcastEnqueue(JSON.stringify([e.raw]), where || '', currentId()); 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. @@ -420,13 +483,15 @@ // „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“. - 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 && - {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. 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'); offerUndo(e, 'Folge gelöscht', {hide, svg: 'trash', run: () => { discardNow([e.id]); @@ -445,7 +510,7 @@ // Several: asked first. function discardMany(ids) { 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); toast(`${nEpisodes(ids.length)} gelöscht`); stopSelecting(); @@ -465,9 +530,10 @@ !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 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), - {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)}, discardItem(e, opts), selectItem(e, opts), @@ -475,6 +541,7 @@ } function episodeMenu(e, opts) { + if (v2()) { listsMenu(e, opts); return; } const queued = inQueue(e.id); openSheet(e.title, [ {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() { const s = settings(); const choose = (name, title) => openSheet(title, POSITIONS.map(p => ({ @@ -510,7 +598,10 @@ openSheet('Podcasts', [ {icon: '⟳', label: 'Alle Feeds jetzt abrufen', hint: 'der Server holt sie, hier kommt nur das Ergebnis an', 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), action: () => choose('enqueue_position', 'Von Hand eingereihte Folgen kommen …')}, {icon: '↻', label: 'Automatisch eingereihte Folgen', hint: label(s.auto_queue_position), @@ -561,6 +652,26 @@ {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() { if (!N.podcastBackgroundSync || !N.podcastSetBackgroundSync) return null; const now = N.podcastBackgroundSync(); @@ -988,7 +1099,9 @@ none: 'Keine angefangenen Folgen.'}, {id: 'favorites', label: 'Favoriten', test: o => !!o.favorite, 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: 'history', label: 'Verlauf', all: true, test: o => !!o.played || (o.position_seconds || 0) > 0, none: 'Noch nichts gehört.'}, @@ -1099,7 +1212,7 @@ }; chip('Alle', !active.length, () => onChange([])); 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); chip(f.label, on, () => onChange(on ? active.filter(x => x !== f.id) : active.filter(x => !(EXCLUDES[f.id] || []).includes(x)).concat(f.id))); @@ -1190,8 +1303,16 @@ } else if (feedView) { renderFeed(); } else { - const tabs = el('div', 'nui-pod-tabs'); - for (const v of VIEWS) { + fixView(); + 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' : '')); b.type = 'button'; b.append(el('span', null, v.label)); @@ -1211,11 +1332,13 @@ if (offer) root.append(offer); 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 === 'episodes' && !selected) folderChips(podcastFeeds(), f => f.id); + else if ((view === 'episodes' || view === 'stream') && !selected) folderChips(podcastFeeds(), f => f.id); if (view === 'queue') renderQueue(); 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 renderSearch(); } @@ -1318,7 +1441,15 @@ } const how = {select: !opts.noSelect, list, filters: opts.filters || []}; const menu = () => (list === 'queue' ? queueMenu(e, how) : episodeMenu(e, how)); - row.append(primaryButton(e, list), button('⋯', 'Mehr', menu, 'nui-pod-menu')); + 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.addEventListener('click', () => openEpisode(e, how)); // Swipes: touch only, not while the queue is locked. 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 episode lists download (or queue, where nothing downloads). 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); } + function playButton(e) { + const now = currentId() === e.id; + const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen', + () => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn'); + b.dataset.play = e.id; + return b; + } + function primaryButton(e, list) { - if (list === 'queue' || list === 'downloads') { - const now = currentId() === e.id; - const b = button(now && player.playing ? '❚❚' : '▶', now && player.playing ? 'Pause' : 'Abspielen', - () => (currentId() === e.id ? N.podcastToggle() : play(e)), 'nui-pod-playbtn'); - b.dataset.play = e.id; - return b; - } + if (list === 'queue' || list === 'downloads' || list === 'later' || (v2() && list === 'feed')) return playButton(e); if (dlPrimary(e, list)) return dlButton(e); 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(); }); } @@ -1523,6 +1658,9 @@ episodes: {label: 'Folgen', left: 'download', right: 'favorite'}, 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'}, + // 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 = [ {id: 'none', label: 'Nichts', icon: ''}, @@ -1535,6 +1673,8 @@ {id: 'played', label: 'Gehört / ungehört', hint: 'als gehört markieren', icon: '✓'}, {id: 'favorite', label: 'Favorit an / aus', icon: '★'}, {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', 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 'dequeue': return {title: 'Aus Warteschlange', hint: DL ? 'Datei bleibt' : null}; 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 'download': return {title: 'Laden'}; case 'favorite': return {title: 'Favorit'}; @@ -1688,8 +1830,27 @@ function swipeDone(e, list, act, filters) { const o = e.raw || {}; 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 (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. const hide = list === 'inbox'; offerUndo(e, 'Eingereiht', {hide, run: () => { @@ -1729,7 +1890,7 @@ if (act === 'played') { const played = !(o.played || e.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('played'); offerUndo(e, played ? 'Als gehört markiert' : 'Als ungehört markiert', {hide, run: () => { @@ -1761,18 +1922,19 @@ function renderQueue() { const all = queue().map(ep); 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; } 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); if (selected) { 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 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: '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(), ])); } else { @@ -1805,6 +1967,74 @@ 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() { const all = inbox().map(ep); if (!all.length) { @@ -2091,7 +2321,10 @@ row.addEventListener('click', () => toggleSelected(f.id)); 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')); const n = counter().value(f); if (n) { @@ -2220,6 +2453,20 @@ } 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) { 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'); const words = filterWords(live); if (words) parts.push(words); - 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'); - if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download'); + 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'); + else if (live.new_episodes === 'ignore') parts.push('neue Folgen: nirgends'); + if (live.auto_download && N.podcastDownloads) parts.push('Auto-Download'); + } if (live.notify) parts.push('Benachrichtigen'); return parts.join(' · '); } @@ -2253,9 +2506,25 @@ const choose = (head, choices) => () => openSheet(head, choices); const items = []; 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) { 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}“ …`, [ {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'})}, @@ -2263,7 +2532,7 @@ action: () => save({new_episodes: 'ignore'})}, ])}); // 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', active: !!live.auto_download, action: () => save({auto_download: !live.auto_download})}); } @@ -2321,7 +2590,7 @@ if ('kind' in live && N.podcastFeedSettings) { const book = isBook(live); 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'})}); } if (hasFolders() && N.podcastFeedSettings) { @@ -2595,7 +2864,7 @@ let epList = null; // {key, items, total, loading, error} // 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) { // The history goes by the last listening; leaving it, by date again. @@ -2620,8 +2889,10 @@ else epList.loading = true; if (!quiet) render(); 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({ - 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), }); if (podFolder != null && hasFolders()) params.set('folder', podFolder); @@ -2668,6 +2939,12 @@ root.append(head); } 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 => { const o = e.raw; const row = episodeRow(e, {showFeed: true, showDate: epSort !== 'played', list: downloadsOnly(epFilters) ? 'downloads' : 'episodes', @@ -2682,6 +2959,10 @@ return row; }); root.append(list); + moreButton(); + } + + function moreButton() { if (epList.items.length < epList.total) { const more = el('button', 'btn nui-pod-more', epList.loading ? 'Wird geladen …' : 'Weitere laden'); 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 ------------------------------------------------------------------------- function favoriteItem(e) { @@ -4876,8 +5212,10 @@ window.__dioraPodcasts = function (json) { state = typeof json === 'string' ? parse(json, state) : json; moveFeedListsToServer(); + listsOnOnce(); render(); }; + setTimeout(listsOnOnce, 2000); // The app won't stream this episode over mobile data without a yes ("Nachfragen", RadioService.mayStream). window.__dioraAskStream = function (id, title) { diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 40b50fa..3bfdcfc 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -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. 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, …). */ .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; } +/* 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 { display: flex; align-items: center;