/* * Audiobooks in the library (docs/audiobooks.md): podcast subscriptions marked as a * Hörbuch or Hörspiel are listened to like a book, next to the e-books in "Bücher" — * "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-books'); const list = document.getElementById('book-list'); if (!tab || !list) 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// picking: false, // choosing a podcast to make an audiobook pickQuery: '', now: null, // playerState() of the last poll }; const shelf = document.createElement('div'); shelf.id = 'audiobook-shelf'; // Above the e-books, and above their "Gelesene anzeigen" switch (it is about them). const filter = list.previousElementSibling; const before = filter && filter.classList.contains('book-list-filter') ? filter : list; list.parentNode.insertBefore(shelf, before); // ---- 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) { const art = String(url || '').replace(/^http:/, 'https:'); return art ? '' : '' + esc((title || '?').trim().charAt(0).toUpperCase()) + ''; } function toast(text) { let t = document.getElementById('ab-toast'); if (!t) { t = document.createElement('div'); t.id = 'ab-toast'; document.body.appendChild(t); } t.textContent = text; t.classList.add('ab-show'); clearTimeout(toast.timer); toast.timer = setTimeout(() => t.classList.remove('ab-show'), 2600); } function cached(name, value) { try { if (value === undefined) return JSON.parse(localStorage.getItem('diora_ab_' + name) || 'null'); localStorage.setItem('diora_ab_' + name, JSON.stringify(value)); } catch (e) {} return null; } async function api(path, body) { const opts = {credentials: 'same-origin', cache: 'no-store'}; if (body !== undefined) { opts.method = 'POST'; opts.headers = {'Content-Type': 'application/json'}; opts.body = JSON.stringify(body); } const r = await fetch(path, opts); const d = await r.json().catch(() => null); if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status)); return d; } function podcastState() { const h = host(); if (!h) return null; try { return JSON.parse(h.podcastState()); } catch (e) { return null; } } function playerState() { const h = host(); if (!h || typeof h.playerState !== 'function') return null; try { return JSON.parse(h.playerState()); } catch (e) { return null; } } // The chapter of the open book playing (or paused) now, else null. function nowInBook() { const p = st.now; const d = st.detail; if (!p || !d || p.kind !== 'podcast') return null; for (let i = 0; i < d.chapters.length; i++) { if (d.chapters[i].episode__id === p.episodeId) return {index: i, pos: p.pos || 0, dur: p.dur || 0, playing: !!p.playing}; } return null; } // ---- loading ------------------------------------------------------------------------ async function loadList() { if (!host()) { shelf.innerHTML = ''; return; } if (!st.books) { st.books = cached('books'); renderShelf(); } try { st.books = (await api('/podcasts/audiobooks/')).books || []; cached('books', st.books); } catch (e) { if (!st.books) st.books = []; } renderShelf(); } async function loadDetail(feedId) { if (!st.detail || st.detail.book.feed_id !== feedId) st.detail = cached('book_' + feedId); renderView(); try { const d = await api('/podcasts/audiobooks/' + feedId + '/'); if (st.open !== feedId) return d; st.detail = d; cached('book_' + feedId, d); } catch (e) { if (!st.detail) toast('Das Hörbuch braucht eine Verbindung.'); } renderView(); return st.detail; } // ---- the shelf in "Bücher" ------------------------------------------------------------ function renderShelf() { const h = host(); const books = st.books || []; const state = h ? podcastState() : null; const pods = state && state.feeds ? state.feeds.filter(f => f.kind !== 'audiobook') : []; if (!h || (!books.length && !pods.length)) { shelf.innerHTML = ''; return; } let html = ''; const go = books.filter(b => b.next && b.last_played_at)[0]; if (go) { html += '
' + cover(go.artwork_url, go.title, 'ab-cover ab-cover-s') + '
Weiterhören
' + '
' + esc(go.title) + '
' + '
' + esc(progressText(go)) + '
' + '
' + '
'; } html += '

Hörbücher

' + (pods.length ? '' : '') + '
'; if (books.length) { html += '
' + books.map(b => '').join('') + '
'; } else { html += '

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.

'; } if (st.picking) html += pickHtml(pods); shelf.innerHTML = html; const input = shelf.querySelector('.ab-pick-q'); if (input && document.activeElement !== input && st.pickFocus) { input.focus(); st.pickFocus = false; } } function pickHtml(pods) { const q = st.pickQuery.trim().toLowerCase(); const found = pods.filter(f => !q || (f.title + ' ' + (f.author || '')).toLowerCase().indexOf(q) >= 0) .sort((a, b) => a.title.localeCompare(b.title)).slice(0, 60); return '
Welcher Podcast ist ein Hörbuch?' + '
' + '' + '
' + found.map(f => '').join('') + (found.length ? '' : '

Kein Abo gefunden.

') + '
'; } 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 = '

Wird geladen …

'; return; } const now = nowInBook(); const marks = d ? d.bookmarks : []; const titleOf = id => { const ch = d ? d.chapters.filter(c => c.episode__id === id)[0] : null; return ch ? 'Kapitel ' + (ch.index + 1) : ''; }; view.innerHTML = '
' + '
' + '' + '
' + cover(b.artwork_url, b.title, 'ab-cover ab-cover-l') + '

' + esc(b.title) + '

' + (b.author ? '
' + esc(b.author) + '
' : '') + '
' + esc(progressText(b)) + '
' + '
' + '
' + '' + '' + '
' + (marks.length ? '

Lesezeichen

' + marks.map(m => '
' + '' + '
').join('') + '
' : '') + '

Kapitel

' + (d ? '
' + d.chapters.map(c => chapterHtml(c, now)).join('') + '
' : '

Wird geladen …

'); } function goLabel(b, now) { if (now && now.playing) return '❚❚ Pause'; if (!b.next) return '↺ Von vorn'; return '▶ ' + (b.done || b.heard_seconds > 0 ? 'Weiterhören' : 'Anfangen'); } function chapterHtml(c, now) { const playing = now && now.index === c.index; const dur = c.episode__duration_seconds || 0; let meta; if (c.played) meta = '✓ gehört'; else if (playing && now.dur) meta = clock(now.pos) + ' / ' + clock(now.dur); else if (c.position_seconds > 0 && dur) meta = 'noch ' + hours(dur - c.position_seconds); else meta = dur ? hours(dur) : ''; return '
' + '
'; } // Play the open book's chapter `index` (from its own place, or from `ms`). function play(index, ms) { const h = host(); const d = st.detail; if (!h || !d || !d.chapters[index]) return; const ch = d.chapters[index]; const body = {feed_id: d.book.feed_id, chapters: d.chapters, index}; const dur = ch.episode__duration_seconds || 0; if (ms != null) body.position_ms = ms; else if (ch.played || (dur && ch.position_seconds >= dur - 30)) body.position_ms = 0; // heard already: from its start h.podcastPlayBook(JSON.stringify(body)); setTimeout(poll, 400); } async function goOn(feedId) { if (st.open !== feedId || !st.detail || st.detail.book.feed_id !== feedId) { st.open = feedId; await loadDetail(feedId); if (!view || view.style.display === 'none') st.open = null; } const d = st.detail; if (!d || d.book.feed_id !== feedId) return; const b = d.book; play(b.next ? b.next.index : 0, b.next ? undefined : 0); } function togglePlay() { const now = nowInBook(); const h = host(); if (now && now.playing && h) { if (typeof h.podcastToggle === 'function') h.podcastToggle(); else if (typeof h.podcastPause === 'function') h.podcastPause(); setTimeout(poll, 300); return; } if (now && h) { play(now.index, now.pos); return; } goOn(st.open); } // The sleep timer to the end of the chapter: the episode's own chapter mark if it has // some (a long file), else the end of the episode. async function sleepAtChapterEnd() { const h = host(); const now = nowInBook(); if (!h || !now) return; const id = st.detail.chapters[now.index].episode__id; let marks = []; try { marks = (await api('/podcasts/episodes/' + id + '/')).chapters || []; } catch (e) {} const pos = (playerState() || {}).pos || now.pos; const next = marks.map(m => m.start * 1000).filter(s => s > pos + 1000).sort((a, b) => a - b)[0]; if (next) { const speed = (st.now && st.now.speed) || 1; h.podcastSleep((next - pos) / speed / 60000); toast('Schlummer: in ' + clock((next - pos) / speed) + ', am Kapitelende'); } else { h.podcastSleep(-1); toast('Schlummer: am Ende dieses Kapitels'); } } async function addBookmark() { const now = nowInBook(); if (!now) return; const pos = (playerState() || {}).pos || now.pos; const ch = st.detail.chapters[now.index]; const note = typeof customPrompt === 'function' ? await customPrompt('Lesezeichen bei Kapitel ' + (now.index + 1) + ', ' + clock(pos) + ' – Notiz (optional):', '') : window.prompt('Notiz (optional):', ''); if (note === null || note === undefined) return; try { await api('/podcasts/bookmarks/', {episode_id: ch.episode__id, position_ms: Math.round(pos), note: String(note)}); toast('Lesezeichen gesetzt'); } catch (e) { toast('Lesezeichen brauchen eine Verbindung.'); } loadDetail(st.open); } async function editBookmark(id) { const m = st.detail.bookmarks.filter(x => x.id === id)[0]; if (!m) return; const note = typeof customPrompt === 'function' ? await customPrompt('Notiz:', m.note) : window.prompt('Notiz:', m.note); if (note === null || note === undefined) return; try { await api('/podcasts/bookmarks/' + id + '/', {note: String(note)}); } catch (e) { toast('Braucht eine Verbindung.'); } loadDetail(st.open); } async function deleteBookmark(id) { const ok = typeof customConfirm === 'function' ? await customConfirm('Lesezeichen löschen?') : window.confirm('Lesezeichen löschen?'); if (!ok) return; try { await api('/podcasts/bookmarks/' + id + '/delete/', {}); } catch (e) { toast('Braucht eine Verbindung.'); } loadDetail(st.open); } function playBookmark(id) { const m = st.detail.bookmarks.filter(x => x.id === id)[0]; if (!m) return; const ch = st.detail.chapters.filter(c => c.episode__id === m.episode_id)[0]; if (ch) play(ch.index, m.position_ms); } // One chapter heard or not: at once here, the host tells the server. function markChapter(index) { const h = host(); const d = st.detail; const ch = d && d.chapters[index]; if (!h || !ch) return; ch.played = !ch.played; const dur = ch.episode__duration_seconds || 0; if (!ch.played && dur && ch.position_seconds >= dur - 30) ch.position_seconds = 0; // from the start again h.podcastMarkPlayedAll(JSON.stringify([ch.episode__id]), ch.played); renderView(); setTimeout(() => loadDetail(st.open), 1500); } function markAll(played) { const h = host(); const d = st.detail; if (!h || !d) return; h.podcastMarkPlayedAll(JSON.stringify(d.chapters.map(c => c.episode__id)), !!played); setTimeout(() => loadDetail(st.open), 900); } async function onViewClick(e) { const t = e.target.closest('button'); const menu = view.querySelector('.ab-menu'); if (menu && (!t || t.dataset.ab !== 'menu')) menu.hidden = true; if (!t) return; const act = t.dataset.ab; if (act === 'close') closeBook(); else if (act === 'menu') { if (menu) menu.hidden = !menu.hidden; } else if (act === 'go') togglePlay(); else if (act === 'sleep') sleepAtChapterEnd(); else if (act === 'mark') addBookmark(); else if (act === 'unplayed') markAll(false); else if (act === 'played') markAll(true); else if (act === 'podcast') { const ok = typeof customConfirm === 'function' ? await customConfirm('„' + st.detail.book.title + '“ wieder als Podcast führen? Es verlässt die Bücher; Stellen und Lesezeichen bleiben.') : true; if (!ok) return; setKind(st.open, 'podcast'); closeBook(); } else if (t.dataset.abPlayed != null) markChapter(Number(t.dataset.abPlayed)); else if (t.dataset.abCh != null) play(Number(t.dataset.abCh)); else if (t.dataset.abBm != null) playBookmark(Number(t.dataset.abBm)); else if (t.dataset.abBmEdit != null) editBookmark(Number(t.dataset.abBmEdit)); else if (t.dataset.abBmDel != null) deleteBookmark(Number(t.dataset.abBmDel)); } shelf.addEventListener('click', e => { const t = e.target.closest('button, .ab-continue'); if (!t) return; if (t.dataset.abGo != null) { e.stopPropagation(); goOn(Number(t.dataset.abGo)); return; } if (t.dataset.abOpen != null) { openBook(Number(t.dataset.abOpen)); return; } if (t.dataset.abMark != null) { const id = Number(t.dataset.abMark); st.picking = false; st.pickQuery = ''; // At once on the shelf, before the server has it: a card that fills in when it answers. const f = ((podcastState() || {}).feeds || []).filter(x => x.id === id)[0]; if (f) { st.books = [{feed_id: id, title: f.title, author: f.author || '', artwork_url: f.artwork_url || '', chapters: f.episode_count || 0, done: 0, total_seconds: 0, heard_seconds: 0, next: null, last_played_at: null}] .concat((st.books || []).filter(b => b.feed_id !== id)); } setKind(id, 'audiobook'); renderShelf(); toast('„' + (f ? f.title : 'Podcast') + '“ ist jetzt ein Hörbuch'); return; } if (t.dataset.ab === 'pick') { st.picking = !st.picking; st.pickFocus = st.picking; renderShelf(); return; } if (t.dataset.ab === 'pick-close') { st.picking = false; renderShelf(); } }); shelf.addEventListener('input', e => { if (!e.target.classList.contains('ab-pick-q')) return; st.pickQuery = e.target.value; const box = shelf.querySelector('.ab-pick-list'); const pods = ((podcastState() || {}).feeds || []).filter(f => f.kind !== 'audiobook'); const tmp = document.createElement('div'); tmp.innerHTML = pickHtml(pods); const fresh = tmp.querySelector('.ab-pick-list'); if (box && fresh) box.innerHTML = fresh.innerHTML; }); // ---- following the player ------------------------------------------------------------ let pollTimer = 0; let lastEpisode = null; function poll() { clearTimeout(pollTimer); const p = playerState(); st.now = p; const ep = p && p.kind === 'podcast' ? p.episodeId : null; if (st.open != null && view && view.style.display !== 'none') { if (ep !== lastEpisode && lastEpisode != null) loadDetail(st.open); // a chapter ended: progress moved else renderView(); pollTimer = setTimeout(poll, 1000); } else if (ep !== lastEpisode && lastEpisode != null && tab.style.display !== 'none') { loadList(); } lastEpisode = ep; if (st.open == null) pollTimer = setTimeout(poll, 5000); } // ---- when to look ------------------------------------------------------------------------ const showTab = window.showTab; if (typeof showTab === 'function') { window.showTab = function (name) { const r = showTab.apply(this, arguments); if (name === 'books') loadList(); else if (st.open != null) closeBook(); return r; }; } const back = window.__dioraBack; window.__dioraBack = function () { if (st.open != null) { const menu = view && view.querySelector('.ab-menu'); if (menu && !menu.hidden) { menu.hidden = true; return true; } closeBook(); return true; } if (st.picking && tab.style.display !== 'none') { st.picking = false; renderShelf(); return true; } return back ? back() : false; }; document.addEventListener('keydown', e => { if (e.key === 'Escape' && st.open != null) closeBook(); }); document.addEventListener('visibilitychange', () => { if (!document.hidden && tab.style.display !== 'none') loadList(); }); // The podcast host fills its state a moment after the page; the shelf needs its abos. setTimeout(loadList, 300); setTimeout(poll, 1500); // ---- styles ---------------------------------------------------------------------------- const style = document.createElement('style'); style.textContent = [ '#audiobook-shelf { margin: 0 0 14px; }', '#audiobook-shelf:empty { display: none; }', '.ab-h { margin: 18px 0 8px; font-size: 12px; font-weight: 400; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted, #9a9a9a); }', '.ab-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }', '.ab-link { background: none; border: none; color: var(--accent, #e63946); font: inherit; font-size: 14px; padding: 8px 0; cursor: pointer; }', '.ab-empty { color: var(--muted, #9a9a9a); font-size: 13px; line-height: 1.45; margin: 6px 0; }', '.ab-meta { display: block; color: var(--muted, #9a9a9a); font-size: 12px; line-height: 1.35; }', '.ab-title { display: block; font-size: 14px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; }', '.ab-label { color: var(--accent, #e63946); font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; }', '.ab-bar { display: block; height: 3px; border-radius: 2px; background: var(--border, #2a2a2a); margin-top: 6px; overflow: hidden; }', '.ab-bar > span { display: block; height: 100%; background: var(--accent, #e63946); }', '.ab-cover { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 6px; background: var(--panel, #161616); display: block; }', '.ab-nocover { display: flex; align-items: center; justify-content: center; font-size: 28px; color: var(--muted, #9a9a9a); }', '.ab-cover-s { width: 56px; height: 56px; flex: none; }', '.ab-cover-xs { width: 40px; height: 40px; flex: none; font-size: 16px; }', '.ab-cover-l { width: 120px; height: 120px; flex: none; }', '.ab-card-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; }', '.ab-continue { display: flex; align-items: center; gap: 12px; padding: 10px; margin: 4px 0 6px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); box-shadow: var(--shadow, none); cursor: pointer; }', '.ab-play { flex: none; width: 48px; height: 48px; border-radius: 50%; border: none; background: var(--accent, #e63946); color: var(--on-accent, #fff); font-size: 18px; cursor: pointer; }', '.ab-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 14px; }', '.ab-card { display: flex; flex-direction: column; gap: 6px; padding: 0; background: none; border: none; color: inherit; font: inherit; cursor: pointer; text-align: left; min-width: 0; }', '.ab-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 0; background: none; border: none; border-bottom: 1px solid var(--border, #2a2a2a); color: inherit; font: inherit; cursor: pointer; text-align: left; }', '.ab-icon { background: none; border: none; color: inherit; font: inherit; font-size: 18px; min-width: 40px; min-height: 40px; cursor: pointer; }', '.ab-pick { margin-top: 10px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; padding: 10px 12px; background: var(--card, #0b0b0b); }', '.ab-pick-head { display: flex; align-items: center; justify-content: space-between; }', '.ab-pick-q { width: 100%; box-sizing: border-box; margin: 6px 0; padding: 9px 10px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }', '.ab-pick-list { max-height: 50vh; overflow-y: auto; }', // Above the tab bar (150), below the page's dialogs (990): a note is asked for on top of it. '#ab-view { position: fixed; top: 0; right: 0; bottom: 0; left: 0; z-index: 900; overflow-y: auto; background: var(--bg, #000); color: var(--fg, #eee); padding: 8px 16px 120px; box-sizing: border-box; }', '#ab-view > * { max-width: 760px; margin-left: auto; margin-right: auto; }', 'html.ab-open { overflow: hidden; }', '.ab-top { display: flex; align-items: center; justify-content: space-between; position: relative; }', '.ab-back { background: none; border: none; color: inherit; font: inherit; font-size: 15px; padding: 10px 0; cursor: pointer; }', '.ab-menu { position: absolute; right: 16px; z-index: 2; display: flex; flex-direction: column; min-width: 240px; background: var(--raised, #222); border: 1px solid var(--border, #2a2a2a); border-radius: 8px; box-shadow: var(--shadow, 0 6px 24px rgba(0,0,0,.4)); }', '#ab-view .ab-menu { margin: 0; left: auto; }', '.ab-menu[hidden] { display: none; }', '.ab-menu button { background: none; border: none; color: inherit; font: inherit; text-align: left; padding: 12px 14px; cursor: pointer; }', '.ab-menu button:hover, .ab-row:hover { background: var(--hover, rgba(255,255,255,0.05)); }', '.ab-hero { display: flex; gap: 16px; align-items: center; margin: 6px auto 14px; }', '.ab-hero-text { flex: 1; min-width: 0; }', '.ab-book-title { margin: 0 0 4px; font-size: 19px; line-height: 1.3; }', '.ab-progress { margin-top: 6px; }', '.ab-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 6px; }', '.ab-btn { min-height: 42px; padding: 0 14px; border-radius: 21px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; }', '.ab-btn[disabled] { opacity: 0.4; cursor: default; }', '.ab-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); min-width: 150px; }', '.ab-num { flex: none; width: 28px; text-align: right; color: var(--muted, #9a9a9a); font-size: 13px; }', '.ab-chapter.ab-done .ab-title { color: var(--muted, #9a9a9a); }', '.ab-chapter.ab-now .ab-title, .ab-chapter.ab-now .ab-num { color: var(--accent, #e63946); }', '.ab-mark, .ab-chrow { display: flex; align-items: center; }', '.ab-chrow .ab-row { flex: 1; min-width: 0; }', '.ab-chrow { border-bottom: 1px solid var(--border, #2a2a2a); }', '.ab-chrow .ab-row { border-bottom: none; }', '.ab-tick { color: var(--muted, #9a9a9a); font-size: 17px; }', '.ab-tick.ab-ticked { color: var(--accent, #e63946); }', '.ab-mark .ab-row { flex: 1; min-width: 0; }', '#ab-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }', '#ab-toast.ab-show { opacity: 1; }', '@media (max-width: 480px) { .ab-grid { grid-template-columns: repeat(auto-fill, minmax(104px, 1fr)); gap: 10px; } .ab-cover-l { width: 96px; height: 96px; } }', ].join('\n'); document.head.appendChild(style); })();