Files
abogen/abogen/webui/templates/reader_embed.html
T

1956 lines
62 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ display_title or job.original_filename }} · Reader</title>
<style>
:root {
color-scheme: dark;
--bg: #020617;
--panel: rgba(15, 23, 42, 0.92);
--border: rgba(148, 163, 184, 0.25);
--text: #e2e8f0;
--accent: #38bdf8;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
background: var(--bg);
color: var(--text);
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
}
body {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem;
}
.reader-shell {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
gap: 0.75rem;
}
.reader-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--border);
}
.reader-toolbar__group {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 0 0 auto;
}
.reader-toolbar__group--end {
justify-content: flex-end;
}
.reader-toolbar__title {
flex: 1 1 auto;
text-align: center;
font-weight: 600;
font-size: 1rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reader-toolbar button,
.reader-chapter-panel button {
flex: 0 0 auto;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
border-radius: 12px;
padding: 0.45rem 0.9rem;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.reader-toolbar button:hover,
.reader-toolbar button:focus-visible,
.reader-chapter-panel button:hover,
.reader-chapter-panel button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-toolbar button:disabled,
.reader-chapter-panel button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reader-toolbar__icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem;
width: 2.5rem;
height: 2.5rem;
border-radius: 999px;
}
.reader-toolbar__icon svg {
width: 1rem;
height: 1rem;
fill: currentColor;
}
.reader-toolbar select {
flex: 1 1 30%;
min-width: 140px;
max-width: 320px;
border-radius: 12px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
padding: 0.4rem 0.75rem;
font-size: 0.95rem;
}
.reader-toolbar select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
#reader-container {
flex: 1 1 auto;
min-height: 0;
border-radius: 16px;
background: var(--panel);
border: 1px solid var(--border);
overflow: hidden;
display: flex;
position: relative;
}
#reader-view {
flex: 1 1 auto;
width: 100%;
height: 100%;
min-height: 0;
outline: none;
}
#reader-view iframe {
width: 100%;
height: 100%;
border: 0;
}
.reader-footer {
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--border);
padding: 0.75rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reader-footer audio {
width: 100%;
}
.reader-player {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reader-player__controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.reader-player__controls button {
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
border-radius: 12px;
padding: 0.4rem 0.8rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.reader-player__controls button:hover,
.reader-player__controls button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-player__controls button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reader-player__secondary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75);
}
.reader-player__secondary label {
display: flex;
align-items: center;
gap: 0.5rem;
}
.reader-player__secondary select {
border-radius: 10px;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
color: var(--text);
padding: 0.25rem 0.5rem;
font-size: 0.9rem;
}
.reader-player__secondary select:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.reader-chapter-links {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
max-height: 100%;
overflow-y: auto;
padding: 0.5rem 0;
}
.reader-chapter-links button {
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.7);
color: var(--text);
border-radius: 10px;
padding: 0.45rem 0.75rem;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
text-align: left;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
width: 100%;
}
.reader-chapter-links button:hover,
.reader-chapter-links button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
.reader-chapter-links button[aria-current="true"] {
background: rgba(56, 189, 248, 0.35);
border-color: rgba(56, 189, 248, 0.6);
color: var(--text);
}
.reader-chapter-links button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.reader-chapter-links__empty {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.6);
}
.visually-hidden {
border: 0;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
width: 1px;
white-space: nowrap;
}
.reader-chapter-panel {
position: fixed;
top: 0.75rem;
bottom: 0.75rem;
right: 0.75rem;
width: min(320px, 85vw);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px 0 0 16px;
box-shadow: -6px 0 24px rgba(15, 23, 42, 0.45);
display: flex;
flex-direction: column;
transform: translateX(110%);
transition: transform 0.24s ease;
z-index: 20;
pointer-events: none;
}
.reader-chapter-panel.is-open {
transform: translateX(0);
pointer-events: auto;
}
.reader-chapter-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
}
.reader-chapter-panel__title {
font-weight: 600;
font-size: 0.95rem;
}
.reader-chapter-panel__body {
flex: 1 1 auto;
min-height: 0;
padding: 0 1rem 1rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.reader-chapter-panel__body > .reader-chapter-links {
flex: 1 1 auto;
}
.reader-status {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75);
}
.chunk--active {
background: rgba(56, 189, 248, 0.35);
box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5);
border-radius: 6px;
}
</style>
</head>
<body
data-asset-base="{{ asset_base }}"
data-epub-url="{{ epub_url }}"
>
<div class="reader-shell" data-role="reader-shell">
<div class="reader-toolbar">
<div class="reader-toolbar__group">
<button type="button" data-action="prev">Previous</button>
<select data-role="reader-chapter" aria-label="Select chapter"></select>
</div>
<div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div>
<div class="reader-toolbar__group reader-toolbar__group--end">
<button type="button" data-action="next">Next</button>
<button
type="button"
class="reader-toolbar__icon"
data-action="toggle-chapters"
aria-label="Toggle chapter list"
aria-expanded="false"
aria-controls="reader-chapter-panel"
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 7h16v2H4zm0 5h16v2H4zm0 5h16v2H4z" />
</svg>
<span class="visually-hidden">Toggle chapter list</span>
</button>
</div>
</div>
<div id="reader-container">
<div id="reader-view" class="reader-view" tabindex="0" aria-label="Book reader"></div>
</div>
<div class="reader-footer">
{% if audio_url %}
<div class="reader-player" data-role="reader-player">
<audio controls preload="metadata" src="{{ audio_url }}"></audio>
<div class="reader-player__controls">
<button type="button" data-action="player-prev" aria-label="Go to previous chapter">Prev chapter</button>
<button type="button" data-action="player-rewind" aria-label="Rewind 15 seconds">-15s</button>
<button type="button" data-action="player-toggle" aria-label="Play or pause audio">Play</button>
<button type="button" data-action="player-forward" aria-label="Skip forward 15 seconds">+15s</button>
<button type="button" data-action="player-next" aria-label="Go to next chapter">Next chapter</button>
</div>
<div class="reader-player__secondary">
<label for="reader-playback-rate">Speed</label>
<select id="reader-playback-rate" data-role="playback-rate">
<option value="0.75">0.75x</option>
<option value="0.9">0.90x</option>
<option value="1" selected>1.00x</option>
<option value="1.25">1.25x</option>
<option value="1.5">1.50x</option>
<option value="1.75">1.75x</option>
<option value="2">2.00x</option>
</select>
</div>
</div>
{% endif %}
<div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div>
</div>
</div>
<aside class="reader-chapter-panel" data-role="chapter-panel" id="reader-chapter-panel" aria-hidden="true">
<div class="reader-chapter-panel__header">
<span class="reader-chapter-panel__title">Chapters</span>
<button type="button" class="reader-toolbar__icon" data-action="close-chapter-panel" aria-label="Close chapter list">
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6.2 5.2 5.2 6.2 11 12l-5.8 5.8 1 1L12 13l5.8 5.8 1-1L13 12l5.8-5.8-1-1L12 11 6.2 5.2z" />
</svg>
<span class="visually-hidden">Close chapter list</span>
</button>
</div>
<div class="reader-chapter-panel__body">
<div class="reader-chapter-links" data-role="chapter-links" aria-label="Chapters"></div>
</div>
</aside>
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': display_title or job.original_filename, 'chapterTimings': chapter_timings}|tojson }}</script>
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js"></script>
<script type="module">
const statusEl = document.querySelector('[data-role="reader-status"]');
const prevBtn = document.querySelector('[data-action="prev"]');
const nextBtn = document.querySelector('[data-action="next"]');
const chapterSelect = document.querySelector('[data-role="reader-chapter"]');
const titleEl = document.querySelector('[data-role="reader-title"]');
const audioEl = document.querySelector('audio');
const playerPrevBtn = document.querySelector('[data-action="player-prev"]');
const playerNextBtn = document.querySelector('[data-action="player-next"]');
const playerRewindBtn = document.querySelector('[data-action="player-rewind"]');
const playerForwardBtn = document.querySelector('[data-action="player-forward"]');
const playerToggleBtn = document.querySelector('[data-action="player-toggle"]');
const playbackRateSelect = document.querySelector('[data-role="playback-rate"]');
const chapterLinksEl = document.querySelector('[data-role="chapter-links"]');
const chapterPanel = document.querySelector('[data-role="chapter-panel"]');
const chapterPanelToggleBtn = document.querySelector('[data-action="toggle-chapters"]');
const chapterPanelCloseBtn = document.querySelector('[data-action="close-chapter-panel"]');
const bodyDataset = document.body.dataset;
const assetBase = bodyDataset.assetBase || '';
const epubUrl = bodyDataset.epubUrl || '';
const reduceMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
let preferSmoothScroll = !reduceMotionQuery.matches;
if (typeof reduceMotionQuery.addEventListener === 'function') {
reduceMotionQuery.addEventListener('change', (event) => {
preferSmoothScroll = !event.matches;
});
} else if (typeof reduceMotionQuery.addListener === 'function') {
reduceMotionQuery.addListener((event) => {
preferSmoothScroll = !event.matches;
});
}
const ACTIVE_CLASS = 'chunk--active';
const PLAYER_SKIP_SECONDS = 15;
const syncCache = new Map();
const setStatus = (message) => {
if (statusEl) {
statusEl.textContent = message;
}
};
const navigateToChapter = (index, options = {}) => {
if (!chapters.length) {
return Promise.resolve(null);
}
const normalizedIndex = Math.min(Math.max(index, 0), chapters.length - 1);
const preservePlayback = options.preservePlayback !== false;
const shouldScroll = options.scroll !== false;
const forcePlay = Boolean(options.forcePlay);
const wasPlaying = audioEl ? !audioEl.paused : false;
const targetShouldPlay = forcePlay || (preservePlayback && wasPlaying);
return displayChapter(normalizedIndex, { scroll: shouldScroll }).then((startTime) => {
if (!audioEl) {
return startTime;
}
const timelineEntry = getChapterTiming(normalizedIndex);
const fallbackStart = timelineEntry && Number.isFinite(timelineEntry.start) ? timelineEntry.start : null;
const resolvedStart = Number.isFinite(startTime) ? startTime : fallbackStart;
if (resolvedStart !== null && Number.isFinite(resolvedStart)) {
audioEl.currentTime = resolvedStart;
}
if (targetShouldPlay) {
audioEl.play().catch(() => {});
}
return resolvedStart;
}).catch((error) => {
console.warn('Failed to navigate to chapter', error);
return null;
});
};
const removeLeadingSlash = (value) => {
if (typeof value !== 'string') {
return value;
}
const normalized = value.replace(/\\/g, '/').replace(/^[\/]+/, '');
if (!normalized) {
return '';
}
const segments = normalized.split('/').filter((segment) => segment && segment !== '.');
if (!segments.length) {
return '';
}
const deduped = [];
segments.forEach((segment) => {
if (!deduped.length || deduped[deduped.length - 1].toLowerCase() !== segment.toLowerCase()) {
deduped.push(segment);
}
});
return deduped.join('/');
};
const normalizeHref = (value) => {
if (!value) {
return '';
}
const raw = String(value);
const pathPart = raw.split('#', 1)[0];
const cleanPart = pathPart.split('?', 1)[0];
if (!cleanPart) {
return '';
}
try {
const decoded = decodeURIComponent(cleanPart);
return removeLeadingSlash(decoded);
} catch (error) {
return removeLeadingSlash(cleanPart);
}
};
let spineHrefList = [];
const spineHrefLookup = new Map();
const refreshSpineLookup = (items) => {
spineHrefList = [];
spineHrefLookup.clear();
if (!Array.isArray(items)) {
return;
}
items.forEach((entry) => {
const href = typeof entry === 'string' ? entry : entry?.href;
const normalized = removeLeadingSlash(href || '');
if (!normalized) {
return;
}
spineHrefList.push(normalized);
const key = normalized.toLowerCase();
if (!spineHrefLookup.has(key)) {
spineHrefLookup.set(key, normalized);
}
});
};
const resolveSpineHref = (value) => {
if (!value) {
return '';
}
const normalized = removeLeadingSlash(value);
if (!normalized) {
return '';
}
const direct = spineHrefLookup.get(normalized.toLowerCase());
if (direct) {
return direct;
}
const segments = normalized.split('/');
for (let index = 1; index < segments.length; index += 1) {
const candidate = segments.slice(index).join('/');
if (!candidate) {
continue;
}
const match = spineHrefLookup.get(candidate.toLowerCase());
if (match) {
return match;
}
}
return normalized;
};
const formatClock = (value) => {
if (!Number.isFinite(value) || value < 0) {
return '--:--';
}
const totalSeconds = Math.floor(value);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
};
const parseSmilTime = (value) => {
if (!value) {
return null;
}
const raw = String(value).trim();
if (!raw) {
return null;
}
if (/^\d+(?:\.\d+)?$/.test(raw)) {
return Number.parseFloat(raw);
}
let match = raw.match(/^(\d+):(\d{2}):(\d{2}(?:\.\d+)?)$/);
if (match) {
const hours = Number.parseInt(match[1], 10);
const minutes = Number.parseInt(match[2], 10);
const seconds = Number.parseFloat(match[3]);
return hours * 3600 + minutes * 60 + seconds;
}
match = raw.match(/^(\d+):(\d{2}(?:\.\d+)?)$/);
if (match) {
const minutes = Number.parseInt(match[1], 10);
const seconds = Number.parseFloat(match[2]);
return minutes * 60 + seconds;
}
match = raw.match(/^([\d.]+)\s*(h|hours?|hr|m|min|minutes?|s|sec|seconds?|ms|millisecond|milliseconds)$/i);
if (match) {
const valueNum = Number.parseFloat(match[1]);
const unit = match[2].toLowerCase();
if (Number.isNaN(valueNum)) {
return null;
}
if (unit.startsWith('h')) {
return valueNum * 3600;
}
if (unit.startsWith('m') && unit !== 'ms' && unit !== 'millisecond' && unit !== 'milliseconds') {
return valueNum * 60;
}
if (unit === 'ms' || unit === 'millisecond' || unit === 'milliseconds') {
return valueNum / 1000;
}
return valueNum;
}
return null;
};
const coerceTime = (value) => {
if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed) {
const parsed = Number.parseFloat(trimmed);
if (Number.isFinite(parsed) && parsed >= 0) {
return parsed;
}
}
}
return null;
};
const fetchEpubAsset = async (url) => {
if (!url) {
throw new Error('Missing EPUB URL');
}
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
Accept: 'application/epub+zip,application/octet-stream;q=0.8,*/*;q=0.5',
},
});
if (!response.ok) {
const error = new Error(`HTTP ${response.status}`);
error.status = response.status;
throw error;
}
const buffer = await response.arrayBuffer();
return buffer;
};
let ensureJsZipPromise = null;
const ensureJsZip = async () => {
if (typeof window !== 'undefined' && window.JSZip) {
return window.JSZip;
}
if (ensureJsZipPromise) {
return ensureJsZipPromise;
}
ensureJsZipPromise = (async () => {
try {
const module = await import('https://cdn.jsdelivr.net/npm/jszip@3.10.1/+esm');
const lib = module?.default || module?.JSZip || module;
if (lib && typeof window !== 'undefined') {
window.JSZip = lib;
}
return lib || null;
} catch (error) {
console.error('Failed to dynamically import JSZip', error);
return null;
}
})();
return ensureJsZipPromise;
};
const buildAssetUrl = (path) => {
if (!path) {
return '';
}
const trimmed = String(path).replace(/^\.\//, '');
const base = `${window.location.origin}${assetBase}`;
try {
const root = base.endsWith('/') ? base : `${base}/`;
const url = new URL(trimmed, root);
return url.toString();
} catch (error) {
const normalized = trimmed.replace(/^\/+/, '');
return `${assetBase}${normalized}`;
}
};
const flattenToc = (entries) => {
const output = [];
const walk = (items) => {
if (!Array.isArray(items)) {
return;
}
items.forEach((item) => {
if (!item) {
return;
}
output.push(item);
if (item.subitems) {
walk(item.subitems);
}
if (item.children) {
walk(item.children);
}
if (item.subnav) {
walk(item.subnav);
}
});
};
walk(entries);
return output;
};
const normalizeChapterList = (items) => {
const normalized = [];
if (!Array.isArray(items)) {
return normalized;
}
items.forEach((entry) => {
if (!entry) {
return;
}
const hrefValue = entry.href || entry.src || '';
const href = typeof hrefValue === 'string' ? hrefValue.trim() : '';
if (!href) {
return;
}
const labelSource = entry.title ?? entry.label ?? entry.name ?? entry.text ?? '';
const title = typeof labelSource === 'string' && labelSource.trim() ? labelSource.trim() : `Chapter ${normalized.length + 1}`;
let sourceIndex = null;
const rawIndex = entry.index ?? entry.order ?? entry.position;
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
sourceIndex = Math.max(0, Math.floor(rawIndex));
} else if (typeof rawIndex === 'string') {
const parsed = Number.parseInt(rawIndex.trim(), 10);
if (Number.isInteger(parsed) && parsed >= 0) {
sourceIndex = parsed;
}
}
if (!Number.isInteger(sourceIndex)) {
sourceIndex = normalized.length;
}
normalized.push({
href,
normalizedHref: normalizeHref(href),
title,
sourceIndex,
});
});
return normalized;
};
const dedupeChapters = (items) => {
const seen = new Set();
const output = [];
items.forEach((item) => {
if (!item || !item.normalizedHref) {
return;
}
if (seen.has(item.normalizedHref)) {
return;
}
seen.add(item.normalizedHref);
output.push(item);
});
return output;
};
const rawDataNode = document.getElementById('reader-data');
let payload = {};
try {
payload = JSON.parse(rawDataNode?.textContent || '{}');
} catch (error) {
payload = {};
}
const baseTitle = typeof payload.title === 'string' && payload.title ? payload.title : 'Book';
document.title = `${baseTitle} · Reader`;
const rawTimingEntries = Array.isArray(payload.chapterTimings) ? payload.chapterTimings : [];
const timingBySourceIndex = new Map();
rawTimingEntries.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return;
}
const rawIndex = entry.index;
let sourceIndex = null;
if (typeof rawIndex === 'number' && Number.isFinite(rawIndex)) {
sourceIndex = Math.max(0, Math.floor(rawIndex));
} else if (typeof rawIndex === 'string') {
const parsed = Number.parseInt(rawIndex.trim(), 10);
if (Number.isInteger(parsed) && parsed >= 0) {
sourceIndex = parsed;
}
}
if (sourceIndex === null) {
return;
}
const timingEntry = {
start: coerceTime(entry.start),
end: coerceTime(entry.end),
title: typeof entry.title === 'string' && entry.title.trim() ? entry.title.trim() : null,
};
timingBySourceIndex.set(sourceIndex, timingEntry);
});
let chapters = dedupeChapters(normalizeChapterList(Array.isArray(payload.chapters) ? payload.chapters : []));
chapters = chapters.map((chapter, idx) => {
const cleanedHref = removeLeadingSlash(chapter.href);
const normalized = chapter.normalizedHref ? removeLeadingSlash(chapter.normalizedHref) : normalizeHref(cleanedHref);
const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx;
return {
...chapter,
href: cleanedHref,
normalizedHref: normalized,
title: chapter.title || `Chapter ${idx + 1}`,
spineHref: chapter.spineHref ? removeLeadingSlash(chapter.spineHref) : null,
sourceIndex,
};
});
chapters = dedupeChapters(chapters);
console.info('[reader] Chapters payload normalized', chapters.map((chapter) => chapter.href));
let chapterTimeline = [];
const rebuildChapterTimings = () => {
chapterTimeline = chapters.map((chapter, idx) => {
const sourceIndex = Number.isInteger(chapter.sourceIndex) ? Number(chapter.sourceIndex) : idx;
const timing = timingBySourceIndex.get(sourceIndex) || null;
return {
sourceIndex,
start: timing ? coerceTime(timing.start) : null,
end: timing ? coerceTime(timing.end) : null,
title: timing && typeof timing.title === 'string' ? timing.title : null,
};
});
};
rebuildChapterTimings();
const getChapterTiming = (index) => {
if (index < 0 || index >= chapterTimeline.length) {
return null;
}
return chapterTimeline[index] || null;
};
let book = null;
let rendition = null;
let currentSync = [];
let activeOverlay = null;
let currentChapterIndex = 0;
let navigationLock = false;
let suppressRelocate = false;
let scheduledSync = 0;
let chapterLinkButtons = [];
let chapterPanelOpen = false;
let chapterPanelRestoreFocus = null;
const focusFirstChapterButton = () => {
if (!chapterPanelOpen || !chapterLinkButtons.length) {
return;
}
const firstInteractive = chapterLinkButtons.find((button) => button instanceof HTMLButtonElement && !button.disabled);
if (firstInteractive) {
try {
firstInteractive.focus({ preventScroll: true });
} catch (error) {
firstInteractive.focus();
}
}
};
const setChapterPanelOpen = (open) => {
if (!chapterPanel) {
chapterPanelOpen = false;
return;
}
chapterPanelOpen = Boolean(open);
chapterPanel.classList.toggle('is-open', chapterPanelOpen);
chapterPanel.setAttribute('aria-hidden', chapterPanelOpen ? 'false' : 'true');
if (chapterPanelToggleBtn) {
chapterPanelToggleBtn.setAttribute('aria-expanded', chapterPanelOpen ? 'true' : 'false');
}
if (!chapterPanelOpen && chapterPanelRestoreFocus && typeof chapterPanelRestoreFocus.focus === 'function') {
try {
chapterPanelRestoreFocus.focus({ preventScroll: true });
} catch (error) {
// ignore focus restoration failure
}
}
if (!chapterPanelOpen) {
chapterPanelRestoreFocus = null;
}
};
const openChapterPanel = () => {
if (!chapterPanel) {
return;
}
if (!chapterPanelOpen) {
const activeElement = document.activeElement;
if (activeElement instanceof HTMLElement && activeElement !== document.body) {
chapterPanelRestoreFocus = activeElement;
} else {
chapterPanelRestoreFocus = chapterPanelToggleBtn instanceof HTMLElement ? chapterPanelToggleBtn : null;
}
}
setChapterPanelOpen(true);
requestAnimationFrame(() => {
focusFirstChapterButton();
});
};
const closeChapterPanel = () => {
setChapterPanelOpen(false);
};
const toggleChapterPanel = () => {
if (chapterPanelOpen) {
closeChapterPanel();
} else {
openChapterPanel();
}
};
const updateChapterLinkState = () => {
if (!chapterLinkButtons.length) {
return;
}
chapterLinkButtons.forEach((button, idx) => {
if (!(button instanceof HTMLButtonElement)) {
return;
}
if (idx === currentChapterIndex) {
button.setAttribute('aria-current', 'true');
} else {
button.removeAttribute('aria-current');
}
});
};
const updatePlaybackControls = () => {
const hasChapters = chapters.length > 0;
const canPrev = hasChapters && currentChapterIndex > 0;
const canNext = hasChapters && currentChapterIndex < chapters.length - 1;
if (playerPrevBtn) {
playerPrevBtn.disabled = !canPrev;
}
if (playerNextBtn) {
playerNextBtn.disabled = !canNext;
}
if (playerRewindBtn) {
playerRewindBtn.disabled = !audioEl;
}
if (playerForwardBtn) {
playerForwardBtn.disabled = !audioEl;
}
if (playerToggleBtn) {
if (!audioEl) {
playerToggleBtn.disabled = true;
playerToggleBtn.textContent = 'Play';
} else {
playerToggleBtn.disabled = false;
playerToggleBtn.textContent = audioEl.paused ? 'Play' : 'Pause';
}
}
if (playbackRateSelect) {
playbackRateSelect.disabled = !audioEl;
if (audioEl) {
const currentRate = Math.max(0.5, Math.min(3, audioEl.playbackRate || 1));
const formattedRate = currentRate.toFixed(2);
let matchFound = false;
Array.from(playbackRateSelect.options).forEach((option) => {
const optionRate = Number.parseFloat(option.value);
if (Number.isFinite(optionRate) && Math.abs(optionRate - currentRate) < 0.01) {
matchFound = true;
}
});
if (!matchFound) {
const customOption = new Option(`${formattedRate}x`, currentRate.toString(), true, true);
playbackRateSelect.append(customOption);
}
playbackRateSelect.value = currentRate.toString();
} else {
playbackRateSelect.value = '1';
}
}
};
const buildChapterLinks = () => {
if (!chapterLinksEl) {
return;
}
chapterLinksEl.innerHTML = '';
chapterLinkButtons = [];
if (!chapters.length) {
const placeholder = document.createElement('span');
placeholder.className = 'reader-chapter-links__empty';
placeholder.textContent = 'No chapters available.';
chapterLinksEl.append(placeholder);
return;
}
chapters.forEach((chapter, idx) => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.chapterIndex = String(idx);
const label = chapter.title || `Chapter ${idx + 1}`;
button.textContent = label;
button.title = label;
chapterLinksEl.append(button);
chapterLinkButtons.push(button);
});
updateChapterLinkState();
if (chapterPanelOpen) {
focusFirstChapterButton();
}
};
const updateStatus = () => {
if (!statusEl) {
return;
}
const parts = [];
if (chapters.length) {
const position = Math.min(currentChapterIndex + 1, chapters.length);
parts.push(`Chapter ${position} of ${chapters.length}`);
} else {
parts.push('No chapters loaded');
}
if (audioEl) {
const currentTime = formatClock(audioEl.currentTime || 0);
const hasDuration = Number.isFinite(audioEl.duration);
if (hasDuration) {
parts.push(`Audio ${currentTime} / ${formatClock(audioEl.duration)}`);
} else {
parts.push(`Audio ${currentTime}`);
}
if (currentSync.length) {
if (activeOverlay) {
parts.push(`Chunk ${activeOverlay.index + 1}/${currentSync.length}`);
} else {
parts.push('Sync ready');
}
} else {
parts.push('Sync unavailable');
}
}
statusEl.textContent = parts.join(' • ');
};
const updateControls = () => {
const hasChapters = chapters.length > 0;
if (prevBtn) {
prevBtn.disabled = !hasChapters || currentChapterIndex <= 0;
}
if (nextBtn) {
nextBtn.disabled = !hasChapters || currentChapterIndex >= chapters.length - 1;
}
if (chapterSelect) {
if (hasChapters) {
chapterSelect.disabled = false;
chapterSelect.value = String(currentChapterIndex);
} else {
chapterSelect.disabled = true;
chapterSelect.value = '';
}
}
if (titleEl) {
titleEl.textContent = baseTitle;
}
if (chapterPanelToggleBtn) {
chapterPanelToggleBtn.disabled = !hasChapters;
if (!hasChapters && chapterPanelOpen) {
closeChapterPanel();
}
}
updatePlaybackControls();
updateChapterLinkState();
};
const highlightChunk = (chunkId, { scroll = false } = {}) => {
if (!rendition) {
return;
}
const contents = rendition.getContents();
contents.forEach((content) => {
const doc = content.document;
if (!doc) {
return;
}
doc.querySelectorAll(`.${ACTIVE_CLASS}`).forEach((node) => {
node.classList.remove(ACTIVE_CLASS);
});
if (!chunkId) {
return;
}
const target = doc.getElementById(chunkId);
if (!target) {
return;
}
target.classList.add(ACTIVE_CLASS);
if (scroll) {
try {
const hostWindow = content.window;
const rect = target.getBoundingClientRect();
const viewportHeight = hostWindow?.innerHeight || doc.documentElement.clientHeight || 0;
if (rect.top < 48 || rect.bottom > viewportHeight - 48) {
target.scrollIntoView({
block: 'center',
behavior: preferSmoothScroll ? 'smooth' : 'auto',
});
}
} catch (error) {
// ignore scrolling errors from detached iframes
}
}
});
};
const findOverlayAt = (time) => {
if (!currentSync.length) {
return null;
}
let low = 0;
let high = currentSync.length - 1;
let candidate = null;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const overlay = currentSync[mid];
if (!overlay) {
break;
}
if (time < overlay.begin) {
high = mid - 1;
continue;
}
if (time > overlay.end) {
low = mid + 1;
continue;
}
candidate = overlay;
break;
}
if (!candidate && low >= 0 && low < currentSync.length) {
const overlay = currentSync[low];
if (overlay && time >= overlay.begin && time <= overlay.end) {
candidate = overlay;
}
}
if (!candidate && high >= 0 && high < currentSync.length) {
const overlay = currentSync[high];
if (overlay && time >= overlay.begin && time <= overlay.end) {
candidate = overlay;
}
}
return candidate;
};
const syncToTime = (time, { force = false, scroll = false } = {}) => {
if (!currentSync.length || !Number.isFinite(time)) {
if (force) {
activeOverlay = null;
highlightChunk(null);
updateStatus();
}
return;
}
const overlay = findOverlayAt(time);
if (!overlay) {
if (force) {
activeOverlay = null;
highlightChunk(null);
updateStatus();
}
return;
}
if (!force && activeOverlay && overlay.fragment === activeOverlay.fragment) {
return;
}
activeOverlay = overlay;
highlightChunk(overlay.fragment, { scroll });
updateStatus();
};
const loadChapterSync = async (href) => {
const key = normalizeHref(href);
if (!key) {
currentSync = [];
activeOverlay = null;
return currentSync;
}
if (syncCache.has(key)) {
currentSync = syncCache.get(key) || [];
activeOverlay = null;
return currentSync;
}
const candidates = [];
const textSwap = key.replace(/\/text\//i, '/smil/');
if (textSwap !== key) {
const maybe = textSwap.replace(/\.xhtml$/i, '.smil');
if (!candidates.includes(maybe)) {
candidates.push(maybe);
}
}
const directSmil = key.replace(/\.xhtml$/i, '.smil');
if (!candidates.includes(directSmil)) {
candidates.push(directSmil);
}
if (!candidates.includes(key)) {
candidates.push(key);
}
let overlays = [];
for (const candidate of candidates) {
const url = buildAssetUrl(candidate);
if (!url) {
continue;
}
try {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
continue;
}
const xmlText = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, 'application/xml');
if (doc.querySelector('parsererror')) {
continue;
}
const parsed = [];
doc.querySelectorAll('par').forEach((par) => {
const textEl = par.querySelector('text');
const audioNode = par.querySelector('audio');
if (!textEl || !audioNode) {
return;
}
const textSrc = textEl.getAttribute('src') || '';
const fragment = textSrc.split('#')[1] || '';
const begin = parseSmilTime(audioNode.getAttribute('clipBegin'));
const endRaw = parseSmilTime(audioNode.getAttribute('clipEnd'));
if (!fragment || begin === null) {
return;
}
const end = endRaw !== null && endRaw >= begin ? endRaw : begin + 0.05;
parsed.push({
fragment,
begin,
end,
id: par.getAttribute('id') || fragment,
index: parsed.length,
});
});
if (parsed.length) {
parsed.sort((a, b) => a.begin - b.begin);
parsed.forEach((item, index) => {
item.index = index;
});
overlays = parsed;
break;
}
} catch (error) {
console.warn('Failed to load SMIL asset', candidate, error);
}
}
currentSync = overlays;
activeOverlay = null;
syncCache.set(key, overlays);
return overlays;
};
const initializeChapters = () => {
if (!chapterSelect) {
return;
}
chapterSelect.innerHTML = '';
if (!chapters.length) {
const option = document.createElement('option');
option.value = '';
option.textContent = 'No chapters';
chapterSelect.append(option);
chapterSelect.disabled = true;
buildChapterLinks();
return;
}
chapterSelect.disabled = false;
chapters.forEach((chapter, idx) => {
const option = document.createElement('option');
option.value = String(idx);
option.textContent = chapter.title || `Chapter ${idx + 1}`;
chapterSelect.append(option);
});
chapterSelect.value = String(currentChapterIndex);
buildChapterLinks();
};
const registerRenditionHooks = () => {
if (!rendition) {
return;
}
const injectedStyles = `:root { color-scheme: dark; }
body { background: transparent !important; color: rgba(226, 232, 240, 0.96) !important; font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif !important; line-height: 1.6; }
a { color: #38bdf8; }
.chunk { transition: background-color 0.25s ease, box-shadow 0.25s ease; }
.chunk.${ACTIVE_CLASS} { background-color: rgba(56, 189, 248, 0.25); box-shadow: 0 0 0 1px rgba(56, 189, 248, 0.5); border-radius: 6px; }
.chunk-group { margin: 0 0 1.1rem 0; }`;
// Inject styling into each EPUB iframe so highlights render consistently.
rendition.hooks.content.register((contents) => {
try {
const doc = contents.document;
if (!doc) {
return;
}
const style = doc.createElement('style');
style.textContent = injectedStyles;
doc.head.appendChild(style);
if (doc.documentElement) {
doc.documentElement.style.background = 'transparent';
}
if (doc.body) {
doc.body.style.background = 'transparent';
}
const handleClick = (event) => {
const target = event.target instanceof Element ? event.target.closest('.chunk') : null;
if (!target) {
return;
}
const chunkId = target.getAttribute('id');
if (!chunkId) {
return;
}
const overlay = currentSync.find((entry) => entry.fragment === chunkId) || null;
if (overlay) {
activeOverlay = overlay;
highlightChunk(chunkId, { scroll: true });
updateStatus();
if (audioEl) {
audioEl.currentTime = overlay.begin;
audioEl.play().catch(() => {});
}
}
};
doc.addEventListener('click', handleClick);
} catch (error) {
console.warn('Failed to decorate EPUB content', error);
}
});
};
const displayChapter = async (index, options = {}) => {
if (!chapters.length || !book || !rendition) {
return;
}
const normalizedIndex = Math.min(Math.max(index, 0), chapters.length - 1);
const target = chapters[normalizedIndex];
if (!target || navigationLock) {
return;
}
navigationLock = true;
let failed = false;
let resolvedStart = null;
setStatus('Loading chapter…');
highlightChunk(null);
activeOverlay = null;
updateStatus();
try {
await book.ready;
const fragment = typeof options.fragment === 'string' ? options.fragment : '';
const baseHref = target.href.split('#')[0];
const displayResolved = resolveSpineHref(target.spineHref || target.href);
const fallbackDisplay = removeLeadingSlash(target.href);
const displayBase = displayResolved || fallbackDisplay;
const displayHref = fragment ? `${displayBase}#${fragment}` : displayBase;
const canonicalSource = fragment ? displayBase : displayHref;
const canonicalCandidate = typeof book.canonical === 'function' ? book.canonical(canonicalSource) : canonicalSource;
const isBlobCanonical = typeof canonicalCandidate === 'string' && canonicalCandidate.startsWith('blob:');
const canonicalHref = isBlobCanonical ? null : removeLeadingSlash(canonicalCandidate);
const syncHref = removeLeadingSlash((canonicalHref || baseHref).split('#')[0]);
console.info('[reader] Displaying chapter', {
requestedIndex: normalizedIndex,
href: target.href,
spineHref: target.spineHref,
displayBase,
displayHref,
canonicalHref,
syncHref,
});
suppressRelocate = true;
await rendition.display(displayHref);
suppressRelocate = false;
currentChapterIndex = normalizedIndex;
updateControls();
const syncTarget = syncHref || target.normalizedHref || removeLeadingSlash(target.href);
await loadChapterSync(syncTarget);
const syncStartCandidate = currentSync.length ? currentSync[0].begin : null;
if (Number.isFinite(syncStartCandidate)) {
const timelineEntry = getChapterTiming(normalizedIndex);
if (timelineEntry) {
timelineEntry.start = syncStartCandidate;
}
const sourceIndex = chapters[normalizedIndex] && Number.isInteger(chapters[normalizedIndex].sourceIndex)
? Number(chapters[normalizedIndex].sourceIndex)
: null;
if (sourceIndex !== null) {
const existingTiming = timingBySourceIndex.get(sourceIndex) || {};
existingTiming.start = syncStartCandidate;
if (chapters[normalizedIndex] && typeof chapters[normalizedIndex].title === 'string') {
existingTiming.title = existingTiming.title || chapters[normalizedIndex].title;
}
timingBySourceIndex.set(sourceIndex, existingTiming);
}
}
const fallbackTiming = getChapterTiming(normalizedIndex);
resolvedStart = Number.isFinite(syncStartCandidate)
? syncStartCandidate
: fallbackTiming && Number.isFinite(fallbackTiming.start)
? fallbackTiming.start
: null;
const shouldScroll = options.scroll ?? Boolean(audioEl && !audioEl.paused);
if (fragment) {
highlightChunk(fragment, { scroll: true });
const overlayMatch = currentSync.find((entry) => entry.fragment === fragment);
if (overlayMatch) {
activeOverlay = overlayMatch;
if (Number.isFinite(overlayMatch.begin)) {
resolvedStart = overlayMatch.begin;
}
}
} else if (audioEl) {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: shouldScroll });
} else {
updateStatus();
}
} catch (error) {
failed = true;
console.error('Failed to display chapter', error);
setStatus('Unable to display this chapter.');
} finally {
suppressRelocate = false;
navigationLock = false;
if (!failed) {
updateStatus();
}
}
return failed ? null : resolvedStart;
};
const scheduleSync = () => {
if (!audioEl) {
return;
}
if (scheduledSync) {
return;
}
scheduledSync = window.requestAnimationFrame(() => {
scheduledSync = 0;
syncToTime(audioEl.currentTime || 0, { scroll: false });
});
};
if (audioEl) {
audioEl.addEventListener('timeupdate', scheduleSync);
audioEl.addEventListener('seeked', () => {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: true });
});
audioEl.addEventListener('loadedmetadata', () => {
updateStatus();
updatePlaybackControls();
});
audioEl.addEventListener('play', () => {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: true });
updateStatus();
updatePlaybackControls();
});
audioEl.addEventListener('pause', () => {
updateStatus();
updatePlaybackControls();
});
audioEl.addEventListener('ended', () => {
activeOverlay = null;
highlightChunk(null);
updateStatus();
updatePlaybackControls();
});
}
const init = async () => {
if (!epubUrl) {
initializeChapters();
updateControls();
setStatus('EPUB asset unavailable for this job.');
return;
}
if (typeof window.ePub !== 'function') {
initializeChapters();
updateControls();
setStatus('EPUB.js failed to load.');
return;
}
const jszipLib = await ensureJsZip();
if (!jszipLib) {
setStatus('JSZip could not be loaded. Please check your network connection.');
return;
}
setStatus('Fetching EPUB…');
let epubPayload = null;
const fetchTimeout = setTimeout(() => {
setStatus('Still fetching EPUB… this may take a moment for large books.');
}, 4000);
try {
epubPayload = await fetchEpubAsset(epubUrl);
console.info('[reader] EPUB fetched', {
byteLength: epubPayload instanceof ArrayBuffer ? epubPayload.byteLength : null,
type: epubPayload ? epubPayload.constructor?.name : null,
});
} catch (error) {
clearTimeout(fetchTimeout);
console.error('Failed to fetch EPUB payload', error);
if (error?.status === 404) {
setStatus('The EPUB package could not be found for this job.');
} else if (error?.status === 403) {
setStatus('Access to the EPUB package was denied. Check your session.');
} else {
setStatus('Unable to fetch the EPUB asset for this job.');
}
return;
}
clearTimeout(fetchTimeout);
if (!epubPayload || !(epubPayload instanceof ArrayBuffer) || epubPayload.byteLength === 0) {
console.error('Fetched EPUB payload is empty or invalid', {
type: typeof epubPayload,
byteLength: epubPayload && typeof epubPayload === 'object' && 'byteLength' in epubPayload ? epubPayload.byteLength : null,
});
setStatus('The EPUB package retrieved from the server was empty.');
return;
}
let epubBlob = null;
try {
epubBlob = new Blob([epubPayload], { type: 'application/epub+zip' });
console.info('[reader] Created EPUB blob', {
size: epubBlob.size,
type: epubBlob.type,
});
} catch (error) {
console.error('Failed to construct Blob from EPUB payload', error);
setStatus('The EPUB data could not be prepared for viewing.');
return;
}
book = window.ePub({
replacements: 'view',
requestCredentials: 'same-origin',
});
setStatus('Loading book…');
let openError = null;
try {
console.info('[reader] Opening book from blob');
await book.open(epubBlob);
console.info('[reader] Book open resolved via blob');
} catch (error) {
openError = error;
console.warn('Primary EPUB open attempt failed, will retry via object URL', error);
}
if (openError) {
let objectUrl = null;
try {
objectUrl = URL.createObjectURL(epubBlob);
console.info('[reader] Opening book from object URL');
await book.open(objectUrl, 'epub');
console.info('[reader] Book open resolved via object URL');
} catch (fallbackError) {
console.error('Fallback EPUB open attempt failed', fallbackError);
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
const errorMessage = fallbackError && fallbackError.message ? fallbackError.message : openError && openError.message;
if (errorMessage) {
setStatus(`Unable to open this EPUB. (${errorMessage})`);
} else {
setStatus('Unable to open this EPUB.');
}
return;
} finally {
if (objectUrl) {
// Delay revocation slightly so the renderer can finish fetching.
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
}
}
}
rendition = book.renderTo('reader-view', {
flow: 'scrolled-doc',
width: '100%',
height: '100%',
spread: 'none',
allowScriptedContent: false,
});
book.on('book:loadFailed', (error) => {
console.error('EPUB engine reported a load failure', error);
setStatus('EPUB content could not be loaded.');
});
registerRenditionHooks();
try {
await book.ready;
console.info('[reader] Book ready resolved');
try {
const spineItems = Array.isArray(book.spine?.items) ? book.spine.items : [];
refreshSpineLookup(spineItems);
console.info('[reader] Spine hrefs', spineHrefList);
} catch (spineError) {
console.warn('[reader] Unable to log spine hrefs', spineError);
}
} catch (error) {
console.error('Book failed to initialize', error);
if (error && error.message) {
setStatus(`Unable to open this EPUB. (${error.message})`);
} else {
setStatus('Unable to open this EPUB.');
}
return;
}
let navigation = null;
try {
navigation = await book.loaded.navigation;
} catch (error) {
console.warn('Navigation unavailable for this EPUB', error);
}
if (!chapters.length && navigation && Array.isArray(navigation.toc)) {
chapters = normalizeChapterList(flattenToc(navigation.toc));
}
chapters = dedupeChapters(chapters);
rebuildChapterTimings();
if (typeof book.canonical === 'function') {
chapters = chapters.map((chapter) => {
const canonicalCandidate = book.canonical(chapter.href) || chapter.href;
const canonicalNormalized = normalizeHref(canonicalCandidate);
const looksBlob = typeof canonicalCandidate === 'string' && canonicalCandidate.startsWith('blob:');
const effectiveHref = removeLeadingSlash(looksBlob ? chapter.href : canonicalCandidate);
const fallbackNormalized = chapter.normalizedHref || normalizeHref(chapter.href);
const effectiveNormalized = !looksBlob && canonicalNormalized ? canonicalNormalized : fallbackNormalized;
return {
...chapter,
href: effectiveHref,
normalizedHref: effectiveNormalized,
spineHref: resolveSpineHref(effectiveHref),
};
});
chapters = dedupeChapters(chapters);
rebuildChapterTimings();
}
chapters = chapters.map((chapter) => ({
...chapter,
spineHref: resolveSpineHref(chapter.spineHref || chapter.href),
}));
currentChapterIndex = Math.min(currentChapterIndex, Math.max(chapters.length - 1, 0));
initializeChapters();
updateControls();
if (!chapters.length) {
setStatus('No readable chapters found in this EPUB.');
return;
}
rendition.on('relocated', (location) => {
if (suppressRelocate || !location || !location.start) {
return;
}
const normalized = normalizeHref(location.start.href);
if (!normalized) {
return;
}
const index = chapters.findIndex((chapter) => chapter.normalizedHref === normalized);
if (index >= 0 && index !== currentChapterIndex) {
currentChapterIndex = index;
updateControls();
const relocateTarget = chapters[index].normalizedHref || removeLeadingSlash(chapters[index].href);
loadChapterSync(relocateTarget).then(() => {
if (audioEl && !audioEl.paused) {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
}
updateStatus();
});
}
});
rendition.on('rendered', () => {
if (audioEl && !audioEl.paused) {
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
}
});
window.addEventListener('resize', () => {
if (rendition && typeof rendition.resize === 'function') {
rendition.resize();
}
});
setStatus('Preparing viewer…');
await displayChapter(currentChapterIndex, { scroll: false, force: true });
updateStatus();
};
document.addEventListener('keydown', (event) => {
if (event.defaultPrevented || event.altKey || event.metaKey || event.ctrlKey) {
return;
}
const target = event.target;
if (target instanceof HTMLElement) {
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) {
return;
}
}
if (event.key === 'ArrowLeft' && currentChapterIndex > 0) {
event.preventDefault();
navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
} else if (event.key === 'ArrowRight' && currentChapterIndex < chapters.length - 1) {
event.preventDefault();
navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
}
});
prevBtn?.addEventListener('click', () => {
if (currentChapterIndex > 0) {
navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
}
});
nextBtn?.addEventListener('click', () => {
if (currentChapterIndex < chapters.length - 1) {
navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
}
});
chapterSelect?.addEventListener('change', (event) => {
const value = Number.parseInt(event.target.value, 10);
if (!Number.isNaN(value)) {
navigateToChapter(value, { preservePlayback: true });
}
});
window.addEventListener('message', (event) => {
if (!audioEl) {
return;
}
if (event.origin && event.origin !== window.location.origin) {
return;
}
const data = event.data;
if (!data || typeof data !== 'object') {
return;
}
if (data.type === 'abogen:reader:pause') {
audioEl.pause();
if (Number.isFinite(data.currentTime) && data.currentTime >= 0) {
audioEl.currentTime = data.currentTime;
}
updateStatus();
updatePlaybackControls();
}
});
playerPrevBtn?.addEventListener('click', () => {
if (currentChapterIndex > 0) {
navigateToChapter(currentChapterIndex - 1, { preservePlayback: true });
}
});
playerNextBtn?.addEventListener('click', () => {
if (currentChapterIndex < chapters.length - 1) {
navigateToChapter(currentChapterIndex + 1, { preservePlayback: true });
}
});
playerRewindBtn?.addEventListener('click', () => {
if (!audioEl) {
return;
}
const current = Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
audioEl.currentTime = Math.max(0, current - PLAYER_SKIP_SECONDS);
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
updateStatus();
});
playerForwardBtn?.addEventListener('click', () => {
if (!audioEl || !Number.isFinite(audioEl.currentTime)) {
return;
}
const duration = Number.isFinite(audioEl.duration) ? audioEl.duration : undefined;
const target = audioEl.currentTime + PLAYER_SKIP_SECONDS;
audioEl.currentTime = duration ? Math.min(duration, target) : target;
syncToTime(audioEl.currentTime || 0, { force: true, scroll: false });
updateStatus();
});
playerToggleBtn?.addEventListener('click', () => {
if (!audioEl) {
return;
}
if (audioEl.paused) {
audioEl.play().catch(() => {});
} else {
audioEl.pause();
}
});
playbackRateSelect?.addEventListener('change', (event) => {
if (!audioEl) {
return;
}
const value = Number.parseFloat(event.target.value);
if (Number.isFinite(value) && value > 0) {
const clamped = Math.max(0.5, Math.min(3, value));
audioEl.playbackRate = clamped;
}
updatePlaybackControls();
});
chapterPanelToggleBtn?.addEventListener('click', (event) => {
event.preventDefault();
toggleChapterPanel();
});
chapterPanelCloseBtn?.addEventListener('click', (event) => {
event.preventDefault();
closeChapterPanel();
if (chapterPanelToggleBtn) {
try {
chapterPanelToggleBtn.focus({ preventScroll: true });
} catch (error) {
chapterPanelToggleBtn.focus();
}
}
});
const handleChapterPanelDismiss = (event) => {
if (!chapterPanelOpen || !chapterPanel) {
return;
}
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (chapterPanel.contains(target)) {
return;
}
if (chapterPanelToggleBtn && chapterPanelToggleBtn.contains(target)) {
return;
}
closeChapterPanel();
};
document.addEventListener('mousedown', handleChapterPanelDismiss);
document.addEventListener('touchstart', handleChapterPanelDismiss);
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && chapterPanelOpen) {
event.preventDefault();
closeChapterPanel();
}
});
chapterLinksEl?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null;
if (!(target instanceof HTMLButtonElement)) {
return;
}
event.preventDefault();
const idx = Number.parseInt(target.dataset.chapterIndex || '', 10);
if (Number.isNaN(idx)) {
return;
}
const shouldResume = audioEl ? !audioEl.paused : false;
navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume }).finally(() => {
if (chapterPanelOpen) {
closeChapterPanel();
}
});
});
updatePlaybackControls();
init().catch((error) => {
console.error('Failed to initialize reader', error);
setStatus('Reader failed to initialize.');
});
</script>
</body>
</html>