Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
/*
|
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
|
|
|
|
* 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 —
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
* "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
|
|
|
|
|
|
* order, outside the queue). Runs in the web page, the desktop app and the Android app
|
|
|
|
|
|
* (not in diora Reader, which has no podcasts). Chrome-61-safe: no ?. / ??.
|
|
|
|
|
|
*/
|
|
|
|
|
|
(function () {
|
|
|
|
|
|
'use strict';
|
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
|
|
|
|
const tab = document.getElementById('tab-audiobooks');
|
|
|
|
|
|
if (!tab) return;
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
if (typeof IS_AUTHENTICATED !== 'undefined' && !IS_AUTHENTICATED) return;
|
|
|
|
|
|
|
|
|
|
|
|
// The podcast host: the app's own on Android, podcasts-web.js elsewhere.
|
|
|
|
|
|
function host() {
|
|
|
|
|
|
const n = window.DioraNative;
|
|
|
|
|
|
if (n && typeof n.podcastPlayBook === 'function') return n;
|
|
|
|
|
|
const h = window.DioraPodcastHost;
|
|
|
|
|
|
return h && typeof h.podcastPlayBook === 'function' ? h : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const st = {
|
|
|
|
|
|
books: null, // /podcasts/audiobooks/ books
|
|
|
|
|
|
open: null, // feed id of the book shown
|
|
|
|
|
|
detail: null, // /podcasts/audiobooks/<id>/
|
|
|
|
|
|
picking: false, // choosing a podcast to make an audiobook
|
|
|
|
|
|
pickQuery: '',
|
|
|
|
|
|
now: null, // playerState() of the last poll
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const shelf = document.createElement('div');
|
|
|
|
|
|
shelf.id = 'audiobook-shelf';
|
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
|
|
|
|
tab.appendChild(shelf);
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
|
|
|
|
|
|
// ---- helpers ------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function esc(s) {
|
|
|
|
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
|
|
|
|
|
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function hours(seconds) {
|
|
|
|
|
|
const m = Math.round(Math.max(0, seconds) / 60);
|
|
|
|
|
|
if (m < 60) return m + ' min';
|
|
|
|
|
|
const h = Math.floor(m / 60);
|
|
|
|
|
|
return h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clock(ms) {
|
|
|
|
|
|
const s = Math.max(0, Math.floor(ms / 1000));
|
|
|
|
|
|
const h = Math.floor(s / 3600);
|
|
|
|
|
|
const mm = String(Math.floor(s / 60) % 60);
|
|
|
|
|
|
const ss = String(s % 60).padStart(2, '0');
|
|
|
|
|
|
return h ? h + ':' + mm.padStart(2, '0') + ':' + ss : mm + ':' + ss;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function progressText(b) {
|
|
|
|
|
|
const parts = [];
|
|
|
|
|
|
if (b.total_seconds > 0) parts.push(hours(b.heard_seconds) + ' von ' + hours(b.total_seconds));
|
|
|
|
|
|
if (!b.next) parts.push('fertig gehört');
|
|
|
|
|
|
else parts.push('Kapitel ' + (b.next.index + 1) + '/' + b.chapters);
|
|
|
|
|
|
return parts.join(' · ');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pct(b) {
|
|
|
|
|
|
if (b.total_seconds > 0) return Math.min(100, Math.round(100 * b.heard_seconds / b.total_seconds));
|
|
|
|
|
|
return b.chapters ? Math.round(100 * b.done / b.chapters) : 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cover(url, title, cls) {
|
|
|
|
|
|
const art = String(url || '').replace(/^http:/, 'https:');
|
|
|
|
|
|
return art
|
|
|
|
|
|
? '<img class="' + cls + '" src="' + esc(art) + '" alt="" loading="lazy">'
|
|
|
|
|
|
: '<span class="' + cls + ' ab-nocover">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function toast(text) {
|
|
|
|
|
|
let t = document.getElementById('ab-toast');
|
|
|
|
|
|
if (!t) {
|
|
|
|
|
|
t = document.createElement('div');
|
|
|
|
|
|
t.id = 'ab-toast';
|
|
|
|
|
|
document.body.appendChild(t);
|
|
|
|
|
|
}
|
|
|
|
|
|
t.textContent = text;
|
|
|
|
|
|
t.classList.add('ab-show');
|
|
|
|
|
|
clearTimeout(toast.timer);
|
|
|
|
|
|
toast.timer = setTimeout(() => t.classList.remove('ab-show'), 2600);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function cached(name, value) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (value === undefined) return JSON.parse(localStorage.getItem('diora_ab_' + name) || 'null');
|
|
|
|
|
|
localStorage.setItem('diora_ab_' + name, JSON.stringify(value));
|
|
|
|
|
|
} catch (e) {}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function api(path, body) {
|
|
|
|
|
|
const opts = {credentials: 'same-origin', cache: 'no-store'};
|
|
|
|
|
|
if (body !== undefined) {
|
|
|
|
|
|
opts.method = 'POST';
|
|
|
|
|
|
opts.headers = {'Content-Type': 'application/json'};
|
|
|
|
|
|
opts.body = JSON.stringify(body);
|
|
|
|
|
|
}
|
|
|
|
|
|
const r = await fetch(path, opts);
|
|
|
|
|
|
const d = await r.json().catch(() => null);
|
|
|
|
|
|
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
|
|
|
|
return d;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function podcastState() {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
if (!h) return null;
|
|
|
|
|
|
try { return JSON.parse(h.podcastState()); } catch (e) { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function playerState() {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
if (!h || typeof h.playerState !== 'function') return null;
|
|
|
|
|
|
try { return JSON.parse(h.playerState()); } catch (e) { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The chapter of the open book playing (or paused) now, else null.
|
|
|
|
|
|
function nowInBook() {
|
|
|
|
|
|
const p = st.now;
|
|
|
|
|
|
const d = st.detail;
|
|
|
|
|
|
if (!p || !d || p.kind !== 'podcast') return null;
|
|
|
|
|
|
for (let i = 0; i < d.chapters.length; i++) {
|
|
|
|
|
|
if (d.chapters[i].episode__id === p.episodeId) return {index: i, pos: p.pos || 0, dur: p.dur || 0, playing: !!p.playing};
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- loading ------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
async function loadList() {
|
|
|
|
|
|
if (!host()) { shelf.innerHTML = ''; return; }
|
|
|
|
|
|
if (!st.books) { st.books = cached('books'); renderShelf(); }
|
|
|
|
|
|
try {
|
|
|
|
|
|
st.books = (await api('/podcasts/audiobooks/')).books || [];
|
|
|
|
|
|
cached('books', st.books);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!st.books) st.books = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
renderShelf();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadDetail(feedId) {
|
|
|
|
|
|
if (!st.detail || st.detail.book.feed_id !== feedId) st.detail = cached('book_' + feedId);
|
|
|
|
|
|
renderView();
|
|
|
|
|
|
try {
|
|
|
|
|
|
const d = await api('/podcasts/audiobooks/' + feedId + '/');
|
|
|
|
|
|
if (st.open !== feedId) return d;
|
|
|
|
|
|
st.detail = d;
|
|
|
|
|
|
cached('book_' + feedId, d);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (!st.detail) toast('Das Hörbuch braucht eine Verbindung.');
|
|
|
|
|
|
}
|
|
|
|
|
|
renderView();
|
|
|
|
|
|
return st.detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- the shelf in "Bücher" ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
function renderShelf() {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
const books = st.books || [];
|
|
|
|
|
|
const state = h ? podcastState() : null;
|
|
|
|
|
|
const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : [];
|
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
let html = '';
|
|
|
|
|
|
const go = books.filter(b => b.next && b.last_played_at)[0];
|
|
|
|
|
|
if (go) {
|
|
|
|
|
|
html += '<div class="ab-continue" data-ab-open="' + go.feed_id + '">' +
|
|
|
|
|
|
cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') +
|
|
|
|
|
|
'<div class="ab-card-text"><div class="ab-label">Weiterhören</div>' +
|
|
|
|
|
|
'<div class="ab-title">' + esc(go.title) + '</div>' +
|
|
|
|
|
|
'<div class="ab-meta">' + esc(progressText(go)) + '</div>' +
|
|
|
|
|
|
'<div class="ab-bar"><span style="width:' + pct(go) + '%"></span></div></div>' +
|
|
|
|
|
|
'<button type="button" class="ab-play" data-ab-go="' + go.feed_id + '" aria-label="Weiterhören">▶</button></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
html += '<div class="ab-head"><h3 class="ab-h">Hörbücher</h3>' +
|
|
|
|
|
|
(pods.length ? '<button type="button" class="ab-link" data-ab="pick">+ Hörbuch</button>' : '') + '</div>';
|
|
|
|
|
|
if (books.length) {
|
|
|
|
|
|
html += '<div class="ab-grid">' + books.map(b =>
|
|
|
|
|
|
'<button type="button" class="ab-card" data-ab-open="' + b.feed_id + '">' +
|
|
|
|
|
|
cover(b.artwork_url, b.title, 'ab-cover') +
|
|
|
|
|
|
'<span class="ab-card-text"><span class="ab-title">' + esc(b.title) + '</span>' +
|
|
|
|
|
|
(b.author ? '<span class="ab-meta">' + esc(b.author) + '</span>' : '') +
|
|
|
|
|
|
'<span class="ab-meta">' + esc(progressText(b)) + '</span>' +
|
|
|
|
|
|
'<span class="ab-bar"><span style="width:' + pct(b) + '%"></span></span></span></button>').join('') + '</div>';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
html += '<p class="ab-empty">Podcasts, die eigentlich Hörbücher oder Hörspiele sind, lassen sich hier ' +
|
|
|
|
|
|
'wie ein Buch hören: in Kapitelreihenfolge, mit Lesezeichen. „+ Hörbuch“ wählt einen aus.</p>';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (st.picking) html += pickHtml(pods);
|
|
|
|
|
|
shelf.innerHTML = html;
|
|
|
|
|
|
const input = shelf.querySelector('.ab-pick-q');
|
|
|
|
|
|
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function pickHtml(pods) {
|
|
|
|
|
|
const q = st.pickQuery.trim().toLowerCase();
|
|
|
|
|
|
const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0)
|
|
|
|
|
|
.sort((a, b) => a.title.localeCompare(b.title)).slice(0, 60);
|
|
|
|
|
|
return '<div class="ab-pick"><div class="ab-pick-head"><strong>Welcher Podcast ist ein Hörbuch?</strong>' +
|
|
|
|
|
|
'<button type="button" class="ab-icon" data-ab="pick-close" aria-label="Schließen">✕</button></div>' +
|
|
|
|
|
|
'<input type="search" class="ab-pick-q" placeholder="Abo suchen …" value="' + esc(st.pickQuery) + '">' +
|
|
|
|
|
|
'<div class="ab-pick-list">' + found.map(f =>
|
|
|
|
|
|
'<button type="button" class="ab-row" data-ab-mark="' + f.id + '">' + cover(f.artwork_url, f.title, 'ab-cover ab-cover-xs') +
|
|
|
|
|
|
'<span class="ab-card-text"><span class="ab-title">' + esc(f.title) + '</span>' +
|
|
|
|
|
|
(f.author ? '<span class="ab-meta">' + esc(f.author) + '</span>' : '') + '</span></button>').join('') +
|
|
|
|
|
|
(found.length ? '' : '<p class="ab-empty">Kein Abo gefunden.</p>') + '</div></div>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setKind(feedId, kind) {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
if (!h) return;
|
|
|
|
|
|
h.podcastFeedSettings(feedId, JSON.stringify({kind}));
|
|
|
|
|
|
// The host sends it on its own; the books are asked again once the server has it.
|
|
|
|
|
|
setTimeout(loadList, 900);
|
|
|
|
|
|
setTimeout(loadList, 3500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- the book --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let view = null;
|
|
|
|
|
|
|
|
|
|
|
|
function openBook(feedId) {
|
|
|
|
|
|
st.open = feedId;
|
|
|
|
|
|
if (!view) {
|
|
|
|
|
|
view = document.createElement('div');
|
|
|
|
|
|
view.id = 'ab-view';
|
|
|
|
|
|
view.addEventListener('click', onViewClick);
|
|
|
|
|
|
document.body.appendChild(view);
|
|
|
|
|
|
}
|
|
|
|
|
|
view.style.display = '';
|
|
|
|
|
|
document.documentElement.classList.add('ab-open');
|
|
|
|
|
|
view.scrollTop = 0;
|
|
|
|
|
|
poll();
|
|
|
|
|
|
loadDetail(feedId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeBook() {
|
|
|
|
|
|
st.open = null;
|
|
|
|
|
|
if (view) view.style.display = 'none';
|
|
|
|
|
|
document.documentElement.classList.remove('ab-open');
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderView() {
|
|
|
|
|
|
if (!view || st.open == null) return;
|
|
|
|
|
|
const d = st.detail && st.detail.book.feed_id === st.open ? st.detail : null;
|
|
|
|
|
|
const fromList = (st.books || []).filter(b => b.feed_id === st.open)[0];
|
|
|
|
|
|
const b = d ? d.book : fromList;
|
|
|
|
|
|
if (!b) { view.innerHTML = '<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button></div><p class="ab-empty">Wird geladen …</p>'; return; }
|
|
|
|
|
|
const now = nowInBook();
|
|
|
|
|
|
const marks = d ? d.bookmarks : [];
|
|
|
|
|
|
const titleOf = id => {
|
|
|
|
|
|
const ch = d ? d.chapters.filter(c => c.episode__id === id)[0] : null;
|
|
|
|
|
|
return ch ? 'Kapitel ' + (ch.index + 1) : '';
|
|
|
|
|
|
};
|
|
|
|
|
|
view.innerHTML =
|
|
|
|
|
|
'<div class="ab-top"><button type="button" class="ab-back" data-ab="close">‹ Bücher</button>' +
|
|
|
|
|
|
'<button type="button" class="ab-icon" data-ab="menu" aria-label="Optionen">⋮</button></div>' +
|
|
|
|
|
|
'<div class="ab-menu" hidden>' +
|
|
|
|
|
|
'<button type="button" data-ab="unplayed">Alles als ungehört markieren</button>' +
|
|
|
|
|
|
'<button type="button" data-ab="played">Alles als gehört markieren</button>' +
|
|
|
|
|
|
'<button type="button" data-ab="podcast">Wieder als Podcast führen</button></div>' +
|
|
|
|
|
|
'<div class="ab-hero">' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') +
|
|
|
|
|
|
'<div class="ab-hero-text"><h2 class="ab-book-title">' + esc(b.title) + '</h2>' +
|
|
|
|
|
|
(b.author ? '<div class="ab-meta">' + esc(b.author) + '</div>' : '') +
|
|
|
|
|
|
'<div class="ab-meta ab-progress">' + esc(progressText(b)) + '</div>' +
|
|
|
|
|
|
'<div class="ab-bar"><span style="width:' + pct(b) + '%"></span></div></div></div>' +
|
|
|
|
|
|
'<div class="ab-actions">' +
|
|
|
|
|
|
'<button type="button" class="ab-btn ab-primary" data-ab="go">' + goLabel(b, now) + '</button>' +
|
|
|
|
|
|
'<button type="button" class="ab-btn" data-ab="sleep"' + (now ? '' : ' disabled') + '>☾ Kapitelende</button>' +
|
|
|
|
|
|
'<button type="button" class="ab-btn" data-ab="mark"' + (now ? '' : ' disabled') + '>+ Lesezeichen</button></div>' +
|
|
|
|
|
|
(marks.length ? '<h3 class="ab-h">Lesezeichen</h3><div class="ab-marks">' + marks.map(m =>
|
|
|
|
|
|
'<div class="ab-mark"><button type="button" class="ab-row" data-ab-bm="' + m.id + '">' +
|
|
|
|
|
|
'<span class="ab-card-text"><span class="ab-meta">' + esc(titleOf(m.episode_id)) + ' · ' + clock(m.position_ms) + '</span>' +
|
|
|
|
|
|
'<span class="ab-title">' + (m.note ? esc(m.note) : '<span class="ab-meta">ohne Notiz</span>') + '</span></span></button>' +
|
|
|
|
|
|
'<button type="button" class="ab-icon" data-ab-bm-edit="' + m.id + '" aria-label="Notiz ändern">✎</button>' +
|
|
|
|
|
|
'<button type="button" class="ab-icon" data-ab-bm-del="' + m.id + '" aria-label="Löschen">✕</button></div>').join('') + '</div>' : '') +
|
|
|
|
|
|
'<h3 class="ab-h">Kapitel</h3>' +
|
|
|
|
|
|
(d ? '<div class="ab-chapters">' + d.chapters.map(c => chapterHtml(c, now)).join('') + '</div>'
|
|
|
|
|
|
: '<p class="ab-empty">Wird geladen …</p>');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function goLabel(b, now) {
|
|
|
|
|
|
if (now && now.playing) return '❚❚ Pause';
|
|
|
|
|
|
if (!b.next) return '↺ Von vorn';
|
|
|
|
|
|
return '▶ ' + (b.done || b.heard_seconds > 0 ? 'Weiterhören' : 'Anfangen');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function chapterHtml(c, now) {
|
|
|
|
|
|
const playing = now && now.index === c.index;
|
|
|
|
|
|
const dur = c.episode__duration_seconds || 0;
|
|
|
|
|
|
let meta;
|
|
|
|
|
|
if (c.played) meta = '✓ gehört';
|
|
|
|
|
|
else if (playing && now.dur) meta = clock(now.pos) + ' / ' + clock(now.dur);
|
|
|
|
|
|
else if (c.position_seconds > 0 && dur) meta = 'noch ' + hours(dur - c.position_seconds);
|
|
|
|
|
|
else meta = dur ? hours(dur) : '';
|
2026-10-16 14:08:44 +02:00
|
|
|
|
return '<div class="ab-chrow"><button type="button" class="ab-row ab-chapter' + (c.played ? ' ab-done' : '') + (playing ? ' ab-now' : '') +
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
'" data-ab-ch="' + c.index + '"><span class="ab-num">' + (playing ? '♪' : (c.index + 1)) + '</span>' +
|
|
|
|
|
|
'<span class="ab-card-text"><span class="ab-title">' + esc(c.episode__title) + '</span>' +
|
2026-10-16 14:08:44 +02:00
|
|
|
|
'<span class="ab-meta">' + esc(meta) + '</span></span></button>' +
|
|
|
|
|
|
'<button type="button" class="ab-icon ab-tick' + (c.played ? ' ab-ticked' : '') + '" data-ab-played="' + c.index + '" ' +
|
|
|
|
|
|
'aria-label="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '" ' +
|
|
|
|
|
|
'title="' + (c.played ? 'Als nicht gehört markieren' : 'Als gehört markieren') + '">' + (c.played ? '✓' : '○') + '</button></div>';
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Play the open book's chapter `index` (from its own place, or from `ms`).
|
|
|
|
|
|
function play(index, ms) {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
const d = st.detail;
|
|
|
|
|
|
if (!h || !d || !d.chapters[index]) return;
|
|
|
|
|
|
const ch = d.chapters[index];
|
|
|
|
|
|
const body = {feed_id: d.book.feed_id, chapters: d.chapters, index};
|
2026-10-16 14:08:44 +02:00
|
|
|
|
const dur = ch.episode__duration_seconds || 0;
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
if (ms != null) body.position_ms = ms;
|
2026-10-16 14:08:44 +02:00
|
|
|
|
else if (ch.played || (dur && ch.position_seconds >= dur - 30)) body.position_ms = 0; // heard already: from its start
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
h.podcastPlayBook(JSON.stringify(body));
|
|
|
|
|
|
setTimeout(poll, 400);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function goOn(feedId) {
|
|
|
|
|
|
if (st.open !== feedId || !st.detail || st.detail.book.feed_id !== feedId) {
|
|
|
|
|
|
st.open = feedId;
|
|
|
|
|
|
await loadDetail(feedId);
|
|
|
|
|
|
if (!view || view.style.display === 'none') st.open = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
const d = st.detail;
|
|
|
|
|
|
if (!d || d.book.feed_id !== feedId) return;
|
|
|
|
|
|
const b = d.book;
|
|
|
|
|
|
play(b.next ? b.next.index : 0, b.next ? undefined : 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function togglePlay() {
|
|
|
|
|
|
const now = nowInBook();
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
if (now && now.playing && h) {
|
|
|
|
|
|
if (typeof h.podcastToggle === 'function') h.podcastToggle();
|
|
|
|
|
|
else if (typeof h.podcastPause === 'function') h.podcastPause();
|
|
|
|
|
|
setTimeout(poll, 300);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (now && h) { play(now.index, now.pos); return; }
|
|
|
|
|
|
goOn(st.open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The sleep timer to the end of the chapter: the episode's own chapter mark if it has
|
|
|
|
|
|
// some (a long file), else the end of the episode.
|
|
|
|
|
|
async function sleepAtChapterEnd() {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
const now = nowInBook();
|
|
|
|
|
|
if (!h || !now) return;
|
|
|
|
|
|
const id = st.detail.chapters[now.index].episode__id;
|
|
|
|
|
|
let marks = [];
|
|
|
|
|
|
try { marks = (await api('/podcasts/episodes/' + id + '/')).chapters || []; } catch (e) {}
|
|
|
|
|
|
const pos = (playerState() || {}).pos || now.pos;
|
|
|
|
|
|
const next = marks.map(m => m.start * 1000).filter(s => s > pos + 1000).sort((a, b) => a - b)[0];
|
|
|
|
|
|
if (next) {
|
|
|
|
|
|
const speed = (st.now && st.now.speed) || 1;
|
|
|
|
|
|
h.podcastSleep((next - pos) / speed / 60000);
|
|
|
|
|
|
toast('Schlummer: in ' + clock((next - pos) / speed) + ', am Kapitelende');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
h.podcastSleep(-1);
|
|
|
|
|
|
toast('Schlummer: am Ende dieses Kapitels');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function addBookmark() {
|
|
|
|
|
|
const now = nowInBook();
|
|
|
|
|
|
if (!now) return;
|
|
|
|
|
|
const pos = (playerState() || {}).pos || now.pos;
|
|
|
|
|
|
const ch = st.detail.chapters[now.index];
|
|
|
|
|
|
const note = typeof customPrompt === 'function'
|
|
|
|
|
|
? await customPrompt('Lesezeichen bei Kapitel ' + (now.index + 1) + ', ' + clock(pos) + ' – Notiz (optional):', '')
|
|
|
|
|
|
: window.prompt('Notiz (optional):', '');
|
|
|
|
|
|
if (note === null || note === undefined) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api('/podcasts/bookmarks/', {episode_id: ch.episode__id, position_ms: Math.round(pos), note: String(note)});
|
|
|
|
|
|
toast('Lesezeichen gesetzt');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
toast('Lesezeichen brauchen eine Verbindung.');
|
|
|
|
|
|
}
|
|
|
|
|
|
loadDetail(st.open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function editBookmark(id) {
|
|
|
|
|
|
const m = st.detail.bookmarks.filter(x => x.id === id)[0];
|
|
|
|
|
|
if (!m) return;
|
|
|
|
|
|
const note = typeof customPrompt === 'function' ? await customPrompt('Notiz:', m.note) : window.prompt('Notiz:', m.note);
|
|
|
|
|
|
if (note === null || note === undefined) return;
|
|
|
|
|
|
try { await api('/podcasts/bookmarks/' + id + '/', {note: String(note)}); } catch (e) { toast('Braucht eine Verbindung.'); }
|
|
|
|
|
|
loadDetail(st.open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteBookmark(id) {
|
|
|
|
|
|
const ok = typeof customConfirm === 'function' ? await customConfirm('Lesezeichen löschen?') : window.confirm('Lesezeichen löschen?');
|
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
|
try { await api('/podcasts/bookmarks/' + id + '/delete/', {}); } catch (e) { toast('Braucht eine Verbindung.'); }
|
|
|
|
|
|
loadDetail(st.open);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function playBookmark(id) {
|
|
|
|
|
|
const m = st.detail.bookmarks.filter(x => x.id === id)[0];
|
|
|
|
|
|
if (!m) return;
|
|
|
|
|
|
const ch = st.detail.chapters.filter(c => c.episode__id === m.episode_id)[0];
|
|
|
|
|
|
if (ch) play(ch.index, m.position_ms);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-16 14:08:44 +02:00
|
|
|
|
// One chapter heard or not: at once here, the host tells the server.
|
|
|
|
|
|
function markChapter(index) {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
const d = st.detail;
|
|
|
|
|
|
const ch = d && d.chapters[index];
|
|
|
|
|
|
if (!h || !ch) return;
|
|
|
|
|
|
ch.played = !ch.played;
|
|
|
|
|
|
const dur = ch.episode__duration_seconds || 0;
|
|
|
|
|
|
if (!ch.played && dur && ch.position_seconds >= dur - 30) ch.position_seconds = 0; // from the start again
|
|
|
|
|
|
h.podcastMarkPlayedAll(JSON.stringify([ch.episode__id]), ch.played);
|
|
|
|
|
|
renderView();
|
|
|
|
|
|
setTimeout(() => loadDetail(st.open), 1500);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
function markAll(played) {
|
|
|
|
|
|
const h = host();
|
|
|
|
|
|
const d = st.detail;
|
|
|
|
|
|
if (!h || !d) return;
|
|
|
|
|
|
h.podcastMarkPlayedAll(JSON.stringify(d.chapters.map(c => c.episode__id)), !!played);
|
|
|
|
|
|
setTimeout(() => loadDetail(st.open), 900);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function onViewClick(e) {
|
|
|
|
|
|
const t = e.target.closest('button');
|
|
|
|
|
|
const menu = view.querySelector('.ab-menu');
|
|
|
|
|
|
if (menu && (!t || t.dataset.ab !== 'menu')) menu.hidden = true;
|
|
|
|
|
|
if (!t) return;
|
|
|
|
|
|
const act = t.dataset.ab;
|
|
|
|
|
|
if (act === 'close') closeBook();
|
|
|
|
|
|
else if (act === 'menu') { if (menu) menu.hidden = !menu.hidden; }
|
|
|
|
|
|
else if (act === 'go') togglePlay();
|
|
|
|
|
|
else if (act === 'sleep') sleepAtChapterEnd();
|
|
|
|
|
|
else if (act === 'mark') addBookmark();
|
|
|
|
|
|
else if (act === 'unplayed') markAll(false);
|
|
|
|
|
|
else if (act === 'played') markAll(true);
|
|
|
|
|
|
else if (act === 'podcast') {
|
|
|
|
|
|
const ok = typeof customConfirm === 'function'
|
|
|
|
|
|
? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.')
|
|
|
|
|
|
: true;
|
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
|
setKind(st.open, 'podcast');
|
|
|
|
|
|
closeBook();
|
|
|
|
|
|
}
|
2026-10-16 14:08:44 +02:00
|
|
|
|
else if (t.dataset.abPlayed != null) markChapter(Number(t.dataset.abPlayed));
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
else if (t.dataset.abCh != null) play(Number(t.dataset.abCh));
|
|
|
|
|
|
else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm));
|
|
|
|
|
|
else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit));
|
|
|
|
|
|
else if (t.dataset.abBmDel != null) deleteBookmark(Number(t.dataset.abBmDel));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
shelf.addEventListener('click', e => {
|
|
|
|
|
|
const t = e.target.closest('button, .ab-continue');
|
|
|
|
|
|
if (!t) return;
|
|
|
|
|
|
if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; }
|
|
|
|
|
|
if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; }
|
|
|
|
|
|
if (t.dataset.abMark != null) {
|
|
|
|
|
|
const id = Number(t.dataset.abMark);
|
|
|
|
|
|
st.picking = false;
|
|
|
|
|
|
st.pickQuery = '';
|
|
|
|
|
|
// At once on the shelf, before the server has it: a card that fills in when it answers.
|
|
|
|
|
|
const f = ((podcastState() || {}).feeds || []).filter(x => x.id === id)[0];
|
|
|
|
|
|
if (f) {
|
|
|
|
|
|
st.books = [{feed_id: id, title: f.title, author: f.author || '', artwork_url: f.artwork_url || '',
|
|
|
|
|
|
chapters: f.episode_count || 0, done: 0, total_seconds: 0, heard_seconds: 0, next: null, last_played_at: null}]
|
|
|
|
|
|
.concat((st.books || []).filter(b => b.feed_id !== id));
|
|
|
|
|
|
}
|
|
|
|
|
|
setKind(id, 'audiobook');
|
|
|
|
|
|
renderShelf();
|
|
|
|
|
|
toast('„' + (f ? f.title : 'Podcast') + '“ ist jetzt ein Hörbuch');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; }
|
|
|
|
|
|
if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); }
|
|
|
|
|
|
});
|
|
|
|
|
|
shelf.addEventListener('input', e => {
|
|
|
|
|
|
if (!e.target.classList.contains('ab-pick-q')) return;
|
|
|
|
|
|
st.pickQuery = e.target.value;
|
|
|
|
|
|
const box = shelf.querySelector('.ab-pick-list');
|
|
|
|
|
|
const pods = ((podcastState() || {}).feeds || []).filter(f => f.kind !== 'audiobook');
|
|
|
|
|
|
const tmp = document.createElement('div');
|
|
|
|
|
|
tmp.innerHTML = pickHtml(pods);
|
|
|
|
|
|
const fresh = tmp.querySelector('.ab-pick-list');
|
|
|
|
|
|
if (box && fresh) box.innerHTML = fresh.innerHTML;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-16 14:42:20 +02:00
|
|
|
|
// Home's "Weiterhören" card (home.js) plays the book from where it was left.
|
|
|
|
|
|
window.__dioraAudiobookGo = feedId => goOn(feedId);
|
|
|
|
|
|
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
// ---- following the player ------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
let pollTimer = 0;
|
|
|
|
|
|
let lastEpisode = null;
|
|
|
|
|
|
function poll() {
|
|
|
|
|
|
clearTimeout(pollTimer);
|
|
|
|
|
|
const p = playerState();
|
|
|
|
|
|
st.now = p;
|
|
|
|
|
|
const ep = p && p.kind === 'podcast' ? p.episodeId : null;
|
|
|
|
|
|
if (st.open != null && view && view.style.display !== 'none') {
|
|
|
|
|
|
if (ep !== lastEpisode && lastEpisode != null) loadDetail(st.open); // a chapter ended: progress moved
|
|
|
|
|
|
else renderView();
|
|
|
|
|
|
pollTimer = setTimeout(poll, 1000);
|
|
|
|
|
|
} else if (ep !== lastEpisode && lastEpisode != null && tab.style.display !== 'none') {
|
|
|
|
|
|
loadList();
|
|
|
|
|
|
}
|
|
|
|
|
|
lastEpisode = ep;
|
|
|
|
|
|
if (st.open == null) pollTimer = setTimeout(poll, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ---- when to look ------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const showTab = window.showTab;
|
|
|
|
|
|
if (typeof showTab === 'function') {
|
|
|
|
|
|
window.showTab = function (name) {
|
|
|
|
|
|
const r = showTab.apply(this, arguments);
|
Navigation: Lesen und Hören (Phase 1, noch nicht veröffentlicht)
Die Bereiche nach dem, was man tut (docs/navigation.md): Home; Lesen mit
Bücher, Zeitschriften, Nachrichten, Blogs; Hören mit Hörbücher, Podcasts,
Radio, Ambient. static/js/nav.js ist die eine Definition für alle Plattformen
(DioraNav: Abschnitte, Gruppen, go, Hash, zuletzt gewählter Abschnitt je
Gruppe). Die Webseite zeichnet ihre Leiste daraus: breit alle Abschnitte unter
ihrer Gruppe, schmal Home · Lesen · Hören mit einer Abschnittszeile oben.
- Hörbücher haben ein eigenes Panel (#tab-audiobooks), nicht mehr über den E-Books.
- Zeitschriften: die Bücher im Ordner „Zeitschriften“ (oder „Magazine“).
- Nachrichten/Blogs wählt die Navigation; der eigene Umschalter verschwindet dann.
- Ein Link auf einen Abschnitt (#news …) schlägt die Startseite.
Die Apps zeichnen ihre Leiste (native-radio.js, diora-android) bald ebenfalls
daraus; bis dahin bleibt das hier unveröffentlicht.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:31:58 +02:00
|
|
|
|
if (name === 'audiobooks') loadList();
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
else if (st.open != null) closeBook();
|
|
|
|
|
|
return r;
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const back = window.__dioraBack;
|
|
|
|
|
|
window.__dioraBack = function () {
|
|
|
|
|
|
if (st.open != null) {
|
|
|
|
|
|
const menu = view && view.querySelector('.ab-menu');
|
|
|
|
|
|
if (menu && !menu.hidden) { menu.hidden = true; return true; }
|
|
|
|
|
|
closeBook();
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; }
|
|
|
|
|
|
return back ? back() : false;
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener('keydown', e => {
|
|
|
|
|
|
if (e.key === 'Escape' && st.open != null) closeBook();
|
|
|
|
|
|
});
|
|
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
|
|
|
|
if (!document.hidden && tab.style.display !== 'none') loadList();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// The podcast host fills its state a moment after the page; the shelf needs its abos.
|
|
|
|
|
|
setTimeout(loadList, 300);
|
|
|
|
|
|
setTimeout(poll, 1500);
|
|
|
|
|
|
|
|
|
|
|
|
// ---- styles ----------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
const style = document.createElement('style');
|
|
|
|
|
|
style.textContent = [
|
|
|
|
|
|
'#audiobook-shelf { margin: 0 0 14px; }',
|
|
|
|
|
|
'#audiobook-shelf:empty { display: none; }',
|
|
|
|
|
|
'.ab-h { margin: 18px 0 8px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }',
|
|
|
|
|
|
'.ab-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }',
|
|
|
|
|
|
'.ab-link { background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 14px; padding: 8px 0; cursor: pointer; }',
|
|
|
|
|
|
'.ab-empty { color: var(--muted, #9a9a9a); font-size: 13px; line-height: 1.45; margin: 6px 0; }',
|
|
|
|
|
|
'.ab-meta { display: block; color: var(--muted, #9a9a9a); font-size: 12px; line-height: 1.35; }',
|
|
|
|
|
|
'.ab-title { display: block; font-size: 14px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; }',
|
|
|
|
|
|
'.ab-label { color: var(--accent, #e63946); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }',
|
|
|
|
|
|
'.ab-bar { display: block; height: 3px; border-radius: 2px; background: var(--border, #2a2a2a); margin-top: 6px; overflow: hidden; }',
|
|
|
|
|
|
'.ab-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }',
|
|
|
|
|
|
'.ab-cover { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 6px; background: var(--panel, #161616); display: block; }',
|
|
|
|
|
|
'.ab-nocover { display: flex; align-items: center; justify-content: center; font-size: 28px; color: var(--muted, #9a9a9a); }',
|
|
|
|
|
|
'.ab-cover-s { width: 56px; height: 56px; flex: none; }',
|
|
|
|
|
|
'.ab-cover-xs { width: 40px; height: 40px; flex: none; font-size: 16px; }',
|
|
|
|
|
|
'.ab-cover-l { width: 120px; height: 120px; flex: none; }',
|
|
|
|
|
|
'.ab-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; }',
|
|
|
|
|
|
'.ab-continue { display: flex; align-items: center; gap: 12px; padding: 10px; margin: 4px 0 6px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); cursor: pointer; }',
|
|
|
|
|
|
'.ab-play { flex: none; width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 18px; cursor: pointer; }',
|
|
|
|
|
|
'.ab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }',
|
|
|
|
|
|
'.ab-card { display: flex; flex-direction: column; gap: 6px; padding: 0; background: none; border: none; color: inherit; font: inherit; cursor: pointer; text-align: left; min-width: 0; }',
|
|
|
|
|
|
'.ab-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; cursor: pointer; text-align: left; }',
|
|
|
|
|
|
'.ab-icon { background: none; border: none; color: inherit; font: inherit; font-size: 18px; min-width: 40px; min-height: 40px; cursor: pointer; }',
|
|
|
|
|
|
'.ab-pick { margin-top: 10px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; padding: 10px 12px; background: var(--card, #0b0b0b); }',
|
|
|
|
|
|
'.ab-pick-head { display: flex; align-items: center; justify-content: space-between; }',
|
|
|
|
|
|
'.ab-pick-q { width: 100%; box-sizing: border-box; margin: 6px 0; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
|
|
|
|
|
|
'.ab-pick-list { max-height: 50vh; overflow-y: auto; }',
|
|
|
|
|
|
// Above the tab bar (150), below the page's dialogs (990): a note is asked for on top of it.
|
|
|
|
|
|
'#ab-view { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; overflow-y: auto; background: var(--bg, #000); color: var(--fg, #eee); padding: 8px 16px 120px; box-sizing: border-box; }',
|
|
|
|
|
|
'#ab-view > * { max-width: 760px; margin-left: auto; margin-right: auto; }',
|
|
|
|
|
|
'html.ab-open { overflow: hidden; }',
|
|
|
|
|
|
'.ab-top { display: flex; align-items: center; justify-content: space-between; position: relative; }',
|
|
|
|
|
|
'.ab-back { background: none; border: none; color: inherit; font: inherit; font-size: 15px; padding: 10px 0; cursor: pointer; }',
|
|
|
|
|
|
'.ab-menu { position: absolute; right: 16px; z-index: 2; display: flex; flex-direction: column; min-width: 240px; background: var(--raised, #222); border: 1px solid var(--border, #2a2a2a); border-radius: 8px; box-shadow: var(--shadow, 0 6px 24px rgba(0,0,0,.4)); }',
|
|
|
|
|
|
'#ab-view .ab-menu { margin: 0; left: auto; }',
|
|
|
|
|
|
'.ab-menu[hidden] { display: none; }',
|
|
|
|
|
|
'.ab-menu button { background: none; border: none; color: inherit; font: inherit; text-align: left; padding: 12px 14px; cursor: pointer; }',
|
|
|
|
|
|
'.ab-menu button:hover, .ab-row:hover { background: var(--hover, rgba(255,255,255,0.05)); }',
|
|
|
|
|
|
'.ab-hero { display: flex; gap: 16px; align-items: center; margin: 6px auto 14px; }',
|
|
|
|
|
|
'.ab-hero-text { flex: 1; min-width: 0; }',
|
|
|
|
|
|
'.ab-book-title { margin: 0 0 4px; font-size: 19px; line-height: 1.3; }',
|
|
|
|
|
|
'.ab-progress { margin-top: 6px; }',
|
|
|
|
|
|
'.ab-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 6px; }',
|
|
|
|
|
|
'.ab-btn { min-height: 42px; padding: 0 14px; border-radius: 21px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }',
|
|
|
|
|
|
'.ab-btn[disabled] { opacity: 0.4; cursor: default; }',
|
|
|
|
|
|
'.ab-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); min-width: 150px; }',
|
|
|
|
|
|
'.ab-num { flex: none; width: 28px; text-align: right; color: var(--muted, #9a9a9a); font-size: 13px; }',
|
|
|
|
|
|
'.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }',
|
|
|
|
|
|
'.ab-chapter.ab-now .ab-title, .ab-chapter.ab-now .ab-num { color: var(--accent, #e63946); }',
|
2026-10-16 14:08:44 +02:00
|
|
|
|
'.ab-mark, .ab-chrow { display: flex; align-items: center; }',
|
|
|
|
|
|
'.ab-chrow .ab-row { flex: 1; min-width: 0; }',
|
|
|
|
|
|
'.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }',
|
|
|
|
|
|
'.ab-chrow .ab-row { border-bottom: none; }',
|
|
|
|
|
|
'.ab-tick { color: var(--muted, #9a9a9a); font-size: 17px; }',
|
|
|
|
|
|
'.ab-tick.ab-ticked { color: var(--accent, #e63946); }',
|
Hörbücher: Podcasts als Hörbuch markieren und in „Bücher“ hören
Ein Abo lässt sich als Hörbuch (oder Hörspiel) markieren (PodcastFeed.kind).
Es verlässt dann die Podcast-Welt: keine „Neu“-Folgen, keine Warteschlangen-
Automatik, nicht in „Folgen“. In der Bibliothek steht es neben den E-Books
(static/js/audiobooks.js, eine Datei für Web, Desktop und Android):
„Weiterhören“, ein Fortschritt pro Buch, die Kapitel in Reihenfolge,
Lesezeichen mit Notiz, Schlummer bis Kapitelende.
Server: /podcasts/audiobooks/ (+<id>/), /podcasts/bookmarks/…, Migration 0019.
Player (podcasts-web.js): podcastPlayBook spielt die Kapitel nacheinander
außerhalb der Warteschlange; ⏭ markiert nicht als gehört. Vertrag:
docs/audiobooks.md. native-podcast.js aus diora-android 7b1d500.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:05:00 +02:00
|
|
|
|
'.ab-mark .ab-row { flex: 1; min-width: 0; }',
|
|
|
|
|
|
'#ab-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); }',
|
|
|
|
|
|
'#ab-toast.ab-show { opacity: 1; }',
|
|
|
|
|
|
'@media (max-width: 480px) { .ab-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 10px; } .ab-cover-l { width: 96px; height: 96px; } }',
|
|
|
|
|
|
].join('\n');
|
|
|
|
|
|
document.head.appendChild(style);
|
|
|
|
|
|
})();
|