Compare commits
No commits in common. "a48dca07ea71b88b5d47e2f12cf194dae1b553ea" and "12b651b7727095d4aa79f8acf13fd65721ce69cf" have entirely different histories.
a48dca07ea
...
12b651b772
14 changed files with 35 additions and 550 deletions
|
|
@ -1,87 +0,0 @@
|
|||
# Navigation: Lesen and Hören
|
||||
|
||||
diora's areas are sorted by what one does, not where things come from:
|
||||
|
||||
| Group | Sections |
|
||||
|-------|----------|
|
||||
| — | Home |
|
||||
| Lesen | Bücher · Zeitschriften · Nachrichten · Blogs |
|
||||
| Hören | Hörbücher · Podcasts · Radio · Ambient |
|
||||
| — | Abos & Quellen (managing all subscriptions; phase 2) |
|
||||
|
||||
- **Desktop / wide window (sidebar):** every section is its own entry, under
|
||||
its group's heading. Nothing inside a section repeats the choice.
|
||||
- **Phone (tab bar):** three tabs, **Home · Lesen · Hören**. Lesen and Hören
|
||||
show a row of their sections at the top (segments); each group remembers
|
||||
the section last used. The mini player stays below as it is.
|
||||
- **diora Reader** (tolino, e-readers): only Lesen, without Home. **TV:** Hören.
|
||||
|
||||
Anything new goes into one of the two groups. Managing (subscribing, settings,
|
||||
folders) moves out of the everyday views into "Abos & Quellen" (phase 2).
|
||||
|
||||
## One definition: `static/js/nav.js` (diora-web)
|
||||
|
||||
Loaded by the page on every platform, before the other area scripts. It owns
|
||||
the sections, which panel shows each, switching between them, the URL hash
|
||||
and the remembered choice. The bars are drawn from it.
|
||||
|
||||
```
|
||||
window.DioraNav = {
|
||||
sections(), // [{id, group, label, icon, available}] in order (only those the page has)
|
||||
groups(), // [{id: 'home'|'lesen'|'hoeren', label, icon, sections: [id…]}]
|
||||
current(), // the section id shown now
|
||||
go(id), // show a section ('lesen'/'hoeren': that group's last section)
|
||||
onChange(fn), // fn(sectionId) after every change
|
||||
}
|
||||
```
|
||||
|
||||
| Section id | Group | Label | Panel | View inside the panel |
|
||||
|------------|-------|-------|-------|-----------------------|
|
||||
| `home` | — | Home | `#tab-home` | |
|
||||
| `books` | lesen | Bücher | `#tab-books` | e-books (not magazines) |
|
||||
| `magazines` | lesen | Zeitschriften | `#tab-books` | magazines |
|
||||
| `news` | lesen | Nachrichten | `#tab-feeds` | news |
|
||||
| `blogs` | lesen | Blogs | `#tab-feeds` | blogs |
|
||||
| `audiobooks` | hoeren | Hörbücher | `#tab-audiobooks` | (audiobooks.js) |
|
||||
| `podcasts` | hoeren | Podcasts | `#tab-podcasts` | |
|
||||
| `radio` | hoeren | Radio | `#tab-radio` | |
|
||||
| `ambient` | hoeren | Ambient | `#tab-ambient` | |
|
||||
|
||||
`go()` calls the page's `showTab(panel)` as before (so everything hooked onto
|
||||
showTab keeps working) and then the panel's view hook:
|
||||
`window.__dioraBooksView('books'|'magazines')`,
|
||||
`window.__dioraFeedsView('news'|'blogs')`. The panels' own switches for that
|
||||
choice (feeds.js News/Blogs) are hidden. `_currentTopTab` stays the panel name.
|
||||
|
||||
Hash: `#books`, `#magazines`, `#news`, `#blogs`, `#audiobooks`, … (old hashes
|
||||
`#feeds`, `#radio/saved` keep working). Remembered: `localStorage`
|
||||
`diora_nav_section` and `diora_nav_last_<group>`.
|
||||
|
||||
**Zeitschriften** (phase 1): the books in a library folder called
|
||||
"Zeitschriften" (or "Magazine"); a per-book mark follows with phase 3.
|
||||
|
||||
### The segment row (phone)
|
||||
|
||||
nav.js draws `#nav-segments` above the panels: the current group's sections,
|
||||
when the layout is the tab bar (not `html.nui-sidebar`) and the group has more
|
||||
than one available section. Styled with the theme tokens; the native CSS may
|
||||
restyle it.
|
||||
|
||||
## Who draws what
|
||||
|
||||
| Where | Bar | Drawn by |
|
||||
|-------|-----|----------|
|
||||
| Android (phone) | `#nui-tabbar`: Home · Lesen · Hören | native-radio.js, from `DioraNav.groups()` |
|
||||
| Android tablet, desktop (`html.nui-sidebar`) | rail: Home, headings Lesen/Hören with their sections, Menü | native-radio.js, from `DioraNav.sections()` |
|
||||
| Web page | `#tabs` (narrow: groups + segments; wide: all sections under headings) | nav.js |
|
||||
|
||||
A tab/rail button calls `DioraNav.go(id)`; the active state comes from
|
||||
`DioraNav.onChange`.
|
||||
|
||||
## Phases
|
||||
|
||||
1. Navigation (this document), Hörbücher as their own panel, Zeitschriften by folder.
|
||||
2. "Abos & Quellen": podcasts, YouTube channels, news sources, blogs and the
|
||||
audiobook mark in one place; the everyday views without managing.
|
||||
3. Library: audiobook series (e.g. Das Kapital, Band 1–3) and folders,
|
||||
magazines marked per book.
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// The navigation (docs/navigation.md): Home, Lesen (Bücher, Zeitschriften, Nachrichten,
|
||||
// Blogs), Hören (Hörbücher, Podcasts, Radio, Ambient), on the web page's own bar.
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
|
||||
test.use({ storageState: path.join(__dirname, '.auth', 'user.json') });
|
||||
|
||||
const visiblePanel = page => page.evaluate(() =>
|
||||
[...document.querySelectorAll('section.tab-panel')].filter(p => p.style.display !== 'none').map(p => p.id));
|
||||
|
||||
test('wide: every section in the bar under its group, each shows its panel and view', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto('/#books');
|
||||
const wide = page.locator('#tabs .nav-wide');
|
||||
await expect(wide.locator('.nav-group-label')).toHaveText(['Lesen', 'Hören']);
|
||||
await expect(wide.locator('[data-nav]')).toHaveText(
|
||||
['Home', 'Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs', 'Hörbücher', 'Podcasts', 'Radio', 'Ambient', 'Fokus']);
|
||||
|
||||
await wide.locator('[data-nav="blogs"]').click();
|
||||
await expect(page).toHaveURL(/#blogs$/);
|
||||
expect(await visiblePanel(page)).toEqual(['tab-feeds']);
|
||||
expect(await page.evaluate(() => localStorage.getItem('diora_feeds_view'))).toBe('blogs');
|
||||
await expect(page.locator('#tab-feeds .fd-seg')).toBeHidden(); // the bar chooses, not the tab
|
||||
await expect(page.locator('#nav-segments')).toBeHidden();
|
||||
|
||||
await wide.locator('[data-nav="audiobooks"]').click();
|
||||
expect(await visiblePanel(page)).toEqual(['tab-audiobooks']);
|
||||
await expect(page.locator('#audiobook-shelf')).toBeVisible();
|
||||
await expect(wide.locator('[data-nav="audiobooks"]')).toHaveClass(/active/);
|
||||
});
|
||||
|
||||
test('narrow: three groups, the sections as a row on top, each group remembers its last', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 800 });
|
||||
await page.goto('/#news');
|
||||
const narrow = page.locator('#tabs .nav-narrow');
|
||||
await expect(narrow.locator('[data-nav]')).toHaveText(['Home', 'Lesen', 'Hören']);
|
||||
await expect(narrow.locator('[data-nav="lesen"]')).toHaveClass(/active/);
|
||||
const segs = page.locator('#nav-segments');
|
||||
await expect(segs.locator('[data-nav]')).toHaveText(['Bücher', 'Zeitschriften', 'Nachrichten', 'Blogs']);
|
||||
await expect(segs.locator('[data-nav="news"]')).toHaveClass(/active/);
|
||||
|
||||
await narrow.locator('[data-nav="hoeren"]').click();
|
||||
await segs.locator('[data-nav="radio"]').click();
|
||||
expect(await visiblePanel(page)).toEqual(['tab-radio']);
|
||||
await narrow.locator('[data-nav="lesen"]').click();
|
||||
await expect(page).toHaveURL(/#news$/); // back where Lesen was left
|
||||
await narrow.locator('[data-nav="hoeren"]').click();
|
||||
await expect(page).toHaveURL(/#radio/);
|
||||
});
|
||||
|
||||
test('old links and direct panel switches land in their section', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1200, height: 800 });
|
||||
await page.goto('/#feeds');
|
||||
expect(await visiblePanel(page)).toEqual(['tab-feeds']);
|
||||
await expect(page.locator('#tabs .nav-wide [data-nav].active')).toHaveText(/Nachrichten|Blogs/);
|
||||
await page.evaluate(() => showTab('podcasts'));
|
||||
await expect(page.locator('#tabs [data-nav="podcasts"]')).toHaveClass(/active/);
|
||||
await expect(page).toHaveURL(/#podcasts$/);
|
||||
await page.reload();
|
||||
expect(await visiblePanel(page)).toEqual(['tab-podcasts']); // remembered
|
||||
});
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
// Tabs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient', 'audiobooks'];
|
||||
const TOP_TABS = ['home', 'radio', 'focus', 'podcasts', 'books', 'feeds', 'ambient'];
|
||||
const RADIO_SUB_TABS = ['search', 'saved', 'history'];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -17,7 +17,7 @@ let _routingFromHash = false;
|
|||
let _currentTopTab = 'radio';
|
||||
|
||||
function _syncHash() {
|
||||
if (_routingFromHash || window.DioraNav) return; // the navigation keeps the hash (static/js/nav.js)
|
||||
if (_routingFromHash) return;
|
||||
let hash = _currentTopTab;
|
||||
if (_currentTopTab === 'radio') {
|
||||
hash += '/' + (localStorage.getItem('diora_active_radio_tab') || 'saved');
|
||||
|
|
@ -26,7 +26,6 @@ function _syncHash() {
|
|||
}
|
||||
|
||||
function _routeFromHash() {
|
||||
if (window.DioraNav) return; // nav.js routes (#news, #audiobooks …)
|
||||
const [tab, sub] = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
|
||||
if (!TOP_TABS.includes(tab)) return;
|
||||
_routingFromHash = true;
|
||||
|
|
@ -53,11 +52,9 @@ function showTab(name) {
|
|||
if (panel) panel.style.display = (p === name) ? '' : 'none';
|
||||
});
|
||||
|
||||
if (!window.DioraNav) { // else nav.js marks its own buttons
|
||||
document.querySelectorAll('#tabs .tab-btn').forEach((btn, i) => {
|
||||
btn.classList.toggle('active', TOP_TABS[i] === name);
|
||||
});
|
||||
}
|
||||
|
||||
localStorage.setItem('diora_active_tab', name);
|
||||
_currentTopTab = name;
|
||||
|
|
|
|||
|
|
@ -205,27 +205,6 @@ async function toggleBookRead(bookId, currentlyRead) {
|
|||
// free in /api/sync/. One level only: folders don't nest.
|
||||
let _currentBookFolder = null; // null = root view (folder tiles + unfiled books)
|
||||
|
||||
// Bücher and Zeitschriften are two sections of Lesen (static/js/nav.js), one list: the
|
||||
// magazines are the books in a folder called "Zeitschriften" (or "Magazine").
|
||||
let _bookSection = 'books';
|
||||
const _MAGAZINE_FOLDER = /^(zeitschriften|magazine|magazines)$/i;
|
||||
const _isMagazine = b => _MAGAZINE_FOLDER.test((b.folder || '').trim());
|
||||
|
||||
// The books of the section shown (all of them where there are no sections).
|
||||
function _bookSectionFilter(books) {
|
||||
if (!window.DioraNav) return books;
|
||||
return books.filter(b => (_bookSection === 'magazines') === _isMagazine(b));
|
||||
}
|
||||
window._bookSectionFilter = _bookSectionFilter;
|
||||
|
||||
window.__dioraBooksView = function (view) {
|
||||
const next = view === 'magazines' ? 'magazines' : 'books';
|
||||
if (next === _bookSection) return;
|
||||
_bookSection = next;
|
||||
_currentBookFolder = null;
|
||||
if (_lastBookListData) renderBookList(_lastBookListData);
|
||||
};
|
||||
|
||||
function _recentlyReadBooks(books) {
|
||||
return books
|
||||
.filter(b => b.last_read)
|
||||
|
|
@ -286,18 +265,13 @@ function _openBookFolder(name) {
|
|||
renderBookList(_lastBookListData);
|
||||
}
|
||||
|
||||
function renderBookList(allBooks) {
|
||||
function renderBookList(books) {
|
||||
const listEl = $('book-list');
|
||||
if (!listEl) return;
|
||||
_lastBookListData = allBooks;
|
||||
const books = _bookSectionFilter(allBooks);
|
||||
_lastBookListData = books;
|
||||
|
||||
if (!books.length) {
|
||||
listEl.innerHTML = window.DioraNav && allBooks.length
|
||||
? (_bookSection === 'magazines'
|
||||
? '<p class="muted">Noch keine Zeitschriften. Hefte, die in einem Ordner „Zeitschriften“ liegen, stehen hier (⋮ → Ordner zuweisen).</p>'
|
||||
: '<p class="muted">Alle Bücher liegen bei den Zeitschriften.</p>')
|
||||
: '';
|
||||
listEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -325,7 +299,7 @@ function renderBookList(allBooks) {
|
|||
|
||||
const grouped = new Map();
|
||||
for (const b of visible) {
|
||||
const key = _bookSection === 'magazines' && _isMagazine(b) ? '' : (b.folder || '');
|
||||
const key = b.folder || '';
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(b);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -71,10 +71,7 @@
|
|||
// except when offline, where books (usable offline) always takes over.
|
||||
const offlineOverride = !navigator.onLine && IS_AUTHENTICATED;
|
||||
const hashTab = (location.hash || '').replace(/^#/, '').split('/')[0];
|
||||
if (window.DioraNav) { // sections (static/js/nav.js)
|
||||
DioraNav.restore(offlineOverride);
|
||||
showRadioTab(localStorage.getItem('diora_active_radio_tab') || 'saved');
|
||||
} else if (!offlineOverride && TOP_TABS.includes(hashTab)) {
|
||||
if (!offlineOverride && TOP_TABS.includes(hashTab)) {
|
||||
_routeFromHash();
|
||||
} else {
|
||||
const savedTab = localStorage.getItem('diora_active_tab') || 'radio';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Audiobooks, the section "Hörbücher" of Hören (docs/audiobooks.md, docs/navigation.md):
|
||||
* podcast subscriptions marked as a Hörbuch or Hörspiel are listened to like a book —
|
||||
* Audiobooks in the library (docs/audiobooks.md): podcast subscriptions marked as a
|
||||
* Hörbuch or Hörspiel are listened to like a book, next to the e-books in "Bücher" —
|
||||
* "Weiterhören", one progress per book, the chapters in order, bookmarks with a note,
|
||||
* the sleep timer to the end of the chapter. The server knows the books
|
||||
* (/podcasts/audiobooks/), the podcast host plays them (podcastPlayBook: the chapters in
|
||||
|
|
@ -9,8 +9,9 @@
|
|||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const tab = document.getElementById('tab-audiobooks');
|
||||
if (!tab) return;
|
||||
const tab = document.getElementById('tab-books');
|
||||
const list = document.getElementById('book-list');
|
||||
if (!tab || !list) return;
|
||||
if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return;
|
||||
|
||||
// The podcast host: the app's own on Android, podcasts-web.js elsewhere.
|
||||
|
|
@ -32,7 +33,10 @@
|
|||
|
||||
const shelf = document.createElement('div');
|
||||
shelf.id = 'audiobook-shelf';
|
||||
tab.appendChild(shelf);
|
||||
// Above the e-books, and above their "Gelesene anzeigen" switch (it is about them).
|
||||
const filter = list.previousElementSibling;
|
||||
const before = filter && filter.classList.contains('book-list-filter') ? filter : list;
|
||||
list.parentNode.insertBefore(shelf, before);
|
||||
|
||||
// ---- helpers ------------------------------------------------------------------------
|
||||
|
||||
|
|
@ -169,12 +173,7 @@
|
|||
const books = st.books || [];
|
||||
const state = h ? podcastState() : null;
|
||||
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
|
||||
if (!h) { shelf.innerHTML = ''; return; }
|
||||
if (!books.length && !pods.length) {
|
||||
shelf.innerHTML = '<p class="ab-empty">Hörbücher und Hörspiele sind hier Podcasts, die wie ein Buch gehört ' +
|
||||
'werden: Kapitel in Reihenfolge, Lesezeichen. Erst einen Podcast abonnieren (Podcasts → Suche), dann hier markieren.</p>';
|
||||
return;
|
||||
}
|
||||
if (!h || (!books.length && !pods.length)) { shelf.innerHTML = ''; return; }
|
||||
let html = '';
|
||||
const go = books.filter(b => b.next && b.last_played_at)[0];
|
||||
if (go) {
|
||||
|
|
@ -503,9 +502,6 @@
|
|||
if (box && fresh) box.innerHTML = fresh.innerHTML;
|
||||
});
|
||||
|
||||
// Home's "Weiterhören" card (home.js) plays the book from where it was left.
|
||||
window.__dioraAudiobookGo = feedId => goOn(feedId);
|
||||
|
||||
// ---- following the player ------------------------------------------------------------
|
||||
|
||||
let pollTimer = 0;
|
||||
|
|
@ -532,7 +528,7 @@
|
|||
if (typeof showTab === 'function') {
|
||||
window.showTab = function (name) {
|
||||
const r = showTab.apply(this, arguments);
|
||||
if (name === 'audiobooks') loadList();
|
||||
if (name === 'books') loadList();
|
||||
else if (st.open != null) closeBook();
|
||||
return r;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -994,14 +994,6 @@
|
|||
// ---- showing the tab
|
||||
|
||||
let shownAt = 0;
|
||||
// Nachrichten and Blogs are sections of Lesen (static/js/nav.js): it chooses, before the
|
||||
// tab is shown; its own switch is then hidden.
|
||||
window.__dioraFeedsView = function (view) {
|
||||
if (view !== 'news' && view !== 'blogs' || view === st.view) return;
|
||||
st.view = view;
|
||||
try { localStorage.setItem('diora_feeds_view', view); } catch (err) {}
|
||||
shownAt = 0; // shown next: loaded for this view
|
||||
};
|
||||
window.__dioraFeedsShow = function () {
|
||||
// Back after a while: fresh state (new briefing); otherwise as it was.
|
||||
if (!st.state || Date.now() - shownAt > 5 * 60000) load();
|
||||
|
|
@ -1047,33 +1039,22 @@
|
|||
if (e.key === 'Escape' && readerOpen()) closeReader();
|
||||
});
|
||||
|
||||
// diora Reader has no tab bar of its own: a small one. With the navigation (nav.js) it
|
||||
// holds the sections of Lesen itself — a row of them to swipe is no good on e-ink.
|
||||
// diora Reader has no tab bar of its own: a small one for books and feeds.
|
||||
if (READER_APP && !document.getElementById('nui-tabbar')) {
|
||||
const NAV = window.DioraNav;
|
||||
const dock = document.createElement('div');
|
||||
dock.id = 'nui-dock';
|
||||
dock.innerHTML = '<nav id="nui-tabbar">' + (NAV
|
||||
? NAV.sections().map(s => '<button type="button" data-nav="' + s.id + '"><span class="nui-tab-icon">' +
|
||||
esc(s.icon) + '</span>' + esc(s.label) + '</button>').join('')
|
||||
: '<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
|
||||
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button>') + '</nav>';
|
||||
dock.innerHTML = '<nav id="nui-tabbar">' +
|
||||
'<button type="button" data-tab="books"><span class="nui-tab-icon">▤</span>Bücher</button>' +
|
||||
'<button type="button" data-tab="feeds"><span class="nui-tab-icon">☰</span>Feeds</button></nav>';
|
||||
document.body.appendChild(dock);
|
||||
const mark = () => dock.querySelectorAll('button').forEach(b => b.classList.toggle('nui-active', NAV
|
||||
? b.dataset.nav === NAV.current()
|
||||
: typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
|
||||
const mark = () => dock.querySelectorAll('button').forEach(b =>
|
||||
b.classList.toggle('nui-active', typeof _currentTopTab !== 'undefined' && b.dataset.tab === _currentTopTab));
|
||||
dock.addEventListener('click', e => {
|
||||
const b = e.target.closest('button');
|
||||
if (!b) return;
|
||||
if (NAV) NAV.go(b.dataset.nav);
|
||||
else showTab(b.dataset.tab);
|
||||
showTab(b.dataset.tab);
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
if (NAV) {
|
||||
document.documentElement.classList.add('nav-flat'); // every section in the bar: no row on top
|
||||
NAV.onChange(mark);
|
||||
NAV.barsReady();
|
||||
}
|
||||
const orig = window.showTab;
|
||||
window.showTab = function (name) {
|
||||
orig.apply(this, arguments);
|
||||
|
|
@ -1097,7 +1078,6 @@
|
|||
'#tab-feeds .fd-seg { display: flex; border: 1px solid var(--border, #2a2a2a); border-radius: 6px; overflow: hidden; }',
|
||||
'#tab-feeds .fd-seg button { background: none; border: none; color: inherit; font: inherit; padding: 9px 14px; cursor: pointer; }',
|
||||
'#tab-feeds .fd-seg button.on { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
||||
'html.nav-bars #tab-feeds .fd-seg { display: none; }',
|
||||
'#tab-feeds .fd-count { display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px; background: var(--raised, rgba(255,255,255,0.18)); font-size: 11px; text-align: center; }',
|
||||
'#tab-feeds .fd-actions { display: flex; gap: 4px; }',
|
||||
'.fd-icon { background: none; border: none; color: inherit; font: inherit; font-size: 20px; min-width: 40px; min-height: 40px; cursor: pointer; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }',
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@
|
|||
card = podCard('Läuft auf ' + (r.device || 'einem anderen Gerät'), r.title || '', r.feed || '', r.artwork || '',
|
||||
r.dur ? (r.dur - (r.pos || 0)) / 1000 : 0, r.dur ? (r.pos || 0) / r.dur : 0,
|
||||
'<button type="button" class="btn btn-sm" data-act="pod-here">Hier weiterhören</button>');
|
||||
} else if (player.kind === 'podcast' && !player.book) { // an audiobook's chapter: under Hörbücher
|
||||
} else if (player.kind === 'podcast') {
|
||||
const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0;
|
||||
card = podCard(player.playing ? 'Läuft gerade' : 'Weiterhören', player.title, player.feed, player.artwork, left,
|
||||
player.dur ? (player.pos || 0) / player.dur : 0,
|
||||
|
|
@ -239,33 +239,6 @@
|
|||
|
||||
// ---- page ---------------------------------------------------------------------
|
||||
|
||||
// ---- Hörbücher (static/js/audiobooks.js) -------------------------------------------
|
||||
|
||||
function audiobookSection() {
|
||||
const h = podHost();
|
||||
if (!h || !document.getElementById('tab-audiobooks') || !window.__dioraAudiobookGo) return '';
|
||||
const player = h.playerState ? parse(h.playerState(), {}) || {} : {};
|
||||
let books = [];
|
||||
try { books = JSON.parse(localStorage.getItem('diora_ab_books') || '[]') || []; } catch (e) {}
|
||||
let card;
|
||||
if (player.kind === 'podcast' && player.book) {
|
||||
const b = player.book;
|
||||
const left = player.dur ? (player.dur - (player.pos || 0)) / 1000 : 0;
|
||||
card = podCard((player.playing ? 'Läuft gerade' : 'Weiterhören') + ' · Kapitel ' + (b.index + 1) + '/' + b.chapters,
|
||||
player.title, b.title || player.feed, player.artwork, left, player.dur ? (player.pos || 0) / player.dur : 0,
|
||||
'<button type="button" class="btn btn-sm home-play" data-act="' + (player.playing ? 'pod-pause' : 'pod-resume') +
|
||||
'" data-id="' + esc(player.episodeId) + '">' + (player.playing ? '■ Pause' : '▶ Weiter') + '</button>');
|
||||
} else {
|
||||
const b = books.filter(x => x.next && x.last_played_at)[0];
|
||||
if (!b) return '';
|
||||
card = podCard('Weiterhören · Kapitel ' + (b.next.index + 1) + '/' + b.chapters, b.title, b.author || '',
|
||||
b.artwork_url, b.total_seconds ? b.total_seconds - b.heard_seconds : 0,
|
||||
b.total_seconds ? b.heard_seconds / b.total_seconds : 0,
|
||||
'<button type="button" class="btn btn-sm home-play" data-act="ab-go" data-id="' + esc(b.feed_id) + '">▶ Weiterhören</button>');
|
||||
}
|
||||
return section('Hörbücher', card, 'audiobooks');
|
||||
}
|
||||
|
||||
function section(title, body, tabName) {
|
||||
return '<section class="home-section"><div class="home-head"><h2>' + esc(title) + '</h2>' +
|
||||
'<button type="button" class="home-more" data-act="tab" data-tab="' + tabName + '">Alle ›</button></div>' + body + '</section>';
|
||||
|
|
@ -283,7 +256,7 @@
|
|||
}
|
||||
let html = '<div class="home-brand">diora</div>';
|
||||
refreshFeeds(3 * 60000);
|
||||
for (const part of [booksSection, audiobookSection, podcastSection, feedsSection, radioSection, ambientSection]) {
|
||||
for (const part of [booksSection, podcastSection, feedsSection, radioSection, ambientSection]) {
|
||||
try { html += part(); } catch (e) { console.warn('home', e); }
|
||||
}
|
||||
if (html !== lastHtml) {
|
||||
|
|
@ -320,7 +293,6 @@
|
|||
if (q) h.podcastPlayEpisode(JSON.stringify(q), 0);
|
||||
} else if (act === 'pod-resume' && h) h.podcastPlay(Number(el.dataset.id));
|
||||
else if (act === 'pod-pause' && h) h.podcastToggle();
|
||||
else if (act === 'ab-go' && window.__dioraAudiobookGo) window.__dioraAudiobookGo(Number(el.dataset.id));
|
||||
else if (act === 'pod-here' && h && h.playerTakeOver) h.playerTakeOver();
|
||||
else if (act === 'pod-open') {
|
||||
if (window.__dioraOpenPlayer && h && parse(h.playerState(), {}).kind) window.__dioraOpenPlayer('podcast');
|
||||
|
|
|
|||
273
static/js/nav.js
273
static/js/nav.js
|
|
@ -1,273 +0,0 @@
|
|||
/*
|
||||
* The navigation (docs/navigation.md): diora's sections, sorted by what one does —
|
||||
* Home; Lesen: Bücher, Zeitschriften, Nachrichten, Blogs; Hören: Hörbücher, Podcasts,
|
||||
* Radio, Ambient. One definition for every platform: the web page's #tabs are drawn
|
||||
* here, the apps' tab bar and sidebar (native-radio.js) from DioraNav.groups() and
|
||||
* DioraNav.sections(). A section is a panel (showTab, as before) plus a view in it.
|
||||
* On a phone, Lesen and Hören show their sections as a row on top (#nav-segments).
|
||||
* Loaded before the app scripts (99-init.js restores the section through it).
|
||||
* Chrome-61-safe: no ?. / ??.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const html = document.documentElement;
|
||||
const authed = typeof IS_AUTHENTICATED === 'undefined' || !!IS_AUTHENTICATED;
|
||||
|
||||
const SECTIONS = [
|
||||
{id: 'home', group: 'home', label: 'Home', icon: '⌂', panel: 'home', account: true},
|
||||
{id: 'books', group: 'lesen', label: 'Bücher', icon: '▤', panel: 'books', view: 'books'},
|
||||
{id: 'magazines', group: 'lesen', label: 'Zeitschriften', icon: '▥', panel: 'books', view: 'magazines', account: true},
|
||||
{id: 'news', group: 'lesen', label: 'Nachrichten', icon: '☰', panel: 'feeds', view: 'news', account: true},
|
||||
{id: 'blogs', group: 'lesen', label: 'Blogs', icon: '✎', panel: 'feeds', view: 'blogs', account: true},
|
||||
{id: 'audiobooks', group: 'hoeren', label: 'Hörbücher', icon: '◐', panel: 'audiobooks', account: true, needsPodcasts: true},
|
||||
{id: 'podcasts', group: 'hoeren', label: 'Podcasts', icon: '◎', panel: 'podcasts'},
|
||||
{id: 'radio', group: 'hoeren', label: 'Radio', icon: '◉', panel: 'radio'},
|
||||
{id: 'ambient', group: 'hoeren', label: 'Ambient', icon: '∿', panel: 'ambient'},
|
||||
// The focus timer: the web page's own (the apps never had it).
|
||||
{id: 'focus', group: 'hoeren', label: 'Fokus', icon: '◷', panel: 'focus', webOnly: true},
|
||||
];
|
||||
const GROUPS = [
|
||||
{id: 'home', label: 'Home', icon: '⌂'},
|
||||
{id: 'lesen', label: 'Lesen', icon: '▤'},
|
||||
{id: 'hoeren', label: 'Hören', icon: '◉'},
|
||||
];
|
||||
const byId = {};
|
||||
SECTIONS.forEach(s => { byId[s.id] = s; });
|
||||
|
||||
const nativeUi = () => html.classList.contains('nui-radio'); // the apps' own bars (native-radio.js)
|
||||
const readerApp = () => html.classList.contains('nui-reader-app');
|
||||
const noRead = () => html.classList.contains('nui-noread'); // TV
|
||||
// Audiobooks need the podcast player: the page has them where it loads audiobooks.js
|
||||
// (diora Reader's build leaves it out). Known before the player's script has run.
|
||||
const podcastHost = () => !!document.querySelector('script[src$="/static/js/audiobooks.js"]');
|
||||
|
||||
function available(s) {
|
||||
if (!document.getElementById('tab-' + s.panel)) return false;
|
||||
if (s.account && !authed) return false;
|
||||
if (s.webOnly && nativeUi()) return false;
|
||||
if (s.needsPodcasts && !podcastHost()) return false;
|
||||
if (readerApp() && s.group !== 'lesen') return false; // diora Reader: reading only
|
||||
if (noRead() && s.group === 'lesen') return false; // TV: no reading
|
||||
return true;
|
||||
}
|
||||
|
||||
function sections() {
|
||||
return SECTIONS.filter(available).map(s => ({id: s.id, group: s.group, label: s.label, icon: s.icon}));
|
||||
}
|
||||
|
||||
function groups() {
|
||||
const list = sections();
|
||||
return GROUPS.map(g => ({id: g.id, label: g.label, icon: g.icon,
|
||||
sections: list.filter(s => s.group === g.id).map(s => s.id)})).filter(g => g.sections.length);
|
||||
}
|
||||
|
||||
function load(key) {
|
||||
try { return localStorage.getItem(key); } catch (e) { return null; }
|
||||
}
|
||||
|
||||
function save(key, value) {
|
||||
try { localStorage.setItem(key, value); } catch (e) {}
|
||||
}
|
||||
|
||||
let current = null;
|
||||
const listeners = [];
|
||||
|
||||
// A section of `group`: the one used last there, else its first.
|
||||
function lastOf(group) {
|
||||
const list = sections().filter(s => s.group === group);
|
||||
const last = load('diora_nav_last_' + group);
|
||||
const found = list.filter(s => s.id === last)[0];
|
||||
return (found || list[0] || {}).id || null;
|
||||
}
|
||||
|
||||
// What a name means: a section, a group (its last section), or a panel (its section).
|
||||
function resolve(name) {
|
||||
if (byId[name] && available(byId[name])) return name;
|
||||
if (GROUPS.some(g => g.id === name)) return lastOf(name);
|
||||
const inPanel = sections().filter(s => byId[s.id].panel === name);
|
||||
if (!inPanel.length) return null;
|
||||
const last = lastOf(byId[inPanel[0].id].group);
|
||||
return inPanel.some(s => s.id === last) ? last : inPanel[0].id;
|
||||
}
|
||||
|
||||
function applyView(s) {
|
||||
if (s.panel === 'books' && window.__dioraBooksView) window.__dioraBooksView(s.view);
|
||||
if (s.panel === 'feeds' && window.__dioraFeedsView) window.__dioraFeedsView(s.view);
|
||||
}
|
||||
|
||||
function setCurrent(id) {
|
||||
current = id;
|
||||
const s = byId[id];
|
||||
save('diora_nav_section', id);
|
||||
save('diora_nav_last_' + s.group, id);
|
||||
syncHash();
|
||||
render();
|
||||
listeners.slice().forEach(fn => { try { fn(id); } catch (e) {} });
|
||||
}
|
||||
|
||||
let going = false;
|
||||
function go(name) {
|
||||
wrapShowTab();
|
||||
const id = resolve(name);
|
||||
if (!id) return false;
|
||||
const s = byId[id];
|
||||
applyView(s); // first: the panel then shows the chosen view
|
||||
going = true;
|
||||
try { window.showTab(s.panel); } finally { going = false; } // with everything hooked onto it
|
||||
setCurrent(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Anything that shows a panel directly (Home's cards, the offline switch to the books,
|
||||
// a notification …): the section follows. Hooked on when first needed: this file comes
|
||||
// before the app's scripts, which define showTab.
|
||||
let wrapped = false;
|
||||
function wrapShowTab() {
|
||||
if (wrapped || typeof window.showTab !== 'function') return;
|
||||
wrapped = true;
|
||||
const showTab = window.showTab;
|
||||
window.showTab = function (name) {
|
||||
const r = showTab.apply(this, arguments);
|
||||
if (!going) {
|
||||
const cur = current && byId[current];
|
||||
if (!cur || cur.panel !== name) {
|
||||
const id = resolve(name);
|
||||
if (id) {
|
||||
if (byId[id].view) applyView(byId[id]);
|
||||
setCurrent(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return r;
|
||||
};
|
||||
}
|
||||
|
||||
// ---- the URL hash: #books, #news, #audiobooks … (old ones like #feeds, #radio/saved too)
|
||||
|
||||
let routing = false;
|
||||
function syncHash() {
|
||||
if (routing) return;
|
||||
let hash = current;
|
||||
if (current === 'radio') hash += '/' + (load('diora_active_radio_tab') || 'saved');
|
||||
if (location.hash.slice(1) !== hash) history.pushState(null, '', '#' + hash);
|
||||
}
|
||||
|
||||
function route() {
|
||||
const parts = (location.hash || '').replace(/^#/, '').split('/').filter(Boolean);
|
||||
if (!parts.length || !resolve(parts[0])) return false;
|
||||
routing = true;
|
||||
try {
|
||||
go(parts[0]);
|
||||
if (parts[0] === 'radio' && parts[1] && typeof showRadioTab === 'function') showRadioTab(parts[1]);
|
||||
} finally {
|
||||
routing = false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// At the start (99-init.js): the hash, else the section used last; offline the books.
|
||||
function restore(offline) {
|
||||
if (offline) { go('books'); return; }
|
||||
if (route()) return;
|
||||
const saved = load('diora_nav_section') || load('diora_active_tab') || 'radio';
|
||||
if (!go(saved)) go(sections()[0] ? sections()[0].id : 'radio');
|
||||
}
|
||||
|
||||
// ---- the web page's bar and the segment row --------------------------------------------
|
||||
|
||||
function esc(s) {
|
||||
return String(s).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c]));
|
||||
}
|
||||
|
||||
function button(id, label, cls, on) {
|
||||
return '<button type="button" class="' + cls + (on ? ' active' : '') + '" data-nav="' + id + '">' + esc(label) + '</button>';
|
||||
}
|
||||
|
||||
let tabs = null;
|
||||
let segs = null;
|
||||
function render() {
|
||||
const cur = current && byId[current];
|
||||
const gs = groups();
|
||||
if (tabs && !nativeUi()) {
|
||||
// Wide: every section under its group; narrow: the groups (their sections on top).
|
||||
tabs.innerHTML =
|
||||
'<div class="nav-wide">' + gs.map(g => g.id === 'home'
|
||||
? button('home', 'Home', 'tab-btn', current === 'home')
|
||||
: '<span class="nav-group-label">' + esc(g.label) + '</span>' +
|
||||
g.sections.map(id => button(id, byId[id].label, 'tab-btn', current === id)).join('')).join('') + '</div>' +
|
||||
'<div class="nav-narrow">' + gs.map(g =>
|
||||
button(g.id, g.label, 'tab-btn', !!cur && cur.group === g.id)).join('') + '</div>';
|
||||
}
|
||||
if (segs) {
|
||||
const g = cur ? gs.filter(x => x.id === cur.group)[0] : null;
|
||||
// Not where the bar has every section already (the sidebar, diora Reader's bar).
|
||||
const show = !!g && g.sections.length > 1 && html.classList.contains('nav-bars') &&
|
||||
!html.classList.contains('nav-flat') && !html.classList.contains('nui-sidebar');
|
||||
segs.hidden = !show;
|
||||
segs.innerHTML = show ? g.sections.map(id => button(id, byId[id].label, 'nav-seg', current === id)).join('') : '';
|
||||
}
|
||||
}
|
||||
|
||||
function setup() {
|
||||
wrapShowTab();
|
||||
tabs = document.getElementById('tabs');
|
||||
const first = document.querySelector('section.tab-panel');
|
||||
if (first) {
|
||||
segs = document.createElement('nav');
|
||||
segs.id = 'nav-segments';
|
||||
segs.hidden = true;
|
||||
first.parentNode.insertBefore(segs, first);
|
||||
segs.addEventListener('click', onClick);
|
||||
}
|
||||
if (tabs && !nativeUi() && !readerApp()) { // diora Reader brings its own (feeds.js)
|
||||
html.classList.add('nav-bars'); // the page's own bar knows the sections
|
||||
tabs.addEventListener('click', onClick);
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
function onClick(e) {
|
||||
const b = e.target.closest('[data-nav]');
|
||||
if (!b) return;
|
||||
e.preventDefault();
|
||||
go(b.dataset.nav);
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
window.DioraNav = {
|
||||
sections,
|
||||
groups,
|
||||
current: () => current,
|
||||
go,
|
||||
onChange(fn) { if (typeof fn === 'function') listeners.push(fn); },
|
||||
// For a bar drawn elsewhere (native-radio.js): it shows the sections itself.
|
||||
barsReady() { html.classList.add('nav-bars'); render(); },
|
||||
route,
|
||||
restore,
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', setup);
|
||||
else setup();
|
||||
window.addEventListener('popstate', route);
|
||||
window.addEventListener('hashchange', route);
|
||||
|
||||
// ---- styles ----------------------------------------------------------------------------
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.textContent = [
|
||||
'#tabs .nav-wide, #tabs .nav-narrow { display: flex; flex-wrap: wrap; align-items: center; gap: 2px; }',
|
||||
'#tabs .nav-narrow { display: none; }',
|
||||
'#tabs .nav-group-label { margin: 0 4px 0 14px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
|
||||
'@media (max-width: 720px) { #tabs .nav-wide { display: none; } #tabs .nav-narrow { display: flex; } }',
|
||||
'#nav-segments { display: flex; gap: 6px; overflow-x: auto; padding: 8px 0 12px; scrollbar-width: none; }',
|
||||
'#nav-segments[hidden] { display: none; }',
|
||||
'#nav-segments::-webkit-scrollbar { display: none; }',
|
||||
'.nav-seg { flex: none; min-height: 36px; padding: 0 14px; border-radius: 18px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
|
||||
'.nav-seg.active { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
||||
// Wide web page: every section is in the bar, the row would repeat it.
|
||||
'@media (min-width: 721px) { html:not(.nui-radio) #nav-segments { display: none; } }',
|
||||
// The sidebar (apps, wide): the sections are in it.
|
||||
'html.nui-sidebar #nav-segments, html.nav-flat #nav-segments { display: none; }',
|
||||
].join('\n');
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
|
|
@ -2063,8 +2063,6 @@
|
|||
if (!start || typeof showTab !== 'function') return;
|
||||
const nav = performance.getEntriesByType ? performance.getEntriesByType('navigation')[0] : null;
|
||||
if (nav && nav.type !== 'navigate') return;
|
||||
// A link to a section (#news, #audiobooks …) is where one wanted to go.
|
||||
if (window.DioraNav && location.hash.length > 1) return;
|
||||
const tab = start === 'queue' || start === 'inbox' ? 'podcasts' : start === 'radio' ? 'radio'
|
||||
: start === 'home' && document.getElementById('tab-home') ? 'home' : 'books';
|
||||
if (typeof _currentTopTab === 'undefined' || _currentTopTab !== tab) showTab(tab);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* diora service worker — caches the app shell for offline use.
|
||||
*/
|
||||
|
||||
const CACHE = 'diora-v116';
|
||||
const CACHE = 'diora-v114';
|
||||
const SHELL = [
|
||||
'/static/css/app.css',
|
||||
'/static/js/app/01-core.js',
|
||||
|
|
@ -41,7 +41,6 @@ const SHELL = [
|
|||
'/static/js/home.js',
|
||||
'/static/js/feeds.js',
|
||||
'/static/js/audiobooks.js',
|
||||
'/static/js/nav.js',
|
||||
'/static/podcasts/native-podcast.js',
|
||||
'/static/podcasts/podcast-ui.css',
|
||||
'/static/podcasts/podcasts-web.css',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// Copied from diora-android 26f5019 2026-10-16: web-native/static/native-podcast.js
|
||||
// Copied from diora-android 7b1d500 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.
|
||||
|
||||
|
|
@ -42,9 +42,6 @@
|
|||
{id: 'queue', label: 'Podcasts: Warteschlange'},
|
||||
{id: 'inbox', label: 'Podcasts: Neu'},
|
||||
{id: 'radio', label: 'Radio'},
|
||||
// Sections of the page's navigation (nav.js), where the page has them.
|
||||
window.DioraNav && DioraNav.sections().some(s => s.id === 'audiobooks') && {id: 'audiobooks', label: 'Hörbücher'},
|
||||
window.DioraNav && DioraNav.sections().some(s => s.id === 'news') && {id: 'news', label: 'Nachrichten'},
|
||||
].filter(Boolean);
|
||||
function startPage() {
|
||||
let v = null;
|
||||
|
|
@ -2880,10 +2877,9 @@
|
|||
// One player for podcasts and radio: the radio's own bar steps aside.
|
||||
document.documentElement.classList.add('nui-one-player');
|
||||
|
||||
// A "Podcasts" button in the bottom bar, between books and radio (with the page's
|
||||
// navigation, nav.js, the bar has the podcasts as a section already).
|
||||
// A "Podcasts" button in the bottom bar, between books and radio.
|
||||
const tabbar = $id('nui-tabbar');
|
||||
if (tabbar && !window.DioraNav && !tabbar.querySelector('[data-tab="podcasts"]')) {
|
||||
if (tabbar && !tabbar.querySelector('[data-tab="podcasts"]')) {
|
||||
const b = el('button');
|
||||
b.type = 'button';
|
||||
b.dataset.tab = 'podcasts';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* Copied from diora-android 26f5019 2026-10-16: the podcast parts of web-native/static/native.css
|
||||
/* Copied from diora-android 7b1d500 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. */
|
||||
|
||||
|
|
|
|||
|
|
@ -263,8 +263,6 @@
|
|||
<section class="tab-panel" id="tab-home" style="display:none;"></section>
|
||||
|
||||
<!-- ===== FEEDS TAB ===== -->
|
||||
{% comment %}Audiobooks (podcasts marked as one, docs/audiobooks.md): filled by static/js/audiobooks.js.{% endcomment %}
|
||||
{% if user.is_authenticated %}<section class="tab-panel" id="tab-audiobooks" style="display:none;"></section>{% endif %}
|
||||
{% comment %}The feed reader, news and blogs: filled by static/js/feeds.js.{% endcomment %}
|
||||
<section class="tab-panel" id="tab-feeds" style="display:none;"></section>
|
||||
|
||||
|
|
@ -350,7 +348,6 @@
|
|||
let USER_FOCUS_STATION = {{ focus_station_json|safe }};
|
||||
</script>
|
||||
{# app.js, split by area (docs/refactoring.md): classic scripts sharing one global scope, in this order. #}
|
||||
<script src="/static/js/nav.js"></script>
|
||||
<script src="/static/js/app/01-core.js"></script>
|
||||
<script src="/static/js/app/10-radio.js"></script>
|
||||
<script src="/static/js/app/14-focus.js"></script>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue