diora-web/static/js/audiobooks.js
mrwnslz fa9dc073cb
All checks were successful
Build and push Docker image / build (push) Successful in 15s
Test / test (push) Successful in 9m8s
Test / e2e (push) Successful in 2m2s
Hörbücher herunterladen (Android-App)
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>
2026-10-17 10:16:39 +02:00

942 lines
46 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
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 =>
({'&': '&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, 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);
})();