mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
- Introduced `auto_prefix_chapter_titles` setting in Job and PendingJob classes to control prefixing of chapter titles with "Chapter". - Updated job detail and settings templates to display and configure the new option. - Enhanced reader.js to manage playback controls, including chapter navigation and playback speed adjustments. - Implemented a new prepare_chapters.html template for chapter selection and configuration during job preparation. - Added tests for chapter title formatting and heading equivalence to ensure correct behavior of the new feature.
1703 lines
55 KiB
HTML
1703 lines
55 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: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 14px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.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 {
|
|
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 {
|
|
background: rgba(56, 189, 248, 0.2);
|
|
border-color: rgba(56, 189, 248, 0.4);
|
|
outline: none;
|
|
}
|
|
|
|
.reader-toolbar button:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.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-wrap: wrap;
|
|
gap: 0.5rem;
|
|
margin: 0.25rem 0;
|
|
max-height: 12.5rem;
|
|
overflow-y: auto;
|
|
padding: 0.25rem 0;
|
|
}
|
|
|
|
.reader-chapter-links button {
|
|
border: 1px solid var(--border);
|
|
background: rgba(30, 41, 59, 0.7);
|
|
color: var(--text);
|
|
border-radius: 999px;
|
|
padding: 0.35rem 0.75rem;
|
|
font-size: 0.85rem;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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">
|
|
<button type="button" data-action="prev">Previous</button>
|
|
<select data-role="reader-chapter" aria-label="Select chapter"></select>
|
|
<div class="reader-toolbar__title" data-role="reader-title">{{ display_title or job.original_filename }}</div>
|
|
<button type="button" data-action="next">Next</button>
|
|
</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-chapter-links" data-role="chapter-links" aria-label="Chapters"></div>
|
|
<div class="reader-status" data-role="reader-status" aria-live="polite">Loading EPUB…</div>
|
|
</div>
|
|
</div>
|
|
<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 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 = [];
|
|
|
|
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();
|
|
};
|
|
|
|
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) {
|
|
const chapter = chapters[currentChapterIndex];
|
|
titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle;
|
|
}
|
|
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();
|
|
});
|
|
|
|
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 });
|
|
});
|
|
|
|
updatePlaybackControls();
|
|
|
|
init().catch((error) => {
|
|
console.error('Failed to initialize reader', error);
|
|
setStatus('Reader failed to initialize.');
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|