diora-web/static/js/audiobooks.js
mrwnslz 838457f6f9 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

625 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;
});
// ---- 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);
})();