From 4e4fac227c4ed1a8ed03d5843125f6fa5c7f9388 Mon Sep 17 00:00:00 2001 From: mrwnslz Date: Fri, 16 Oct 2026 21:06:41 +0200 Subject: [PATCH] Podcast-UI aus diora-android 449118c (Ordner-Chips, ein Kopf je Bereich); E-Ink: Art-Abzeichen in Abos & Quellen lesbar Co-Authored-By: Claude Opus 5.5 --- static/js/sources.js | 1 + static/js/sw.js | 2 +- static/podcasts/native-podcast.js | 396 ++++++++++++++++++++++++++++-- static/podcasts/podcast-ui.css | 37 ++- 4 files changed, 410 insertions(+), 26 deletions(-) diff --git a/static/js/sources.js b/static/js/sources.js index 8f049cc..9072889 100644 --- a/static/js/sources.js +++ b/static/js/sources.js @@ -476,6 +476,7 @@ '#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }', '#src-toast.src-show { opacity: 1; }', 'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }', + 'body.eink #tab-sources .src-kind { background: none; color: #000; border: 1px solid #000; }', ].join('\n'); document.head.appendChild(style); })(); diff --git a/static/js/sw.js b/static/js/sw.js index 33b9c63..f0cd94e 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-v119'; +const CACHE = 'diora-v120'; 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 7a80727..bf23c65 100644 --- a/static/podcasts/native-podcast.js +++ b/static/podcasts/native-podcast.js @@ -1,4 +1,4 @@ -// Copied from diora-android 3eed256 2026-10-16: web-native/static/native-podcast.js +// Copied from diora-android 449118c 2026-10-16: web-native/static/native-podcast.js // by tools/sync_podcast_ui.py. Do not edit here: change it in diora-android // and copy again. The browser host is static/js/podcasts-web.js. @@ -251,9 +251,59 @@ top.append(button('⌄', closeLabel, onClose)); sheet.append(panel); sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); }); + swipeToClose(panel, onClose); return {sheet, panel, top}; } + // A sheet (the player, an episode's details …) follows the finger down from its top and + // closes when let go far enough down (or flicked); otherwise it springs back. Only while + // nothing under the finger can scroll up any more, and never on a slider. + function swipeToClose(panel, onClose) { + let g = null; + const scrolledUp = target => { + for (let n = target; n && n !== panel.parentNode; n = n.parentNode) { + if (n.nodeType === 1 && n.scrollTop > 0 && n.scrollHeight > n.clientHeight) return true; + } + return false; + }; + panel.addEventListener('touchstart', e => { + g = null; + if (e.touches.length !== 1 || e.target.closest('input, textarea, select, [contenteditable="true"]')) return; + if (scrolledUp(e.target)) return; + g = {y: e.touches[0].clientY, x: e.touches[0].clientX, t: Date.now(), on: false, dy: 0}; + }, {passive: true}); + panel.addEventListener('touchmove', e => { + if (!g || e.touches.length !== 1) return; + const t = e.touches[0]; + const dy = t.clientY - g.y; + if (!g.on) { + if (dy < 0 || Math.abs(t.clientX - g.x) > Math.abs(dy)) { g = null; return; } + if (dy < 10) return; + g.on = true; + panel.style.transition = 'none'; + } + e.preventDefault(); // the sheet moves, the page under it doesn't scroll + g.dy = dy; + panel.style.transform = `translateY(${Math.max(0, dy)}px)`; + }, {passive: false}); + const end = () => { + if (!g || !g.on) { g = null; return; } + const fast = g.dy / Math.max(1, Date.now() - g.t) > 0.6; // a flick: px per ms + const far = g.dy > Math.min(160, panel.offsetHeight * 0.25); + g = null; + panel.style.transition = 'transform 0.18s ease-out'; + if (far || fast) { + panel.style.transform = 'translateY(100%)'; + setTimeout(onClose, 150); + } else { + panel.style.transform = ''; + setTimeout(() => { panel.style.transition = ''; }, 200); + } + }; + panel.addEventListener('touchend', end, {passive: true}); + panel.addEventListener('touchcancel', end, {passive: true}); + } + // Feed titles sometimes arrive with HTML entities still in them (&). const decoder = document.createElement('textarea'); function plain(s) { @@ -797,12 +847,12 @@ } // The bar while choosing: count, all/none, and what to do with the chosen ones. - function selectionBar(items, actions) { + function selectionBar(items, actions, hint) { const bar = el('div', 'nui-pod-selbar'); const all = items.length > 0 && items.every(e => selected.has(e.id)); bar.append( button('✕', 'Auswahl beenden', stopSelecting), - el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : 'Folgen antippen'), + el('span', 'nui-pod-selcount', selected.size ? `${selected.size} ausgewählt` : (hint || 'Folgen antippen')), button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => { selected = new Set(all ? [] : items.map(e => e.id)); render(); @@ -1013,7 +1063,11 @@ refreshing = true; render(); N.podcastRefresh(!!fetchFeeds); + loadFolderOrder(); } + setTimeout(loadFolderOrder, 1500); + // Pull to refresh (native-gestures.js). + window.__dioraPodcastRefresh = refresh; // ---- rendering --------------------------------------------------------------------------- @@ -1091,6 +1145,9 @@ root.append(tabs); const offer = importOffer(); 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 === 'feeds') folderChips(podcastFeeds(), f => f.id); if (view === 'queue') renderQueue(); else if (view === 'inbox') renderInbox(); else if (view === 'episodes') renderEpisodes(); @@ -1637,11 +1694,13 @@ } function renderQueue() { - const items = queue().map(ep); - if (!items.length) { + 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.'); return; } + const items = all.filter(e => inFolder(e.feedId)); + if (!items.length) { empty(folderEmpty('in der Warteschlange')); 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, [ @@ -1654,7 +1713,8 @@ } else { const head = el('div', 'nui-pod-head'); const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : ''; - head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}`), + head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Folge' : 'Folgen'} · ${minutes(total)}${kept}` + + (podFolder != null && items.length < all.length ? ` · von ${all.length}` : '')), listTools([ button('⇅', 'Sortieren oder mischen', sortMenu), button('☐', 'Mehrere auswählen', () => startSelecting()), @@ -1667,7 +1727,7 @@ for (const e of items) { const row = episodeRow(e, {showFeed: true, list: 'queue'}); if (!selected) { - if (!locked && !keepSorted()) { + if (!locked && !keepSorted() && podFolder == null) { const handle = el('div', 'nui-pod-handle', '≡'); handle.title = 'Ziehen zum Umsortieren'; handle.addEventListener('click', ev => ev.stopPropagation()); @@ -1681,13 +1741,15 @@ } function renderInbox() { - const items = inbox().map(ep); - if (!items.length) { + const all = inbox().map(ep); + if (!all.length) { empty(feeds().length ? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.' : 'Noch keine Abos. Unter „Suche“ findest du Podcasts.'); return; } + const items = all.filter(e => inFolder(e.feedId)); + if (!items.length) { empty(folderEmpty('neu')); return; } if (selected) { const chosen = ids => items.filter(e => ids.includes(e.id)); const add = (ids, where) => { @@ -1711,8 +1773,9 @@ } else { const head = el('div', 'nui-pod-head'); const total = inboxTotal(); - head.append(el('div', 'nui-pod-summary', `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` + - (total > items.length ? ` · die ${items.length} neuesten` : '')), + head.append(el('div', 'nui-pod-summary', podFolder != null + ? `${items.length} neue ${items.length === 1 ? 'Folge' : 'Folgen'} · von ${total}` + : `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` + (total > items.length ? ` · die ${items.length} neuesten` : '')), listTools([button('☐', 'Mehrere auswählen', () => startSelecting())])); root.append(head); } @@ -1721,6 +1784,174 @@ root.append(list); } + // ---- folders (diora-web docs/library.md) ------------------------------------------------------ + // Like tags: a subscription may be in several (feed.folders; `folder`, the first, is for older + // clients). Podcasts and audiobooks share the names and their order (/podcasts/folders/). + // One filter for Neu, Warteschlange and Abos, remembered on this device. Putting into + // folders, renaming and deleting go through the subscriptions' settings, so they work + // offline too; only the order is posted on its own (and again later when that failed). + + // folders.js loads after this file. + const hasFolders = () => !!window.DioraFolders; + let podFolder = null; // null: all, '': without a folder, else a name + let folderOrder = []; + try { + podFolder = JSON.parse(localStorage.getItem('nui_pod_folder') || 'null'); + folderOrder = JSON.parse(localStorage.getItem('nui_pod_folder_order') || '[]') || []; + } catch (e) {} + + const foldersOf = f => (!f ? [] : f.folders && f.folders.length ? f.folders.slice() : f.folder ? [f.folder] : []); + const sameName = (a, b) => a.toLowerCase() === b.toLowerCase(); + const dedupe = names => names.filter((n, i) => names.findIndex(m => sameName(m, n)) === i); + + // The names in their order: the order the server keeps first, the rest by name. + function orderedNames(names) { + const first = folderOrder.filter(n => names.includes(n)); + const rest = names.filter(n => !first.includes(n)).sort((a, b) => a.localeCompare(b, 'de', {sensitivity: 'base'})); + return first.concat(rest); + } + const namesOf = list => orderedNames(dedupe([].concat(...list.map(foldersOf)))); + + function inFolder(feedId) { + if (podFolder == null) return true; + const fs = foldersOf(feeds().find(f => f.id === feedId)); + return podFolder === '' ? !fs.length : fs.includes(podFolder); + } + + function setFolder(name) { + podFolder = name; + try { localStorage.setItem('nui_pod_folder', JSON.stringify(name)); } catch (e) {} + render(); + scrollTop(0); + } + + function folderEmpty(what) { + return podFolder === '' ? `Ohne Ordner ist nichts ${what}.` : `In „${podFolder}“ ist nichts ${what}.`; + } + + // The chips (folders.js) above a list: each folder with how many of the list are in it. + function folderChips(items, feedIdOf) { + if (!hasFolders()) return; + const names = namesOf(podcastFeeds()); + if (!names.length || (podFolder && !names.includes(podFolder))) podFolder = null; + if (!names.length) return; + const byId = new Map(feeds().map(f => [f.id, f])); + const counts = new Map(); + let none = 0; + for (const x of items) { + const fs = foldersOf(byId.get(feedIdOf(x))); + if (!fs.length) none++; + for (const n of fs) counts.set(n, (counts.get(n) || 0) + 1); + } + const box = el('div', 'nui-pod-folders'); + 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), + onManage: manageFolders, + }); + root.append(box); + } + + function saveFeedFolders(feedId, names) { + N.podcastFeedSettings(feedId, JSON.stringify({folders: names})); + } + + // Each subscription's folders through fn (renaming, deleting a folder). + function rewriteFolders(fn) { + for (const f of feeds()) { + const before = foldersOf(f); + if (!before.length) continue; + const after = dedupe(fn(before)); + if (after.join('\n') !== before.join('\n')) saveFeedFolders(f.id, after); + } + } + + // "In Ordner …" for one subscription or several: what all of them are in comes ticked; + // unticking takes it from all, ticking puts all in; the folders only some are in stay. + function assignFolders(list) { + if (!list.length) return; + const common = foldersOf(list[0]).filter(n => list.every(f => foldersOf(f).includes(n))); + DioraFolders.assign({ + title: list.length === 1 ? `„${plain(list[0].title)}“ in Ordner` : `${list.length} Abos in Ordner`, + folders: namesOf(feeds()), + selected: common, + onDone: picked => { + for (const f of list) { + const before = foldersOf(f); + const after = dedupe(before.filter(n => !common.includes(n) || picked.includes(n)).concat(picked)); + if (after.join('\n') !== before.join('\n')) saveFeedFolders(f.id, after); + } + toast(list.length > 1 ? `${list.length} Abos eingeordnet` + : picked.length ? 'In ' + picked.map(n => `„${n}“`).join(', ') : 'In keinem Ordner'); + stopSelecting(); + setTimeout(render, 80); + }, + }); + } + + function manageFolders() { + DioraFolders.manage({ + title: 'Ordner verwalten', + folders: namesOf(feeds()), + onRename: (old, name) => { + // Onto an existing name: both become one (its spelling stays). + const target = namesOf(feeds()).find(n => n !== old && sameName(n, name)) || name; + rewriteFolders(fs => fs.map(n => (n === old ? target : n))); + keepOrder(dedupe(folderOrder.map(n => (n === old ? target : n))), true); + if (podFolder === old) podFolder = target; + setTimeout(render, 80); + }, + onDelete: name => { + rewriteFolders(fs => fs.filter(n => n !== name)); + keepOrder(folderOrder.filter(n => n !== name), true); + if (podFolder === name) podFolder = null; + setTimeout(render, 80); + }, + onOrder: order => keepOrder(order, true), + }); + } + + async function postJsonTo(url, body) { + const headers = {'Content-Type': 'application/json'}; + if (typeof getCsrfToken === 'function') headers['X-CSRFToken'] = getCsrfToken(); + const r = await fetch(url, {method: 'POST', headers, body: JSON.stringify(body)}); + if (!r.ok) throw new Error(r.status); + return r.json(); + } + + // The chips' order: kept here, and on the server (posted again later when that failed). + function keepOrder(order, post) { + const changed = order.join('\n') !== folderOrder.join('\n'); + folderOrder = order.slice(); + try { + localStorage.setItem('nui_pod_folder_order', JSON.stringify(folderOrder)); + if (post) localStorage.setItem('nui_pod_folder_order_dirty', '1'); + } catch (e) {} + if (post) sendOrder(); + if (changed && !post) render(); + } + + async function sendOrder() { + try { + const d = await postJsonTo('/podcasts/folders/', {order: folderOrder}); + try { localStorage.removeItem('nui_pod_folder_order_dirty'); } catch (e) {} + if (d && Array.isArray(d.order)) keepOrder(d.order, false); + } catch (e) { /* offline: again on the next load */ } + } + + async function loadFolderOrder() { + if (!hasFolders()) return; + let dirty = false; + try { dirty = localStorage.getItem('nui_pod_folder_order_dirty') === '1'; } catch (e) {} + if (dirty) { sendOrder(); return; } + try { + const d = await getJson('/podcasts/folders/'); + if (d && Array.isArray(d.order)) keepOrder(d.order, false); + } catch (e) { /* offline: the order kept here */ } + } + // The subscriptions: sorted, each with a number (new, unplayed or downloaded // episodes), both remembered on this device. const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || '')); @@ -1774,6 +2005,10 @@ function feedRow(f) { const row = el('div', 'nui-pod-row'); + if (selected) { + row.classList.add('nui-pod-selecting'); + row.append(el('span', 'nui-pod-check' + (selected.has(f.id) ? ' on' : ''), selected.has(f.id) ? '☑' : '☐')); + } row.append(art(f.artwork_url, null, null, f.title)); const main = el('div', 'nui-pod-main'); main.append(el('div', 'nui-pod-title', plain(f.title))); @@ -1783,7 +2018,14 @@ else if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at)); main.append(el('div', 'nui-pod-meta', meta.join(' · '))); if (f.last_error) main.append(el('div', 'nui-pod-feederr', '⚠ lädt nicht: ' + f.last_error)); + // Its folders, where the chips don't already say it. + const fs = foldersOf(f).filter(n => n !== podFolder); + if (fs.length && hasFolders()) main.append(el('div', 'nui-pod-meta nui-pod-folderline', '▣ ' + fs.join(', '))); row.append(main); + if (selected) { + row.addEventListener('click', () => toggleSelected(f.id)); + return row; + } 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); @@ -1798,22 +2040,30 @@ function renderFeeds() { const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0]; - const items = podcastFeeds().slice().sort((a, b) => sort.cmp(a, b) || byTitle(a, b)); + const items = podcastFeeds().filter(f => inFolder(f.id)).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`); if (!items.length) { + if (podFolder != null && podcastFeeds().length) { empty(folderEmpty('abonniert')); return; } empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.'); if (books) root.append(booksHint()); return; } - const head = el('div', 'nui-pod-head'); - head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`), - listTools([ - hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources), - button('⇅', 'Sortieren und Zahl wählen', feedSortMenu), - ].filter(Boolean))); - root.append(head); + if (selected) { + root.append(selectionBar(items, [ + hasFolders() && N.podcastFeedSettings && {label: 'In Ordner …', run: ids => assignFolders(items.filter(f => ids.includes(f.id)))}, + ], 'Abos antippen')); + } else { + const head = el('div', 'nui-pod-head'); + head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`), + listTools([ + hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources), + button('⇅', 'Sortieren und Zahl wählen', feedSortMenu), + hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()), + ].filter(Boolean))); + root.append(head); + } const list = el('div', 'nui-pod-list'); for (const f of items) list.append(feedRow(f)); root.append(list); @@ -2009,6 +2259,11 @@ hint: book ? 'tippen: wieder als Podcast' : 'Folgen als Kapitel unter Hören → Hörbücher, nicht in „Neu“', active: book, action: () => save({kind: book ? 'podcast' : 'audiobook'})}); } + if (hasFolders() && N.podcastFeedSettings) { + const fs = foldersOf(live); + items.push({icon: '▣', label: 'In Ordner …', hint: fs.length ? fs.join(', ') : 'in keinem', active: fs.length > 0, + action: () => assignFolders([live])}); + } // Skip silence for this podcast (server), where this player can skip silence at all. if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) { const mine = live.skip_silence; @@ -2880,9 +3135,11 @@ const text = el('button', 'nui-mini-text'); text.type = 'button'; text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track')); - text.addEventListener('click', () => (miniKind === 'tts' ? ttsShowBook() + const openFromMini = () => (miniKind === 'tts' ? ttsShowBook() : miniKind === 'ambient' ? showTab('ambient') - : miniKind === 'radio' ? openRadioPlayer() : openPlayer())); + : miniKind === 'radio' ? openRadioPlayer() : openPlayer()); + text.addEventListener('click', openFromMini); + miniGestures(mini, openFromMini); const devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices'); const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here'); devices.hidden = here.hidden = true; @@ -2922,13 +3179,15 @@ } } - // Ambient (app.js's #tab-ambient): its sleep timer, a ☾ at the frame's top right corner. + // Ambient (app.js's #tab-ambient): its sleep timer, a ☾ in the section's header + // (native-ui.js), else at the frame's top right corner. function ambientSleepButton() { const tab = $id('tab-ambient'); if (!tab || !N.podcastSleep || $id('nui-amb-sleep')) return; const b = button('☾', 'Sleep-Timer', () => sleepMenu(true, true), 'nui-pod-sleep'); b.id = 'nui-amb-sleep'; - tab.append(b); + const bar = tab.querySelector('.nui-section-bar'); + if (bar) bar.append(b); else tab.append(b); } function renderAmbientSleep() { @@ -3486,6 +3745,93 @@ // The full player: one for both. Which one it shows now. let sheetKind = ''; + // ---- gestures on the player (native-gestures.js has the reader's and the lists') ----------- + + // The mini player: up opens the big player; sideways skips back / forward (an episode, + // read-aloud's sentences) or goes to the previous / next saved station. + function miniGestures(bar, open) { + let g = null; + let swiped = 0; + bar.addEventListener('touchstart', e => { + g = e.touches.length === 1 ? {x: e.touches[0].clientX, y: e.touches[0].clientY, t: Date.now()} : null; + }, {passive: true}); + bar.addEventListener('touchend', e => { + if (!g) return; + const t = e.changedTouches[0]; + const dx = t.clientX - g.x; + const dy = t.clientY - g.y; + const quick = Date.now() - g.t < 700; + g = null; + if (!quick) return; + if (dy < -40 && Math.abs(dy) > Math.abs(dx) * 1.2) { + swiped = Date.now(); + open(); + } else if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) { + swiped = Date.now(); + miniStep(dx < 0); + } + }, {passive: true}); + // The tap that a swipe ends with is no tap. + bar.addEventListener('click', e => { + if (Date.now() - swiped < 450) { e.stopPropagation(); e.preventDefault(); } + }, true); + } + + function miniStep(forward) { + const prefs = devicePrefs(); + const secs = forward ? (prefs.skip_forward || 30) : (prefs.skip_back || 15); + if (miniKind === 'remote') { + remoteCmd('skip', forward ? secs : -secs); + toast((forward ? '+' : '−') + secs + ' s'); + } else if (miniKind === 'tts') { + const t = window.__dioraTts; + if (t) forward ? t.next() : t.prev(); + } else if (miniKind === 'radio') { + radioStep(forward); + } else if (miniKind !== 'ambient') { + N.podcastSkip(forward); + toast((forward ? '+' : '−') + secs + ' s'); + } + } + + // The previous / next of the saved stations (the radio's list, in its order). + function radioStep(forward) { + const rows = [...document.querySelectorAll('#saved-tbody tr[data-id]')]; + if (!rows.length || typeof playStation !== 'function') return; + const now = typeof currentStation !== 'undefined' && currentStation ? currentStation.url : null; + const at = rows.findIndex(r => r.dataset.url === now); + const row = rows[((at < 0 ? (forward ? -1 : 0) : at) + (forward ? 1 : -1) + rows.length) % rows.length]; + playStation(row.dataset.url, row.dataset.name, parseInt(row.dataset.id, 10) || null); + toast('◉ ' + row.dataset.name); + } + + // The big player's cover: tap twice on its left / right half to skip back / forward. + function coverGestures(cover) { + let last = 0; + let lastSide = null; + cover.addEventListener('touchend', e => { + if (e.changedTouches.length !== 1) return; + const t = e.changedTouches[0]; + const r = cover.getBoundingClientRect(); + const side = t.clientX < r.left + r.width / 2 ? 'back' : 'fwd'; + const now = Date.now(); + if (now - last < 320 && side === lastSide) { + e.preventDefault(); + last = 0; + const prefs = devicePrefs(); + const fwd = side === 'fwd'; + N.podcastSkip(fwd); + const mark = el('span', 'nui-pod-cover-skip ' + (fwd ? 'nui-right' : 'nui-left'), + (fwd ? '+' : '−') + (fwd ? (prefs.skip_forward || 30) : (prefs.skip_back || 15)) + ' s'); + cover.append(mark); + setTimeout(() => mark.remove(), 700); + return; + } + last = now; + lastSide = side; + }, {passive: false}); + } + function openPlayer() { if (remote()) { openRemotePlayer(); return; } if (player.kind !== 'podcast') return; @@ -3493,7 +3839,9 @@ sheetKind = 'podcast'; const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Player'); sheet = made; - panel.append(top, art(player.artwork, 'player', null, player.feed)); + const cover = art(player.artwork, 'player', null, player.feed); + coverGestures(cover); + 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); diff --git a/static/podcasts/podcast-ui.css b/static/podcasts/podcast-ui.css index 1dbdce9..b6b05f3 100644 --- a/static/podcasts/podcast-ui.css +++ b/static/podcasts/podcast-ui.css @@ -1,4 +1,4 @@ -/* Copied from diora-android 3eed256 2026-10-16: the podcast parts of web-native/static/native.css +/* Copied from diora-android 449118c 2026-10-16: the podcast parts of web-native/static/native.css (bottom sheet, podcasts) by tools/sync_podcast_ui.py. Do not edit here. The web's own additions are in podcasts-web.css. */ @@ -196,6 +196,34 @@ html.nui-one-player #nui-mini { display: none !important; } .nui-pod-summary { padding: 6px 2px 10px; font-size: 12px; color: var(--nui-muted); } /* Where audiobook subscriptions went (they are in the library, see audiobooks.js). */ .nui-pod-hint { padding: 14px 2px; font-size: 13px; color: var(--nui-muted); font-family: var(--nui-sans); letter-spacing: normal; } +/* ---- gestures (native-gestures.js, native-podcast.js) ---- */ +#nui-gesture-sign { + position: fixed; left: 50%; top: 42%; transform: translate(-50%, -50%); + z-index: 995; padding: 12px 20px; border-radius: 12px; + background: rgba(0, 0, 0, 0.78); color: #fff; font-size: 18px; font-weight: 700; + opacity: 0; pointer-events: none; transition: opacity 0.2s; +} +#nui-gesture-sign.nui-show { opacity: 1; } +#nui-pull { + position: fixed; left: 50%; top: 0; z-index: 995; width: 40px; height: 40px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: var(--panel, #111); border: 1px solid var(--border); color: var(--nui-muted); font-size: 20px; + transform: translate(-50%, -48px); opacity: 0; pointer-events: none; transition: opacity 0.15s; +} +#nui-pull.nui-show { opacity: 1; } +#nui-pull.nui-ready { color: var(--accent); border-color: var(--accent); } +.nui-pod-art-player { position: relative; } +.nui-pod-cover-skip { + position: absolute; top: 50%; transform: translateY(-50%); padding: 10px 14px; border-radius: 22px; + background: rgba(0, 0, 0, 0.7); color: #fff; font-weight: 700; font-size: 16px; pointer-events: none; + animation: nui-cover-skip 0.7s ease-out forwards; +} +.nui-pod-cover-skip.nui-left { left: 12px; } +.nui-pod-cover-skip.nui-right { right: 12px; } +@keyframes nui-cover-skip { from { opacity: 1; } to { opacity: 0; } } +body.eink #nui-gesture-sign { background: #000; transition: none; } +body.eink #nui-pull { transition: none; } + /* A hint that leads somewhere (Abos & Quellen). */ button.nui-pod-link { display: block; width: 100%; border: none; background: none; text-align: left; cursor: pointer; padding: 4px 2px 12px; } .nui-pod-empty { padding: 32px 12px; color: var(--nui-muted); text-align: center; line-height: 1.5; font-family: var(--nui-sans); letter-spacing: normal; font-weight: 400; font-size: 14px; } @@ -798,3 +826,10 @@ body.eink .nui-pod-facts a { text-decoration-color: #000; } background: rgba(0, 0, 0, 0.55); color: #ddd; } +.nui-section-bar #nui-amb-sleep { + position: static; + background: none; + border-color: transparent; + color: inherit; +} +.nui-section-bar #nui-amb-sleep.on { border-color: var(--accent); }