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 <noreply@anthropic.com>
This commit is contained in:
parent
d870ed6f9d
commit
4e4fac227c
4 changed files with 410 additions and 26 deletions
|
|
@ -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 { 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; }',
|
'#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-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');
|
].join('\n');
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -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-v119';
|
const CACHE = 'diora-v120';
|
||||||
const SHELL = [
|
const SHELL = [
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
'/static/js/app/01-core.js',
|
'/static/js/app/01-core.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
|
// 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.
|
||||||
|
|
||||||
|
|
@ -251,9 +251,59 @@
|
||||||
top.append(button('⌄', closeLabel, onClose));
|
top.append(button('⌄', closeLabel, onClose));
|
||||||
sheet.append(panel);
|
sheet.append(panel);
|
||||||
sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); });
|
sheet.addEventListener('click', ev => { if (ev.target === sheet) onClose(); });
|
||||||
|
swipeToClose(panel, onClose);
|
||||||
return {sheet, panel, top};
|
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 (&).
|
// Feed titles sometimes arrive with HTML entities still in them (&).
|
||||||
const decoder = document.createElement('textarea');
|
const decoder = document.createElement('textarea');
|
||||||
function plain(s) {
|
function plain(s) {
|
||||||
|
|
@ -797,12 +847,12 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// The bar while choosing: count, all/none, and what to do with the chosen ones.
|
// 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 bar = el('div', 'nui-pod-selbar');
|
||||||
const all = items.length > 0 && items.every(e => selected.has(e.id));
|
const all = items.length > 0 && items.every(e => selected.has(e.id));
|
||||||
bar.append(
|
bar.append(
|
||||||
button('✕', 'Auswahl beenden', stopSelecting),
|
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', () => {
|
button(all ? '☑' : '☐', all ? 'Keine auswählen' : 'Alle auswählen', () => {
|
||||||
selected = new Set(all ? [] : items.map(e => e.id));
|
selected = new Set(all ? [] : items.map(e => e.id));
|
||||||
render();
|
render();
|
||||||
|
|
@ -1013,7 +1063,11 @@
|
||||||
refreshing = true;
|
refreshing = true;
|
||||||
render();
|
render();
|
||||||
N.podcastRefresh(!!fetchFeeds);
|
N.podcastRefresh(!!fetchFeeds);
|
||||||
|
loadFolderOrder();
|
||||||
}
|
}
|
||||||
|
setTimeout(loadFolderOrder, 1500);
|
||||||
|
// Pull to refresh (native-gestures.js).
|
||||||
|
window.__dioraPodcastRefresh = refresh;
|
||||||
|
|
||||||
// ---- rendering ---------------------------------------------------------------------------
|
// ---- rendering ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -1091,6 +1145,9 @@
|
||||||
root.append(tabs);
|
root.append(tabs);
|
||||||
const offer = importOffer();
|
const offer = importOffer();
|
||||||
if (offer) root.append(offer);
|
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();
|
if (view === 'queue') renderQueue();
|
||||||
else if (view === 'inbox') renderInbox();
|
else if (view === 'inbox') renderInbox();
|
||||||
else if (view === 'episodes') renderEpisodes();
|
else if (view === 'episodes') renderEpisodes();
|
||||||
|
|
@ -1637,11 +1694,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderQueue() {
|
function renderQueue() {
|
||||||
const items = queue().map(ep);
|
const all = queue().map(ep);
|
||||||
if (!items.length) {
|
if (!all.length) {
|
||||||
empty('Die Warteschlange ist leer. Unter „Neu“ oder bei einem Abo tippst du auf „+“, um Folgen einzureihen.');
|
empty('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));
|
||||||
|
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);
|
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, [
|
||||||
|
|
@ -1654,7 +1713,8 @@
|
||||||
} else {
|
} else {
|
||||||
const head = el('div', 'nui-pod-head');
|
const head = el('div', 'nui-pod-head');
|
||||||
const kept = keepSorted() ? ' · ' + ((SORTS.find(x => x.id === keepSorted()) || {}).label || '').toLowerCase() : '';
|
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([
|
listTools([
|
||||||
button('⇅', 'Sortieren oder mischen', sortMenu),
|
button('⇅', 'Sortieren oder mischen', sortMenu),
|
||||||
button('☐', 'Mehrere auswählen', () => startSelecting()),
|
button('☐', 'Mehrere auswählen', () => startSelecting()),
|
||||||
|
|
@ -1667,7 +1727,7 @@
|
||||||
for (const e of items) {
|
for (const e of items) {
|
||||||
const row = episodeRow(e, {showFeed: true, list: 'queue'});
|
const row = episodeRow(e, {showFeed: true, list: 'queue'});
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
if (!locked && !keepSorted()) {
|
if (!locked && !keepSorted() && podFolder == null) {
|
||||||
const handle = el('div', 'nui-pod-handle', '≡');
|
const handle = el('div', 'nui-pod-handle', '≡');
|
||||||
handle.title = 'Ziehen zum Umsortieren';
|
handle.title = 'Ziehen zum Umsortieren';
|
||||||
handle.addEventListener('click', ev => ev.stopPropagation());
|
handle.addEventListener('click', ev => ev.stopPropagation());
|
||||||
|
|
@ -1681,13 +1741,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInbox() {
|
function renderInbox() {
|
||||||
const items = inbox().map(ep);
|
const all = inbox().map(ep);
|
||||||
if (!items.length) {
|
if (!all.length) {
|
||||||
empty(feeds().length
|
empty(feeds().length
|
||||||
? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.'
|
? 'Keine neuen Folgen. Neue Folgen deiner Abos erscheinen hier, sobald der Server sie gefunden hat.'
|
||||||
: 'Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
: 'Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const items = all.filter(e => inFolder(e.feedId));
|
||||||
|
if (!items.length) { empty(folderEmpty('neu')); return; }
|
||||||
if (selected) {
|
if (selected) {
|
||||||
const chosen = ids => items.filter(e => ids.includes(e.id));
|
const chosen = ids => items.filter(e => ids.includes(e.id));
|
||||||
const add = (ids, where) => {
|
const add = (ids, where) => {
|
||||||
|
|
@ -1711,8 +1773,9 @@
|
||||||
} else {
|
} else {
|
||||||
const head = el('div', 'nui-pod-head');
|
const head = el('div', 'nui-pod-head');
|
||||||
const total = inboxTotal();
|
const total = inboxTotal();
|
||||||
head.append(el('div', 'nui-pod-summary', `${total} neue ${total === 1 ? 'Folge' : 'Folgen'}` +
|
head.append(el('div', 'nui-pod-summary', podFolder != null
|
||||||
(total > items.length ? ` · die ${items.length} neuesten` : '')),
|
? `${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())]));
|
listTools([button('☐', 'Mehrere auswählen', () => startSelecting())]));
|
||||||
root.append(head);
|
root.append(head);
|
||||||
}
|
}
|
||||||
|
|
@ -1721,6 +1784,174 @@
|
||||||
root.append(list);
|
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
|
// The subscriptions: sorted, each with a number (new, unplayed or downloaded
|
||||||
// episodes), both remembered on this device.
|
// episodes), both remembered on this device.
|
||||||
const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || ''));
|
const byDate = key => (a, b) => String(b[key] || '').localeCompare(String(a[key] || ''));
|
||||||
|
|
@ -1774,6 +2005,10 @@
|
||||||
|
|
||||||
function feedRow(f) {
|
function feedRow(f) {
|
||||||
const row = el('div', 'nui-pod-row');
|
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));
|
row.append(art(f.artwork_url, null, null, f.title));
|
||||||
const main = el('div', 'nui-pod-main');
|
const main = el('div', 'nui-pod-main');
|
||||||
main.append(el('div', 'nui-pod-title', plain(f.title)));
|
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));
|
else if (f.latest_episode_at) meta.push('neueste ' + day(f.latest_episode_at));
|
||||||
main.append(el('div', 'nui-pod-meta', meta.join(' · ')));
|
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));
|
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);
|
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'));
|
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);
|
||||||
|
|
@ -1798,22 +2040,30 @@
|
||||||
|
|
||||||
function renderFeeds() {
|
function renderFeeds() {
|
||||||
const sort = FEED_SORTS.find(s => s.id === feedSort) || FEED_SORTS[0];
|
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 books = feeds().length - items.length;
|
||||||
const booksHint = () => el('div', 'nui-pod-hint',
|
const booksHint = () => el('div', 'nui-pod-hint',
|
||||||
`${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`);
|
`${books === 1 ? 'Ein Hörbuch' : books + ' Hörbücher'}: unter Hören → Hörbücher`);
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
|
if (podFolder != null && podcastFeeds().length) { empty(folderEmpty('abonniert')); return; }
|
||||||
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
empty('Noch keine Abos. Unter „Suche“ findest du Podcasts.');
|
||||||
if (books) root.append(booksHint());
|
if (books) root.append(booksHint());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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');
|
const head = el('div', 'nui-pod-head');
|
||||||
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
|
head.append(el('div', 'nui-pod-summary', `${items.length} ${items.length === 1 ? 'Abo' : 'Abos'} · ${sort.label.toLowerCase()}`),
|
||||||
listTools([
|
listTools([
|
||||||
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
|
hasSources() && button('⚙', 'Verwalten: Abos & Quellen', toSources),
|
||||||
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
|
button('⇅', 'Sortieren und Zahl wählen', feedSortMenu),
|
||||||
|
hasFolders() && N.podcastFeedSettings && button('☐', 'Mehrere auswählen (in Ordner)', () => startSelecting()),
|
||||||
].filter(Boolean)));
|
].filter(Boolean)));
|
||||||
root.append(head);
|
root.append(head);
|
||||||
|
}
|
||||||
const list = el('div', 'nui-pod-list');
|
const list = el('div', 'nui-pod-list');
|
||||||
for (const f of items) list.append(feedRow(f));
|
for (const f of items) list.append(feedRow(f));
|
||||||
root.append(list);
|
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“',
|
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'})});
|
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.
|
// 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) {
|
if ('skip_silence' in live && 'skip_silence' in devicePrefs() && N.podcastFeedSettings) {
|
||||||
const mine = live.skip_silence;
|
const mine = live.skip_silence;
|
||||||
|
|
@ -2880,9 +3135,11 @@
|
||||||
const text = el('button', 'nui-mini-text');
|
const text = el('button', 'nui-mini-text');
|
||||||
text.type = 'button';
|
text.type = 'button';
|
||||||
text.append(el('span', 'nui-mini-station'), el('span', 'nui-mini-track'));
|
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 === '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 devices = button('⇄', 'Wiedergabe auf …', deviceMenu, 'nui-mini-devices');
|
||||||
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
|
const here = button('⇩', 'Hier abspielen', takeOver, 'nui-mini-here');
|
||||||
devices.hidden = here.hidden = true;
|
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() {
|
function ambientSleepButton() {
|
||||||
const tab = $id('tab-ambient');
|
const tab = $id('tab-ambient');
|
||||||
if (!tab || !N.podcastSleep || $id('nui-amb-sleep')) return;
|
if (!tab || !N.podcastSleep || $id('nui-amb-sleep')) return;
|
||||||
const b = button('☾', 'Sleep-Timer', () => sleepMenu(true, true), 'nui-pod-sleep');
|
const b = button('☾', 'Sleep-Timer', () => sleepMenu(true, true), 'nui-pod-sleep');
|
||||||
b.id = 'nui-amb-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() {
|
function renderAmbientSleep() {
|
||||||
|
|
@ -3486,6 +3745,93 @@
|
||||||
// The full player: one for both. Which one it shows now.
|
// The full player: one for both. Which one it shows now.
|
||||||
let sheetKind = '';
|
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() {
|
function openPlayer() {
|
||||||
if (remote()) { openRemotePlayer(); return; }
|
if (remote()) { openRemotePlayer(); return; }
|
||||||
if (player.kind !== 'podcast') return;
|
if (player.kind !== 'podcast') return;
|
||||||
|
|
@ -3493,7 +3839,9 @@
|
||||||
sheetKind = 'podcast';
|
sheetKind = 'podcast';
|
||||||
const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Player');
|
const {sheet: made, panel, top} = podSheet('', 'Player schließen', closePlayer, 'Player');
|
||||||
sheet = made;
|
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');
|
const chapBox = el('div', 'nui-pod-chapbox');
|
||||||
chapBox.hidden = true;
|
chapBox.hidden = true;
|
||||||
panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox);
|
panel.append(el('div', 'nui-pod-player-title'), el('div', 'nui-pod-player-feed'), chapBox);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
(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. */
|
||||||
|
|
||||||
|
|
@ -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); }
|
.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). */
|
/* 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; }
|
.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). */
|
/* 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; }
|
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; }
|
.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);
|
background: rgba(0, 0, 0, 0.55);
|
||||||
color: #ddd;
|
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); }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue