diora-web/static/js/audiobooks.js
mrwnslz 143950df94
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 8m58s
Test / e2e (push) Successful in 1m58s
Abos & Quellen: alle Abos an einem Ort (Navigation Phase 2)
Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts,
YouTube-Kanäle, Hörbücher, Nachrichten und Blogs:
- ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines
  Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog);
  /feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video);
- eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche;
- je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern,
  abbestellen; OPML-Import und -Export.
In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt
hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt
auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal).
Podcast-UI aus diora-android 50c6f9c.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:23:12 +02:00

630 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 —
* "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';
const tab = document.getElementById('tab-audiobooks');
if (!tab) return;
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';
tab.appendChild(shelf);
// ---- helpers ------------------------------------------------------------------------
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'}[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') : [];
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;
}
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) : '';
return '<div class="ab-chrow"><button type="button" class="ab-row ab-chapter' + (c.played ? ' ab-done' : '') + (playing ? ' ab-now' : '') +
'" 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>' +
'<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>';
}
// 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};
const dur = ch.episode__duration_seconds || 0;
if (ms != null) body.position_ms = ms;
else if (ch.played || (dur && ch.position_seconds >= dur - 30)) body.position_ms = 0; // heard already: from its start
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);
}
// 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);
}
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();
}
else if (t.dataset.abPlayed != null) markChapter(Number(t.dataset.abPlayed));
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;
});
// Home's "Weiterhören" card (home.js) plays the book from where it was left.
window.__dioraAudiobookGo = feedId => goOn(feedId);
// Abos & Quellen (sources.js): open a book.
window.__dioraAudiobookOpen = feedId => openBook(Number(feedId));
// ---- 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);
if (name === 'audiobooks') loadList();
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); }',
'.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); }',
'.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);
})();