diora-web/static/js/audiobooks.js

631 lines
31 KiB
JavaScript
Raw Normal View History

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