In der Ansicht eines Hörbuchs „↓ Herunterladen“: alle Kapitel, die noch fehlen, ab dem nächsten (mit Fortschritt „Lädt … 12/56“, danach „Auf dem Gerät“); je Kapitel ein Download-Zeichen (laden, Fortschritt, erneut versuchen, löschen) und im ⋮-Menü „Vom Gerät löschen“. Nur wo der Host Downloads kann (DioraNative.podcastDownload); sw.js: diora-v126. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
942 lines
46 KiB
JavaScript
942 lines
46 KiB
JavaScript
/*
|
||
* 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
|
||
folder: null, // the folder chip chosen (null: all, '': without a folder)
|
||
folderOrder: [], // the podcasts' folder order (/podcasts/folders/)
|
||
series: null, // the series shown (its key), inside the folder
|
||
};
|
||
|
||
const shelf = document.createElement('div');
|
||
shelf.id = 'audiobook-shelf';
|
||
tab.appendChild(shelf);
|
||
|
||
// ---- 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, author) {
|
||
const art = String(url || '').replace(/^http:/, 'https:');
|
||
if (!art && window.DioraGenCover) { // none: one drawn from the title (gencover.js)
|
||
return '<img class="' + cls + '" src="' + DioraGenCover.url({title: title || '', author: author || ''}) + '" alt="">';
|
||
}
|
||
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 {
|
||
const got = await Promise.all([api('/podcasts/audiobooks/'), loadFolderOrder()]);
|
||
st.books = got[0].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 += gridHtml(books);
|
||
} 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;
|
||
drawChips(books);
|
||
const input = shelf.querySelector('.ab-pick-q');
|
||
if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; }
|
||
}
|
||
|
||
// ---- organising: folders (set per book) and series (found in the titles) ----------------
|
||
|
||
// "Das Kapital – Erster Band", "Harry Potter: Teil 3", "Die drei ??? – Folge 12":
|
||
// the series' name and the volume's number, or null.
|
||
const VOLUME_WORD = /\b(band|bd\.|teil|buch|folge|staffel|episode|vol\.?|volume|part|book)\b/i;
|
||
const ORDINALS = ['erst', 'zweit', 'dritt', 'viert', 'fünft', 'sechst', 'siebt', 'acht', 'neunt', 'zehnt',
|
||
'elft', 'zwölft'];
|
||
const ORDINALS_EN = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth', 'ninth', 'tenth'];
|
||
const ROMAN = {i: 1, ii: 2, iii: 3, iv: 4, v: 5, vi: 6, vii: 7, viii: 8, ix: 9, x: 10, xi: 11, xii: 12};
|
||
|
||
function volumeNumber(text) {
|
||
const t = text.toLowerCase();
|
||
const digits = /(\d+)/.exec(t);
|
||
if (digits) return parseInt(digits[1], 10);
|
||
for (const word of t.split(/[^a-zäöüß]+/)) {
|
||
if (ROMAN[word]) return ROMAN[word];
|
||
for (let i = 0; i < ORDINALS.length; i++) if (word.indexOf(ORDINALS[i]) === 0) return i + 1;
|
||
for (let i = 0; i < ORDINALS_EN.length; i++) if (word === ORDINALS_EN[i]) return i + 1;
|
||
}
|
||
return 999;
|
||
}
|
||
|
||
function seriesOf(b) {
|
||
const m = /^(.+?)\s+[–—-]\s+(.+)$/.exec(b.title) || /^(.+?):\s+(.+)$/.exec(b.title) ||
|
||
/^(.+?)\.\s+(.+)$/.exec(b.title); // "Der Zauberberg. Erster Band"
|
||
if (!m || !(VOLUME_WORD.test(m[2]) || /^\s*\d+\s*$/.test(m[2]))) return null;
|
||
const name = m[1].trim();
|
||
return {key: (name + '|' + (b.author || '')).toLowerCase(), name, number: volumeNumber(m[2]), volume: m[2].trim()};
|
||
}
|
||
|
||
// The books of one level (all, or one folder) as cards: series of two or more volumes
|
||
// become one stack; folders come first (at the top level).
|
||
function entries(books) {
|
||
const groups = new Map();
|
||
const single = [];
|
||
books.forEach(b => {
|
||
const sr = seriesOf(b);
|
||
if (!sr) { single.push({kind: 'book', b, sort: b.title}); return; }
|
||
if (!groups.has(sr.key)) groups.set(sr.key, {kind: 'series', key: sr.key, name: sr.name, books: [], sort: sr.name});
|
||
groups.get(sr.key).books.push(Object.assign({}, b, {_volume: sr.volume, _number: sr.number}));
|
||
});
|
||
const out = single;
|
||
groups.forEach(g => {
|
||
if (g.books.length < 2) { out.push({kind: 'book', b: g.books[0], sort: g.books[0].title}); return; }
|
||
g.books.sort((x, y) => x._number - y._number || x.title.localeCompare(y.title));
|
||
out.push(g);
|
||
});
|
||
// The last listened first (as the list comes), then the rest by name.
|
||
const recent = e => {
|
||
const list = e.kind === 'series' ? e.books : [e.b];
|
||
return list.map(x => x.last_played_at || '').sort().pop() || '';
|
||
};
|
||
return out.sort((x, y) => (recent(y) > recent(x) ? 1 : recent(y) < recent(x) ? -1 : 0) ||
|
||
x.sort.localeCompare(y.sort, 'de'));
|
||
}
|
||
|
||
function bookCard(b, label) {
|
||
return '<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(label || b.title) + '</span>' +
|
||
(b.author && !label ? '<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>';
|
||
}
|
||
|
||
function seriesCard(g) {
|
||
const total = g.books.reduce((n, b) => n + (b.total_seconds || 0), 0);
|
||
const heard = g.books.reduce((n, b) => n + (b.heard_seconds || 0), 0);
|
||
const going = g.books.filter(b => b.next && b.last_played_at)[0];
|
||
const done = g.books.filter(b => !b.next).length;
|
||
const meta = g.books.length + ' Bände' + (going ? ' · bei ' + going._volume
|
||
: done === g.books.length ? ' · fertig gehört' : done ? ' · ' + done + ' gehört' : '');
|
||
return '<button type="button" class="ab-card ab-stack" data-ab-series="' + esc(g.key) + '">' +
|
||
'<span class="ab-stack-covers">' + cover(g.books[0].artwork_url, g.name, 'ab-cover') + '</span>' +
|
||
'<span class="ab-card-text"><span class="ab-title">' + esc(g.name) + '</span>' +
|
||
(g.books[0].author ? '<span class="ab-meta">' + esc(g.books[0].author) + '</span>' : '') +
|
||
'<span class="ab-meta">' + esc(meta) + '</span>' +
|
||
'<span class="ab-bar"><span style="width:' + (total ? Math.round(100 * heard / total) : 0) + '%"></span></span></span></button>';
|
||
}
|
||
|
||
const foldersOf = b => (b.folders && b.folders.length ? b.folders : (b.folder ? [b.folder] : []));
|
||
|
||
// The audiobooks' folders as chips (docs/library.md), in the podcasts' folder order.
|
||
function folderChips(books) {
|
||
const counts = new Map();
|
||
let none = 0;
|
||
books.forEach(b => {
|
||
const fs = foldersOf(b);
|
||
if (!fs.length) none++;
|
||
fs.forEach(f => counts.set(f, (counts.get(f) || 0) + 1));
|
||
});
|
||
const rank = n => { const i = st.folderOrder.indexOf(n); return i < 0 ? 1e6 : i; };
|
||
const names = Array.from(counts.keys()).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b, 'de'));
|
||
if (st.folder && !counts.has(st.folder)) st.folder = null;
|
||
return {folders: names.map(n => ({name: n, count: counts.get(n)})), none: names.length ? none : 0};
|
||
}
|
||
|
||
function gridHtml(books) {
|
||
const here = books.filter(b => st.folder == null ? true
|
||
: st.folder === '' ? !foldersOf(b).length : foldersOf(b).indexOf(st.folder) >= 0);
|
||
const list = entries(here);
|
||
if (st.series != null) {
|
||
const g = list.filter(e => e.kind === 'series' && e.key === st.series)[0];
|
||
if (g) {
|
||
return '<div class="ab-place"><button type="button" class="ab-back" data-ab="up">‹ Hörbücher</button>' +
|
||
'<strong>' + esc(g.name) + '</strong></div>' +
|
||
'<div class="ab-grid">' + g.books.map(b => bookCard(b, b._volume)).join('') + '</div>';
|
||
}
|
||
st.series = null;
|
||
}
|
||
return '<div class="ab-chips"></div><div class="ab-grid">' +
|
||
list.map(e => e.kind === 'series' ? seriesCard(e) : bookCard(e.b)).join('') + '</div>';
|
||
}
|
||
|
||
function drawChips(books) {
|
||
const el = shelf.querySelector('.ab-chips');
|
||
if (!el || !window.DioraFolders) return;
|
||
const c = folderChips(books);
|
||
if (!c.folders.length) { el.innerHTML = ''; return; }
|
||
window.DioraFolders.chips(el, {
|
||
folders: c.folders, active: st.folder, none: c.none,
|
||
onPick(name) { st.folder = name; renderShelf(); },
|
||
onManage: manageFolders,
|
||
});
|
||
}
|
||
|
||
async function loadFolderOrder() {
|
||
try { st.folderOrder = (await api('/podcasts/folders/')).order || []; } catch (e) {}
|
||
}
|
||
|
||
// All folder names of the podcasts and audiobooks, in their order.
|
||
async function allFolderNames() {
|
||
try {
|
||
const d = await api('/podcasts/folders/');
|
||
st.folderOrder = d.order || [];
|
||
return st.folderOrder;
|
||
} catch (e) {
|
||
return Array.from(new Set([].concat.apply([], (st.books || []).map(foldersOf))));
|
||
}
|
||
}
|
||
|
||
function manageFolders() {
|
||
if (!window.DioraFolders) return;
|
||
allFolderNames().then(names => window.DioraFolders.manage({
|
||
title: 'Ordner der Podcasts und Hörbücher',
|
||
folders: names,
|
||
onRename: (old, n) => api('/podcasts/folders/rename/', {old, new: n}).then(loadList).catch(() => toast('Das braucht eine Verbindung.')),
|
||
onDelete: name => api('/podcasts/folders/delete/', {name}).then(loadList).catch(() => toast('Das braucht eine Verbindung.')),
|
||
onOrder: order => api('/podcasts/folders/', {order}).then(d => { st.folderOrder = d.order || order; renderShelf(); }).catch(() => {}),
|
||
}));
|
||
}
|
||
|
||
// Into folders (any number): the server keeps them with the subscription.
|
||
async function assignFolder(feedId) {
|
||
if (!window.DioraFolders) return;
|
||
const b = (st.books || []).filter(x => x.feed_id === feedId)[0];
|
||
const names = await allFolderNames();
|
||
window.DioraFolders.assign({
|
||
title: 'Ordner für „' + (b ? b.title : 'Hörbuch') + '“',
|
||
folders: names,
|
||
selected: b ? foldersOf(b) : [],
|
||
async onDone(picked) {
|
||
try {
|
||
await api('/podcasts/feeds/' + feedId + '/settings/', {folders: picked});
|
||
toast(picked.length ? 'In ' + picked.map(n => '„' + n + '“').join(', ') : 'Ohne Ordner');
|
||
} catch (e) {
|
||
toast('Das braucht eine Verbindung.');
|
||
}
|
||
loadList();
|
||
if (st.open === feedId) loadDetail(feedId);
|
||
},
|
||
});
|
||
}
|
||
|
||
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 dlMap = d ? downloads() : null;
|
||
const dl = dlMap ? bookDownloads(d, dlMap) : null;
|
||
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="folder">In Ordner …</button>' +
|
||
(dl && dl.held ? '<button type="button" data-ab="dl-remove">Vom Gerät löschen (' + dl.held + ' Kapitel)</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>' +
|
||
(dl ? downloadButton(dl) : '') + '</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, dlMap)).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');
|
||
}
|
||
|
||
// ---- downloads (the Android app: chapters on the device, for listening without a connection) ----
|
||
|
||
function dlHost() {
|
||
const h = host();
|
||
return h && typeof h.podcastDownload === 'function' && typeof h.podcastDownloads === 'function' ? h : null;
|
||
}
|
||
|
||
// The host's downloads: {episodeId: {state: queued|downloading|done|failed, progress …}}, or null.
|
||
function downloads() {
|
||
const h = dlHost();
|
||
if (!h) return null;
|
||
try { return JSON.parse(h.podcastDownloads()) || {}; } catch (e) { return {}; }
|
||
}
|
||
|
||
function bookDownloads(d, map) {
|
||
const n = {total: d.chapters.length, done: 0, busy: 0, failed: 0, held: 0};
|
||
d.chapters.forEach(c => {
|
||
const e = map[c.episode__id];
|
||
if (!e) return;
|
||
n.held++;
|
||
if (e.state === 'done') n.done++;
|
||
else if (e.state === 'failed') n.failed++;
|
||
else n.busy++;
|
||
});
|
||
return n;
|
||
}
|
||
|
||
function downloadButton(n) {
|
||
if (n.done === n.total) return '<button type="button" class="ab-btn ab-dl-on" data-ab="dl-info">' + DL_DONE + ' Auf dem Gerät</button>';
|
||
if (n.busy) return '<button type="button" class="ab-btn" data-ab="dl-info">↓ Lädt … ' + n.done + '/' + n.total + '</button>';
|
||
return '<button type="button" class="ab-btn" data-ab="dl-all">↓ Herunterladen' + (n.done ? ' (noch ' + (n.total - n.done) + ')' : '') + '</button>';
|
||
}
|
||
|
||
// Arrow into a tray; on the device: the same, white in a full circle (not a ✓: that's "heard").
|
||
const DL_ICON = '<svg class="ab-dl-ic" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10.5l5 5 5-5M5 19.5h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="square"/></svg>';
|
||
const DL_DONE = '<svg class="ab-dl-ic" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor"/><path d="M12 6v8M8.5 10.5l3.5 3.5 3.5-3.5M7.5 17.5h9" fill="none" stroke="var(--bg, #fff)" stroke-width="2" stroke-linecap="square"/></svg>';
|
||
|
||
function chapterDownload(c, map) {
|
||
const e = map[c.episode__id];
|
||
let sign = DL_ICON;
|
||
let label = 'Kapitel herunterladen';
|
||
let cls = '';
|
||
if (e && e.state === 'done') { sign = DL_DONE; label = 'Auf dem Gerät – tippen: löschen'; cls = ' ab-dl-on'; }
|
||
else if (e && e.state === 'failed') { sign = '!'; label = 'Fehlgeschlagen – tippen: erneut versuchen'; }
|
||
else if (e && e.state === 'downloading') { sign = Math.round((e.progress || 0) * 100) + '%'; label = 'Lädt'; cls = ' ab-dl-busy'; }
|
||
else if (e) { sign = '…'; label = 'Wartet auf den Download'; cls = ' ab-dl-busy'; }
|
||
return '<button type="button" class="ab-icon ab-dl' + cls + '" data-ab-dl="' + c.index + '" aria-label="' + label + '" title="' + label + '">' + sign + '</button>';
|
||
}
|
||
|
||
// All chapters not yet on the device, from the one that comes next on (then the ones before).
|
||
function downloadAll() {
|
||
const h = dlHost();
|
||
const d = st.detail;
|
||
if (!h || !d) return;
|
||
const map = downloads() || {};
|
||
const from = d.book.next ? d.book.next.index : 0;
|
||
const order = d.chapters.slice(from).concat(d.chapters.slice(0, from));
|
||
let n = 0;
|
||
order.forEach(c => {
|
||
const e = map[c.episode__id];
|
||
if (e && e.state !== 'failed') return;
|
||
if (e && typeof h.podcastRetryDownload === 'function') h.podcastRetryDownload(c.episode__id);
|
||
else h.podcastDownload(JSON.stringify(c));
|
||
n++;
|
||
});
|
||
toast(n ? n + (n === 1 ? ' Kapitel wird' : ' Kapitel werden') + ' heruntergeladen' : 'Schon alles auf dem Gerät');
|
||
setTimeout(renderView, 300);
|
||
}
|
||
|
||
async function removeDownloads() {
|
||
const h = dlHost();
|
||
const d = st.detail;
|
||
if (!h || !d || typeof h.podcastDeleteDownload !== 'function') return;
|
||
const ok = typeof customConfirm === 'function'
|
||
? await customConfirm('Die Kapitel von „' + d.book.title + '“ vom Gerät löschen? Stellen und Lesezeichen bleiben; abspielen geht dann nur mit Verbindung.', {danger: true})
|
||
: true;
|
||
if (!ok) return;
|
||
const map = downloads() || {};
|
||
d.chapters.forEach(c => { if (map[c.episode__id]) h.podcastDeleteDownload(c.episode__id); });
|
||
toast('Vom Gerät gelöscht');
|
||
setTimeout(renderView, 300);
|
||
}
|
||
|
||
async function chapterDownloadTap(index) {
|
||
const h = dlHost();
|
||
const d = st.detail;
|
||
const c = d && d.chapters[index];
|
||
if (!h || !c) return;
|
||
const e = (downloads() || {})[c.episode__id];
|
||
if (!e) h.podcastDownload(JSON.stringify(c));
|
||
else if (e.state === 'failed' && typeof h.podcastRetryDownload === 'function') h.podcastRetryDownload(c.episode__id);
|
||
else if (e.state === 'done' && typeof h.podcastDeleteDownload === 'function') {
|
||
const ok = typeof customConfirm === 'function' ? await customConfirm('Kapitel ' + (index + 1) + ' vom Gerät löschen?') : true;
|
||
if (ok) h.podcastDeleteDownload(c.episode__id);
|
||
}
|
||
setTimeout(renderView, 300);
|
||
}
|
||
|
||
function chapterHtml(c, now, dlMap) {
|
||
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>' +
|
||
(dlMap ? chapterDownload(c, dlMap) : '') + '</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 === 'folder') assignFolder(st.open);
|
||
else if (act === 'dl-all') downloadAll();
|
||
else if (act === 'dl-remove') removeDownloads();
|
||
else if (act === 'dl-info') {
|
||
const n = bookDownloads(st.detail, downloads() || {});
|
||
toast(n.done === n.total ? 'Alle ' + n.total + ' Kapitel sind auf dem Gerät (löschen: ⋮ oben)' : n.done + ' von ' + n.total + ' Kapiteln sind da, der Rest lädt');
|
||
}
|
||
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.abDl != null) chapterDownloadTap(Number(t.dataset.abDl));
|
||
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.abSeries != null) { st.series = t.dataset.abSeries; renderShelf(); return; }
|
||
if (t.dataset.ab === 'up') {
|
||
st.series = null;
|
||
renderShelf();
|
||
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; }
|
||
if (st.series != null && tab.style.display !== 'none') {
|
||
st.series = null;
|
||
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-place { display: flex; align-items: center; gap: 12px; margin: 0 0 10px; }',
|
||
'.ab-place .ab-back { padding: 6px 0; }',
|
||
'.ab-chips { margin: 0 0 10px; }',
|
||
'.ab-stack-covers { position: relative; display: block; }',
|
||
'.ab-stack-covers::before, .ab-stack-covers::after { content: ""; position: absolute; left: 6%; right: 6%; height: 100%; border-radius: 6px; border: 1px solid var(--border-strong, #444); background: var(--panel, #161616); }',
|
||
'.ab-stack-covers::before { top: -8px; left: 12%; right: 12%; }',
|
||
'.ab-stack-covers::after { top: -4px; }',
|
||
'.ab-stack-covers .ab-cover { position: relative; z-index: 1; }',
|
||
'.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-dl { color: var(--muted, #9a9a9a); font-size: 15px; min-width: 44px; }',
|
||
'.ab-dl-ic { width: 20px; height: 20px; vertical-align: -4px; }',
|
||
'.ab-dl.ab-dl-on { color: var(--fg, inherit); }',
|
||
'.ab-dl.ab-dl-busy { font-size: 12px; }',
|
||
'.ab-btn.ab-dl-on { border-color: var(--fg, currentColor); }',
|
||
'.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);
|
||
})();
|