tts-voice-creator-clone-and.../static/js/reader.js
mARTin-B78 c15a91d229 Add Casting back-navigation, fix truncated prompt generation (v1.12.91)
Characters/Cast gains a "Casting" button back to the active casting
session, the pipeline stepper renders on the Library section, and the
stepper's Cast Characters stop navigates instead of side-effect-running
sheet generation. Prompt generation: 4096-token budget (1600 truncated
the four-prompt JSON so two fields silently arrived empty), truncated
answers salvage completed fields, all-empty responses fail loudly, and
partial results name the missing prompts. The PDF-extraction progress
pill is enlarged and vertically centered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:43:51 +02:00

2031 lines
90 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Read Aloud — PDF/TXT document reader with live word highlighting ────────
//
// Imports a PDF (rendered to canvas via pdf.js with a highlight overlay on the
// real text) or a plain-text file, then reads it sentence-by-sentence with a
// chosen voice/backend/speed while highlighting the word being spoken.
//
// Per-sentence synthesis state is colour-coded: red = not synthesised,
// yellow = synthesising, green = ready (cached), blue = currently reading.
// PDF view supports zoom (fit-width / fit-height / two-page / ±). Reading
// position is remembered so the same document resumes where it left off.
//
// Reuses existing globals: loadPdfJs() + computeWordTimings() (rehearser.js),
// fetchTtsPreviewBlob() (tts-preview.js), ttsBackendOptions()/availableTtsBackends()
// (settings.js), and $ / escHtml / toast (utils.js).
const readerState = {
mode: null, // 'pdf' | 'text'
title: '',
pdfDoc: null,
pages: [], // [{ pageDiv, overlay, page, base, rendered, renderTask }]
sentences: [], // [{ text, words:[{page,x,top,w,h} | {el}], status, _stat }]
idx: 0,
readingIdx: -1, // the single unit currently shown as "reading" (avoids full scans)
playing: false,
speed: 1,
scale: 1,
zoomMode:'fit-width', // 'fit-width' | 'fit-height' | 'two' | 'custom'
audioCtx: null,
currentSource: null,
raf: null,
blobCache: new Map(), // idx -> Blob
bufCache: new Map(), // idx -> AudioBuffer
gainCache: new Map(), // idx -> normalisation gain
unitsByPage: new Map(),// page -> [unit indices] for lazy status-box creation
baseSentences: [], // raw per-sentence units (before chunk grouping)
chunkMode: 'sentence',// 'sentence' | 'paragraph' | 'page' — synthesis chunk size
normalize: true, // even out loudness between chunks on playback
io: null, // IntersectionObserver for lazy page raster
_seq: 0, // import generation guard
synthRunning: false,
synthCancel: false,
selecting: false, // click-to-select-range mode (stays on until Done/Esc)
selStart: null,
selEnd: null,
selAnchored: false, // true after the first click, waiting for the end click
fileBlob: null, // original imported file (Blob) — kept so PDFs can be re-rendered
docText: '', // original text (for text-mode docs)
savedId: null, // server library doc id when this doc came from / was saved to the library
savedAudioIdx: new Set(), // unit indices whose audio is already on the server (incremental save / lazy fetch)
sourceUploaded: false, // the source document has been uploaded to the server
ocrWorker: null, // lazily-created Tesseract.js worker (terminated after each extraction)
synthStarted: false, // true once any unit leaves 'pending' — gates the red "not synthesised" tint
};
window.readerState = readerState;
const READER_RESUME_KEY = 'reader-resume';
const READER_FMT = 'mp3'; // synthesise + store as MP3 (compact; decodeAudioData plays it)
const READER_BUF_WINDOW = 3; // keep decoded PCM only for ±N units around the playhead
const READER_CANVAS_MARGIN = 2200; // px; raster canvases beyond this from view are released
const READER_IMPORT_YIELD_EVERY = 350;
const READER_PDF_INITIAL_PAGES = 16;
const READER_PDF_INITIAL_MAX_WITHOUT_TEXT = 64;
const READER_OCR_LANGS = 'deu+eng'; // headline OCR: fixed dual-pack (covers German prose + digits/Latin chapter numerals)
const READER_OCR_MIN_GAP_RATIO = 0.08; // treat a text-free band at the top of a page as a possible image heading once it's this tall...
const READER_OCR_MIN_GAP_PX = 40; // ...or at least this many px (whichever is larger)
const READER_OCR_RENDER_SCALE = 2.5; // rasterization DPI multiplier fed to Tesseract
const READER_OCR_MIN_CONFIDENCE = 40; // discard OCR guesses below this confidence (0-100)
function readerCtx() {
if (!readerState.audioCtx) readerState.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (readerState.audioCtx.state === 'suspended') readerState.audioCtx.resume().catch(() => {});
return readerState.audioCtx;
}
function readerYield() {
return new Promise(resolve => setTimeout(resolve, 0));
}
// ── Section enter / leave hooks (wired from nav.js) ─────────────────────────
window.readerOnShow = async function () {
const sel = $('reader-backend-select');
if (sel && (!sel.value || sel.options.length <= 1)) {
// Backends may not have loaded yet (or loaded before this section existed) —
// fetch them on demand so the dropdown fills instead of staying on "Checking…".
if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
typeof refreshTtsBackendAvailability === 'function') {
try { await refreshTtsBackendAvailability(); } catch (_) {}
}
if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
sel.innerHTML = ttsBackendOptions(sel.value);
sel.disabled = false;
}
}
readerUpdateBackendHint();
if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select');
readerRenderLibrary();
};
window.readerStop = function () { readerSaveResume(); readerPersistProgress(); readerStopPlayback(); };
// ── Backend + voice pickers ─────────────────────────────────────────────────
async function readerFetchVoices() {
const btn = $('reader-fetch-voices-btn');
const backend = $('reader-backend-select')?.value;
if (!backend) { toast('No available TTS backend', 'error'); return; }
if (btn) btn.disabled = true;
try {
const raw = await fetch('/api/tts-voices?backend=' + encodeURIComponent(backend)).then(r => r.json());
const ids = (Array.isArray(raw) ? raw : []).map(v => (typeof v === 'string' ? v : (v.id || v.name || v.voice_id))).filter(Boolean);
const sel = $('reader-voice-select'); if (!sel) return;
const prev = sel.value;
if (window.VoicePicker) {
VoicePicker.upgrade('reader-voice-select');
VoicePicker.populate('reader-voice-select', ids);
if (prev && ids.includes(prev)) VoicePicker.setValue('reader-voice-select', prev);
else if (ids.length) VoicePicker.setValue('reader-voice-select', ids[0]);
} else {
sel.innerHTML = '<option value="">— select a voice —</option>';
ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); });
if (prev && ids.includes(prev)) sel.value = prev;
}
toast('Fetched ' + ids.length + ' voices', 'success');
} catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Guidance for steady long-form narration (addresses voice drift between chunks):
// some backends re-sample timbre per request; flag those and suggest remedies.
function readerUpdateBackendHint() {
const el = $('reader-backend-hint'); if (!el) return;
const id = $('reader-backend-select')?.value || '';
const b = (typeof backendById === 'function') ? backendById(id) : null;
const steady = id && ((b && b.style_aware) || /design|custom|kokoro|magpie/i.test(id));
el.innerHTML = steady
? '<span class="mdi mdi-check-circle-outline"></span> Good for long-form narration. For maximum steadiness, increase “Voice consistency”.'
: '<span class="mdi mdi-information-outline"></span> Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise “Voice consistency” to paragraph/page, set a fixed <b>Seed</b> with a low <b>Temperature</b> (e.g. 0.3), keep “Normalise loudness” on, or pick a style-aware backend.';
el.classList.toggle('reader-hint-warn', !steady);
}
// ── Import ──────────────────────────────────────────────────────────────────
function readerTitleFromText(text) {
const first = String(text || '').split(/\r?\n/).map(x => x.trim()).find(Boolean) || 'Pasted text';
return first.replace(/\s+/g, ' ').slice(0, 80) || 'Pasted text';
}
function readerUpdateToolbarVisibility(loaded = !!readerState.sentences.length) {
const toolbar = $('reader-toolbar');
if (toolbar) toolbar.hidden = !loaded;
const pdfOnly = readerState.mode === 'pdf';
const zoom = $('reader-zoom'); if (zoom) zoom.hidden = !pdfOnly;
const legend = document.querySelector('.reader-legend'); if (legend) legend.hidden = !pdfOnly;
}
function readerFinishLoadedDocument({ resume = true, successPrefix = 'Loaded' } = {}) {
if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return false; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
const pf = $('reader-page-from'), pt = $('reader-page-to');
if (pf) { pf.max = n; pf.value = 1; }
if (pt) { pt.max = n; pt.value = n; }
}
}
readerUpdateScopeLabel();
const resumed = resume && readerLoadResume();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast(resumed
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
: successPrefix + ' · ' + readerState.sentences.length + ' sentences — press play', 'success');
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
return true;
}
async function readerImportFile(file) {
if (!file) return;
const name = (file.name || '').toLowerCase();
readerResetDoc();
readerState.title = file.name.replace(/\.[^.]+$/, '');
readerState.fileBlob = file;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
try {
if (name.endsWith('.pdf')) {
toast('Loading PDF…', 'success');
const loaded = await readerLoadPdfSkeleton(file);
if (!loaded) return;
readerShowExtractPrompt(loaded);
} else {
const text = await file.text();
readerState.docText = text;
await readerLoadText(text);
readerFinishLoadedDocument({ successPrefix: 'Loaded' });
}
} catch (e) { toast('Import failed: ' + (e.message || e), 'error'); }
}
// Shown after a fresh PDF import: pages are already visible (rasterized from
// the skeleton stage) but nothing is readable/searchable/castable yet until
// the user explicitly extracts text — this is the pipeline's "PDF → Text"
// step, and it's where the OCR-headings choice belongs since it only matters
// once you're about to extract.
function readerShowExtractPrompt(loaded) {
const dz = $('reader-dropzone'); if (dz) dz.hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
const banner = $('reader-extract-banner');
if (banner) {
banner.hidden = false;
const pc = $('reader-extract-pagecount'); if (pc) pc.textContent = String(loaded.nPages);
}
const btn = $('reader-extract-btn');
if (btn) btn.onclick = () => readerRunExtraction(loaded);
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
toast('PDF loaded · ' + loaded.nPages + ' pages — click "Extract Text" when ready', 'success');
}
async function readerRunExtraction(loaded) {
const btn = $('reader-extract-btn');
const original = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Extracting…'; }
try {
await readerExtractPdfText(loaded);
const banner = $('reader-extract-banner'); if (banner) banner.hidden = true;
readerFinishLoadedDocument({ resume: false, successPrefix: 'Extracted' });
} catch (e) {
toast('Text extraction failed: ' + (e.message || e), 'error');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = original; }
}
}
async function readerImportPastedText() {
const ta = $('reader-paste-text');
const text = (ta?.value || '').trim();
if (!text) { toast('Paste some text first', 'error'); ta?.focus(); return; }
const btn = $('reader-paste-load');
if (btn) btn.disabled = true;
readerResetDoc();
readerState.title = readerTitleFromText(text);
readerState.docText = text;
readerState.fileBlob = null;
readerState.sourceUploaded = false;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = readerState.title;
try {
await readerLoadText(text);
readerFinishLoadedDocument({ resume: false, successPrefix: 'Loaded pasted text' });
} catch (e) {
toast('Import failed: ' + (e.message || e), 'error');
} finally {
if (btn) btn.disabled = false;
}
}
function readerResetDoc() {
readerStopPlayback();
readerState._seq++;
readerState.mode = null;
readerState.pdfDoc = null;
readerState.pages = [];
readerState.sentences = [];
readerState.idx = 0;
readerState.readingIdx = -1;
readerState.scale = 1;
readerState.zoomMode = 'fit-width';
readerState.blobCache.clear();
readerState.bufCache.clear();
readerState.gainCache.clear();
readerState.unitsByPage = new Map();
readerState.baseSentences = [];
readerState.fileBlob = null;
readerState.docText = '';
readerState.savedId = null;
readerState.savedAudioIdx = new Set();
readerState.sourceUploaded = false;
const extractBanner = $('reader-extract-banner'); if (extractBanner) extractBanner.hidden = true;
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
readerState.synthRunning = false;
readerState.synthCancel = false;
readerState.synthStarted = false;
readerSetSelecting(false);
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
const sb = $('reader-synthbar'); if (sb) sb.hidden = true;
const tr = $('reader-transport'); if (tr) tr.hidden = true;
readerUpdateToolbarVisibility(false);
const sp = $('reader-synth-prog'); if (sp) sp.hidden = true;
const si = $('reader-sel-info'); if (si) si.hidden = true;
const doc = $('reader-doc'); if (doc) { doc.innerHTML = ''; doc.classList.remove('two-page', 'reader-selecting', 'reader-synth-started'); }
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs('source');
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = false;
readerClearSearch({ clearInput: true });
}
// Build sentence list from a flat array of word objects (each carries geometry).
function readerBuildSentences(words) {
const sentences = [];
let cur = null;
const endRe = /[.!?]["'”’)\]]?$/;
const abbrev = /^(mr|mrs|ms|dr|prof|sr|jr|vs|etc|e\.g|i\.e|no|vol|st|fig)\.?$/i;
for (const w of words) {
// A detected paragraph break (readerMarkParagraphBreaks, PDF only) always
// ends the current sentence, even mid-punctuation, so the boundary isn't
// lost — it's carried as .paraStart for audiobookScopeText/TTS pacing.
if (w.para && cur && cur.words.length) { sentences.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, paraStart: !!w.para };
cur.words.push(w);
cur.text += (cur.text ? ' ' : '') + w.text;
const isEnd = endRe.test(w.text) && !abbrev.test(w.text.replace(/[^a-z.]/gi, ''));
if ((isEnd && cur.words.length >= 2) || cur.words.length >= 45) {
sentences.push(cur); cur = null;
}
}
if (cur && cur.words.length) sentences.push(cur);
return sentences;
}
// Group base sentences into synthesis units. Larger units = one continuous
// generation = steadier voice (fewer seams where timbre/prosody can drift).
// • sentence — one unit per sentence (most responsive)
// • paragraph — merge sentences within a paragraph (text) / page (PDF), ~500 chars
// • page — merge a whole PDF page (or ~2000-char blocks of text)
function readerGroupUnits(base, mode) {
if (mode === 'sentence' || !base.length) {
return base.map(s => ({ text: s.text, words: s.words, status: 'pending', _stat: null, paraStart: !!s.paraStart }));
}
const maxChars = mode === 'page' ? 2000 : 500;
const isPdf = readerState.mode === 'pdf';
const keyOf = s => isPdf ? (s.words[0]?.page ?? 0) : (s.words[0]?.para ?? 0);
const units = [];
let cur = null;
for (const s of base) {
const k = keyOf(s);
// boundary break: paragraph mode breaks on para/page change; page mode breaks
// only on PDF page change (text page mode flows by size alone)
const boundary = cur && (
(mode === 'paragraph' && k !== cur._key) ||
(mode === 'page' && isPdf && k !== cur._key)
);
const tooLong = cur && cur.words.length && (cur.text.length + s.text.length + 1 > maxChars);
if (boundary || tooLong) { units.push(cur); cur = null; }
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null, _key: k, paraStart: !!s.paraStart };
// A real paragraph break inside a merged (paragraph/page) unit is kept as
// a blank line rather than collapsed to a space, so casting/synthesis can
// still tell paragraphs apart even when several are merged into one unit.
cur.text += !cur.text ? s.text : (s.paraStart ? '\n\n' : ' ') + s.text;
cur.words.push(...s.words);
}
if (cur) units.push(cur);
return units;
}
// Flag the first word of each line that starts a new paragraph, by grouping
// words into visual lines (shared `top`) and comparing consecutive lines'
// vertical gap against the page's typical single-line spacing — a gap clearly
// bigger than normal (paragraph spacing, or the gap after a heading/image)
// marks a paragraph break. This is the only place paragraph structure is
// ever recovered for PDFs; downstream (readerBuildSentences, audiobookScopeText)
// just honours the `.para` flag it sets.
function readerMarkParagraphBreaks(pageWords) {
if (!pageWords.length) return;
const lines = [];
let curLine = null, curTop = null;
for (const w of pageWords) {
if (curLine && Math.abs(w.top - curTop) < w.h * 0.4) curLine.push(w);
else { curLine = [w]; curTop = w.top; lines.push(curLine); }
}
lines[0][0].para = true; // first line on the page always starts a paragraph
if (lines.length < 3) return;
const gaps = [];
for (let i = 1; i < lines.length; i++) gaps.push(lines[i][0].top - lines[i - 1][0].top);
const sorted = [...gaps].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)] || 0;
if (median <= 0) return;
for (let i = 1; i < lines.length; i++) {
if (lines[i][0].top - lines[i - 1][0].top > median * 1.35) lines[i][0].para = true;
}
}
// ── Headline OCR: recover chapter titles baked into the PDF as images ───────
// pdf.js getTextContent() only ever returns real text glyphs, so a headline
// drawn as a raster/vector graphic (common with stylised chapter title pages)
// is otherwise invisible to the reader. When a page's real text starts well
// below its top edge, we assume the gap holds such an image, rasterize just
// that band, and OCR it with a locally-vendored Tesseract.js worker.
async function loadTesseract() {
if (window.Tesseract) return;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/static/js/tesseract/tesseract.min.js';
s.onload = resolve; s.onerror = reject;
document.head.appendChild(s);
});
}
async function readerGetOcrWorker() {
if (readerState.ocrWorker) return readerState.ocrWorker;
await loadTesseract();
readerState.ocrWorker = await Tesseract.createWorker(READER_OCR_LANGS, 1, {
workerPath: '/static/js/tesseract/worker.min.js',
corePath: '/static/js/tesseract/tesseract-core-simd-lstm.wasm.js',
langPath: '/static/js/tesseract/lang',
gzip: true,
});
return readerState.ocrWorker;
}
// Rasterize the top `gapPx` (scale-1 px) of `page` and OCR it, returning
// synthetic word entries (same shape as real getTextContent words) spread
// across the band so they slot into the normal sentence/highlight pipeline.
async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
let crop;
try {
const worker = await readerGetOcrWorker();
const viewport = page.getViewport({ scale: READER_OCR_RENDER_SCALE });
// The band is at the top of the page, so a canvas that is only cropH tall
// captures it directly — the 2D context clips the rest of the render, so
// we never allocate or rasterize the remaining ~90% of the page.
const cropH = Math.max(Math.round(gapPx * READER_OCR_RENDER_SCALE), 1);
crop = document.createElement('canvas');
crop.width = viewport.width;
crop.height = cropH;
await page.render({ canvasContext: crop.getContext('2d'), viewport }).promise;
const { data } = await worker.recognize(crop);
const text = (data.text || '').replace(/\s+/g, ' ').trim();
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return [];
const tokens = text.split(' ').filter(Boolean);
const bandH = Math.max(gapPx - 4, 10);
const avgCharW = Math.max((base.width - 16) / text.length, 4);
const words = [];
let x = 8;
for (const tok of tokens) {
const w = Math.max(tok.length * avgCharW, 10);
words.push({ page: pageIdx, x, top: 4, w, h: bandH, text: tok, ocr: true });
x += w + avgCharW;
}
return words;
} catch (e) {
console.warn('Heading OCR failed', e);
return [];
} finally {
if (crop) { crop.width = 0; crop.height = 0; }
}
}
// ── PDF import: build scale-1 geometry; render lazily at current scale ───────
// Loading a PDF is two real stages so the pipeline stepper's "PDF" and "Text"
// stops mean something: readerLoadPdfSkeleton() opens the file and rasterizes
// pages (fast, no text layer yet — you can already scroll/zoom/look at it),
// then readerExtractPdfText() is a separate, explicit action that runs
// getTextContent() + heading OCR + sentence-building. A freshly-imported PDF
// stops after the skeleton and shows an "Extract Text" prompt; reopening an
// already-known document (library restore) runs both stages back to back via
// readerLoadPdf() since there's nothing to "decide" on a document you've
// already extracted before.
async function readerLoadPdfSkeleton(file) {
await loadPdfJs();
const seq = readerState._seq;
const ab = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: ab }).promise;
if (seq !== readerState._seq) return null;
readerState.mode = 'pdf';
readerState.pdfDoc = pdf;
const doc = $('reader-doc');
const nPages = pdf.numPages;
// Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
const page1 = await pdf.getPage(1);
if (seq !== readerState._seq) return null;
const base1 = page1.getViewport({ scale: 1 });
// Render empty skeleton pages instantly so the user can scroll
for (let p = 1; p <= nPages; p++) {
const pageDiv = document.createElement('div');
pageDiv.className = 'reader-page';
// set initial size so scrollbar reflects full document size instantly
pageDiv.style.width = base1.width + 'px';
pageDiv.style.height = base1.height + 'px';
const overlay = document.createElement('div');
overlay.className = 'reader-overlay';
pageDiv.appendChild(overlay);
doc.appendChild(pageDiv);
// Use base1 as placeholder geometry until actual page is parsed
readerState.pages.push({ pageDiv, overlay, page: null, base: base1, rendered: false, renderTask: null });
if (p % 50 === 0) await readerYield();
}
// Apply zoom and rasterize immediately so the user sees the book!
readerApplyZoom('fit-width');
readerSetupLazyRaster();
return { pdf, page1, nPages };
}
async function readerExtractPdfText(loaded) {
const seq = readerState._seq;
const pdf = loaded.pdf, page1 = loaded.page1, nPages = loaded.nPages;
const progress = readerShowParseProgress(nPages);
const ocrHeadingsEnabled = $('reader-ocr-headings')?.checked !== false;
// Sentences/units are built incrementally per page (page-local) and appended.
// We yield on every page to keep the browser buttery smooth for scrolling.
for (let p = 1; p <= nPages; p++) {
// If page1 was already fetched, reuse it
const page = p === 1 ? page1 : await pdf.getPage(p);
if (seq !== readerState._seq) { progress.done(); return; }
const base = page.getViewport({ scale: 1 }); // actual geometry reference
const pageIdx = p - 1;
const pgState = readerState.pages[pageIdx];
// Update the skeleton with actual page data
pgState.page = page;
pgState.base = base;
pgState.pageDiv.style.width = (base.width * readerState.scale) + 'px';
pgState.pageDiv.style.height = (base.height * readerState.scale) + 'px';
if (readerIsPageNearView(pgState)) readerRenderPage(pageIdx);
// Per-word geometry in scale-1 device px (top-left origin) → multiply by scale later
const content = await page.getTextContent();
if (seq !== readerState._seq) return;
await readerYield();
const pageWords = [];
for (let itemIdx = 0; itemIdx < content.items.length; itemIdx++) {
const item = content.items[itemIdx];
const str = (item.str || '');
if (!str.trim()) continue;
const m = pdfjsLib.Util.transform(base.transform, item.transform);
const h = Math.hypot(m[2], m[3]) || Math.hypot(m[0], m[1]) || 12;
const x = m[4];
const top = m[5] - h;
const w = (item.width || 0);
const tokens = str.split(/(\s+)/); // keep spaces for width proportion
const totalLen = str.length || 1;
let offX = x;
for (const tok of tokens) {
const tw = (tok.length / totalLen) * w;
if (tok.trim()) pageWords.push({ page: pageIdx, x: offX, top, w: Math.max(tw, 2), h, text: tok });
offX += tw;
}
if (itemIdx && itemIdx % READER_IMPORT_YIELD_EVERY === 0) await readerYield();
}
// A tall text-free band at the top of the page likely holds an image
// heading (chapter title graphics) that getTextContent() can't see.
if (ocrHeadingsEnabled) {
// Plain loop, not Math.min(...map()) — spreading a word-dense page's
// array into arguments allocates per page and can overflow the arg limit.
let gapPx = base.height;
for (const w of pageWords) if (w.top < gapPx) gapPx = w.top;
if (gapPx > Math.max(base.height * READER_OCR_MIN_GAP_RATIO, READER_OCR_MIN_GAP_PX)) {
const ocrWords = await readerOcrPageHeading(page, base, pageIdx, gapPx);
if (seq !== readerState._seq) return;
if (ocrWords.length) pageWords.unshift(...ocrWords);
}
}
readerMarkParagraphBreaks(pageWords);
// Build + append this page's sentences and units
const pageBase = readerBuildSentences(pageWords);
const pageUnits = readerGroupUnits(pageBase, readerState.chunkMode);
const startUnit = readerState.sentences.length;
readerState.baseSentences.push(...pageBase);
readerState.sentences.push(...pageUnits);
const idxs = [];
for (let u = startUnit; u < readerState.sentences.length; u++) idxs.push(u);
readerState.unitsByPage.set(pageIdx, idxs);
// Yield to the event loop EVERY page so scrolling remains 60fps
progress.update(p);
await new Promise(r => setTimeout(r, 0));
}
progress.done();
// Free the Tesseract worker's WASM/lang-data memory (tens of MB) now that
// extraction is over; the next import recreates it lazily.
if (readerState.ocrWorker) {
try { readerState.ocrWorker.terminate(); } catch (_) {}
readerState.ocrWorker = null;
}
}
// Convenience wrapper used by restore paths (library reopen) where extraction
// should just happen automatically — there's nothing to decide on a document
// you've already read and extracted before.
async function readerLoadPdf(file) {
const loaded = await readerLoadPdfSkeleton(file);
if (!loaded) return;
await readerExtractPdfText(loaded);
}
// Floating parse-progress pill for big PDFs — sticky at ~mid-height of the
// document pane and sized to be legible from across the room, since a 300-page
// extraction can take a while and this is the only "still working" signal.
function readerShowParseProgress(total) {
const doc = $('reader-doc'); if (!doc) return { update() {}, done() {} };
const el = document.createElement('div');
el.className = 'reader-parsing';
el.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:26px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
el.style.cssText = 'position:sticky; top:40vh; z-index:100; margin:0 auto -64px; background:var(--accent); color:#fff; padding:14px 28px; border-radius:32px; font-size:18px; display:inline-flex; align-items:center; gap:12px; box-shadow:0 6px 24px rgba(0,0,0,.35); font-weight:700;';
doc.insertBefore(el, doc.firstChild);
const label = el.querySelector('span:last-child');
return {
update(p) { if (label) label.textContent = 'Reading PDF… page ' + p + ' / ' + total; },
done() { el.remove(); },
};
}
// ── Zoom ─────────────────────────────────────────────────────────────────────
function readerComputeScale(mode) {
const doc = $('reader-doc');
if (!doc || !readerState.pages.length) return 1;
let maxW = 0, maxH = 0;
for (const p of readerState.pages) {
if (p.base) {
if (p.base.width > maxW) maxW = p.base.width;
if (p.base.height > maxH) maxH = p.base.height;
}
}
if (!maxW) return 1;
const padW = 36, gap = 16;
const availW = (doc.clientWidth || 900) - padW;
const availH = (doc.clientHeight || 600) - 36;
if (mode === 'fit-width') return Math.max(0.2, availW / maxW);
if (mode === 'fit-height') return Math.max(0.2, availH / maxH);
if (mode === 'two') return Math.max(0.2, (availW - gap) / 2 / maxW);
return readerState.scale;
}
function readerApplyZoom(mode) {
if (!readerState.pages.length) return;
if (mode) readerState.zoomMode = mode;
if (mode && mode !== 'custom') readerState.scale = readerComputeScale(mode);
const scale = readerState.scale;
const doc = $('reader-doc');
doc.classList.toggle('two-page', readerState.zoomMode === 'two');
readerState.pages.forEach(pg => {
pg.pageDiv.style.width = (pg.base.width * scale) + 'px';
pg.pageDiv.style.height = (pg.base.height * scale) + 'px';
const old = pg.pageDiv.querySelector('canvas.reader-canvas');
if (old) old.remove();
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
pg.rendered = false;
});
// reposition only the status boxes that actually exist (lazy/visible pages)
readerState.pages.forEach(pg => pg.overlay.querySelectorAll('.reader-stat').forEach(el => {
const i = parseInt(el.dataset.si); if (!isNaN(i)) readerPaintStatus(i);
}));
readerPaintSearchHighlights();
if (readerState.idx < readerState.sentences.length) readerHighlightSentence(readerState.sentences[readerState.idx]);
const pct = $('reader-zoom-pct'); if (pct) pct.textContent = Math.round(scale * 100) + '%';
readerRenderVisible();
}
function readerSetupLazyRaster() {
if (readerState.io) readerState.io.disconnect();
readerState.io = new IntersectionObserver(entries => {
entries.forEach(en => {
if (!en.isIntersecting) return;
const idx = readerState.pages.findIndex(p => p.pageDiv === en.target);
if (idx >= 0) readerRenderPage(idx);
});
}, { root: $('reader-doc'), rootMargin: '600px 0px' });
readerState.pages.forEach(p => readerState.io.observe(p.pageDiv));
}
function readerRenderVisible() {
const doc = $('reader-doc'); if (!doc) return;
readerState.pages.forEach((pg, i) => {
if (readerIsPageNearView(pg, 800)) readerRenderPage(i);
});
readerEvictCanvases();
}
function readerIsPageNearView(pg, margin = 800) {
const doc = $('reader-doc');
if (!doc || !pg?.pageDiv) return false;
const dr = doc.getBoundingClientRect();
const r = pg.pageDiv.getBoundingClientRect();
return r.bottom > dr.top - margin && r.top < dr.bottom + margin;
}
// Release canvas bitmaps for pages far outside the viewport so a long book's
// raster memory stays bounded; they re-render when scrolled back into view.
function readerEvictCanvases(dr) {
const doc = $('reader-doc'); if (!doc) return;
dr = dr || doc.getBoundingClientRect();
readerState.pages.forEach(pg => {
if (!pg.rendered) return;
const r = pg.pageDiv.getBoundingClientRect();
if (r.bottom < dr.top - READER_CANVAS_MARGIN || r.top > dr.bottom + READER_CANVAS_MARGIN) {
if (pg.renderTask) { try { pg.renderTask.cancel(); } catch (_) {} pg.renderTask = null; }
const c = pg.pageDiv.querySelector('canvas.reader-canvas');
if (c) c.remove();
pg.rendered = false; // allow re-raster when it returns to view
}
});
}
async function readerRenderPage(idx) {
const pg = readerState.pages[idx];
if (!pg || pg.rendered || !pg.page) return;
pg.rendered = true; // claim immediately to avoid double render
const scale = readerState.scale;
const viewport = pg.page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.className = 'reader-canvas';
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
pg.pageDiv.insertBefore(canvas, pg.overlay);
readerCreatePageStatus(idx); // create this page's status boxes lazily (cheap for big books)
try {
pg.renderTask = pg.page.render({ canvasContext: canvas.getContext('2d'), viewport });
await pg.renderTask.promise;
} catch (_) { pg.rendered = false; canvas.remove(); }
finally { pg.renderTask = null; }
}
// ── Text import: render word spans, highlight via classes ────────────────────
async function readerLoadText(text) {
readerState.mode = 'text';
readerState.docText = text; // keep the source so it can be saved to the library
const doc = $('reader-doc');
const pane = document.createElement('div');
pane.className = 'reader-text';
doc.appendChild(pane);
const words = [];
const paras = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
const frag = document.createDocumentFragment();
let renderedWords = 0;
for (let pi = 0; pi < paras.length; pi++) {
const para = paras[pi];
const pEl = document.createElement('p');
pEl.className = 'reader-text-para';
const lines = para.split('\n');
for (let li = 0; li < lines.length; li++) {
const line = lines[li];
const toks = line.trim().split(/\s+/);
for (const tok of toks) {
if (!tok) continue;
const span = document.createElement('span');
span.className = 'reader-word';
span.textContent = tok;
pEl.appendChild(span);
pEl.appendChild(document.createTextNode(' '));
words.push({ el: span, text: tok, page: 0, para: pi });
renderedWords++;
if (renderedWords % READER_IMPORT_YIELD_EVERY === 0) await readerYield();
}
if (li < lines.length - 1) pEl.appendChild(document.createElement('br'));
}
frag.appendChild(pEl);
if (pi && pi % 20 === 0) await readerYield();
}
pane.appendChild(frag);
readerState.baseSentences = readerBuildSentences(words);
await readerYield();
readerState.sentences = readerGroupUnits(readerState.baseSentences, readerState.chunkMode);
pane.addEventListener('click', e => {
const span = e.target.closest('.reader-word'); if (!span) return;
const si = readerState.sentences.findIndex(s => s.words.some(w => w.el === span));
if (si < 0) return;
if (readerState.selecting) readerPickSentence(si);
else readerJumpTo(si);
});
}
// ── Per-sentence synthesis-state visualization ───────────────────────────────
function readerSentBBox(sentence) {
const pageIdx = sentence.words[0]?.page ?? 0;
const on = sentence.words.filter(w => w.page === pageIdx);
return {
page: pageIdx,
x: Math.min(...on.map(w => w.x)),
top: Math.min(...on.map(w => w.top)),
w: Math.max(...on.map(w => w.x + w.w)) - Math.min(...on.map(w => w.x)),
h: Math.max(...on.map(w => w.top + w.h)) - Math.min(...on.map(w => w.top)),
};
}
// Index units by their page so status boxes can be created lazily per page
// (creating thousands of overlay divs up front would jank big books).
function readerBuildUnitIndex() {
readerState.unitsByPage = new Map();
if (readerState.mode !== 'pdf') return;
readerState.sentences.forEach((s, i) => {
const pg = s.words[0]?.page ?? 0;
if (!readerState.unitsByPage.has(pg)) readerState.unitsByPage.set(pg, []);
readerState.unitsByPage.get(pg).push(i);
});
}
// Create + paint the status boxes for one page (called when the page rasters).
function readerCreatePageStatus(pageIdx) {
if (readerState.mode !== 'pdf') return;
const pg = readerState.pages[pageIdx]; if (!pg) return;
const idxs = readerState.unitsByPage?.get(pageIdx) || [];
for (const i of idxs) {
const s = readerState.sentences[i];
if (!s || s._stat) continue;
const el = document.createElement('div');
el.className = 'reader-stat';
el.dataset.si = i;
pg.overlay.appendChild(el);
s._stat = el;
readerPaintStatus(i);
}
}
// effective colour: reading wins, else cached/synth/pending
function readerEffectiveStatus(s) { return s.status; }
function readerSetStatus(idx, status) {
const s = readerState.sentences[idx]; if (!s) return;
s.status = status;
if (status !== 'pending' && !readerState.synthStarted) {
readerState.synthStarted = true;
$('reader-doc')?.classList.add('reader-synth-started');
}
readerPaintStatus(idx);
}
function readerPaintStatus(idx) {
const s = readerState.sentences[idx]; if (!s) return;
const eff = readerEffectiveStatus(s);
if (readerState.mode === 'pdf') {
if (!s._stat) return;
const b = readerSentBBox(s);
const scale = readerState.scale;
Object.assign(s._stat.style, { left: (b.x * scale) + 'px', top: (b.top * scale) + 'px', width: (b.w * scale) + 'px', height: (b.h * scale) + 'px' });
s._stat.className = 'reader-stat reader-stat-' + eff;
} else {
s.words.forEach(w => {
if (!w.el) return;
w.el.classList.remove('stat-pending', 'stat-synth', 'stat-ready', 'stat-reading');
w.el.classList.add('stat-' + eff);
});
}
}
// ── Active reading highlight (word + scroll) ─────────────────────────────────
let _readerWordBox = null;
function readerEnsureBox() {
if (!_readerWordBox) { _readerWordBox = document.createElement('div'); _readerWordBox.className = 'reader-hl-word'; }
}
function readerClearHighlights() {
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
} else if (_readerWordBox) {
_readerWordBox.hidden = true;
}
}
// Scroll ONLY the #reader-doc pane (never the window) so the active text stays
// inside the visible frame and not under the app header. Keeps the target at
// ~38% from the top when it would otherwise be clipped top or bottom.
function readerEnsureVisible(el) {
const doc = $('reader-doc'); if (!doc || !el) return;
const dr = doc.getBoundingClientRect();
const er = el.getBoundingClientRect();
if (er.top < dr.top + 70 || er.bottom > dr.bottom - 50) {
const delta = (er.top - dr.top) - doc.clientHeight * 0.38;
doc.scrollTo({ top: doc.scrollTop + delta, behavior: 'smooth' });
}
}
function readerHighlightSentence(sentence) {
if (!sentence) return;
if (readerState.mode === 'text') readerEnsureVisible(sentence.words[0]?.el);
else if (sentence._stat) readerEnsureVisible(sentence._stat);
}
function readerHighlightWord(sentence, wi) {
const w = sentence.words[wi]; if (!w) return;
if (readerState.mode === 'text') {
document.querySelectorAll('.reader-word.is-word').forEach(el => el.classList.remove('is-word'));
if (w.el) { w.el.classList.add('is-word'); readerEnsureVisible(w.el); }
return;
}
readerEnsureBox();
const pg = readerState.pages[w.page]; if (!pg) return;
const scale = readerState.scale;
if (_readerWordBox.parentNode !== pg.overlay) pg.overlay.appendChild(_readerWordBox);
Object.assign(_readerWordBox.style, { left: (w.x * scale) + 'px', top: (w.top * scale) + 'px', width: (w.w * scale) + 'px', height: (w.h * scale) + 'px' });
_readerWordBox.hidden = false;
readerEnsureVisible(_readerWordBox);
}
// ── Scope: select range / page range / all + batch synthesis ─────────────────
// Select mode STAYS ON until the user explicitly finishes (Done button / Esc).
// Step 1: click a start sentence (the "anchor"). Step 2: click the end sentence.
// The anchor pulses while waiting; after a range is set, the next click starts a
// fresh anchor so you can keep refining without leaving the mode.
function readerSetSelecting(on) {
readerState.selecting = !!on;
readerState.selAnchored = false;
const doc = $('reader-doc'); if (doc) doc.classList.toggle('reader-selecting', readerState.selecting);
const btn = $('reader-select-toggle'); if (btn) btn.classList.toggle('active', readerState.selecting);
const lbl = $('reader-select-label'); if (lbl) lbl.textContent = readerState.selecting ? 'Done' : 'Select range';
const ic = $('reader-select-icon'); if (ic) ic.className = 'mdi ' + (readerState.selecting ? 'mdi-check' : 'mdi-cursor-default-click-outline');
readerSelHint(readerState.selecting ? 'Click the <b>start</b> sentence…' : '');
if (!readerState.selecting) readerPaintSelection(); // drop the transient anchor styling
}
function readerSelHint(html) {
const h = $('reader-sel-hint'); if (!h) return;
h.innerHTML = html; h.hidden = !html;
}
function readerPickSentence(si) {
if (!readerState.selAnchored) {
// start a fresh range
readerState.selStart = si; readerState.selEnd = si; readerState.selAnchored = true;
readerSelHint('Start at sentence <b>' + (si + 1) + '</b> — now click the <b>end</b> sentence');
} else {
readerState.selEnd = si; readerState.selAnchored = false;
const r = readerSelRange();
readerSelHint('Selected <b>' + (r[1] - r[0] + 1) + '</b> sentences — click a new start, or <b>Done</b>');
}
readerPaintSelection();
readerUpdateScopeLabel();
}
function readerClearSelection() {
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
readerPaintSelection();
readerUpdateScopeLabel();
if (readerState.selecting) readerSelHint('Click the <b>start</b> sentence…');
}
function readerSelRange() {
if (readerState.selStart === null || readerState.selEnd === null) return null;
return [Math.min(readerState.selStart, readerState.selEnd), Math.max(readerState.selStart, readerState.selEnd)];
}
function readerPaintSelection() {
const range = readerSelRange();
const inSel = i => range && i >= range[0] && i <= range[1];
// While waiting for the second click, the lone start sentence is the "anchor".
const anchorI = (readerState.selAnchored && range && range[0] === range[1]) ? range[0] : -1;
readerState.sentences.forEach((s, i) => {
const sel = inSel(i) && i !== anchorI;
const anchor = i === anchorI;
if (readerState.mode === 'pdf') {
if (s._stat) { s._stat.classList.toggle('sel', sel); s._stat.classList.toggle('sel-anchor', anchor); }
} else {
s.words.forEach(w => { if (w.el) { w.el.classList.toggle('in-sel', sel); w.el.classList.toggle('sel-anchor', anchor); } });
}
});
}
// Indices to act on: an active selection wins, else PDF page range, else everything.
function readerScopeIndices() {
const all = readerState.sentences.map((_, i) => i);
const range = readerSelRange();
if (range) return all.filter(i => i >= range[0] && i <= range[1]);
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
let from = parseInt($('reader-page-from')?.value) || 1;
let to = parseInt($('reader-page-to')?.value) || n;
from = Math.max(1, Math.min(from, n));
to = Math.max(from, Math.min(to, n));
return all.filter(i => { const p = (readerState.sentences[i].words[0]?.page ?? 0) + 1; return p >= from && p <= to; });
}
return all;
}
function readerUpdateScopeLabel() {
const scopeEl = $('reader-synth-scope');
const range = readerSelRange();
if (scopeEl) {
if (range) scopeEl.textContent = 'selection';
else if (readerState.mode === 'pdf') scopeEl.textContent = 'pages';
else scopeEl.textContent = 'all';
}
const info = $('reader-sel-info'), txt = $('reader-sel-text');
if (info) info.hidden = !range;
if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')';
}
// Synthesise the given sentence indices (skips already-cached). Returns count done.
async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i));
if (!targets.length || readerState.synthRunning) return 0;
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) { toast('Pick a voice first', 'error'); return 0; }
if (!backend) { toast('No TTS backend selected', 'error'); return 0; }
const instruct = $('reader-instruct')?.value.trim() || '';
readerState.synthRunning = true; readerState.synthCancel = false;
const prog = $('reader-synth-prog'); if (prog) prog.hidden = false;
const total = targets.length; let done = 0;
const update = () => {
const f = $('reader-synth-fill'); if (f) f.style.width = (done / total * 100) + '%';
const l = $('reader-synth-label'); if (l) l.textContent = done + ' / ' + total;
};
update();
const queue = targets.slice();
const worker = async () => {
while (queue.length && !readerState.synthCancel) {
const i = queue.shift();
if (readerState.blobCache.has(i)) { done++; update(); continue; }
if (readerState.sentences[i].status === 'pending') readerSetStatus(i, 'synth');
try {
const blob = await fetchTtsPreviewBlob(voice, readerState.sentences[i].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(i, blob);
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'ready');
} catch (_) {
if (readerState.sentences[i].status === 'synth') readerSetStatus(i, 'pending');
}
done++; update();
}
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
readerState.synthRunning = false;
if (prog) prog.hidden = true;
return done;
}
async function readerSynthAll() {
const targets = readerScopeIndices().filter(i => !readerState.blobCache.has(i));
if (!targets.length) { toast('Selected range is already synthesised', 'success'); return; }
const done = await readerSynthIndices(targets);
if (done || !readerState.synthCancel) toast(readerState.synthCancel ? 'Synthesis cancelled (' + done + ' done)' : 'Synthesised ' + done + ' sentences', readerState.synthCancel ? 'error' : 'success');
}
// ── Export as MP3 (per page, or per sentence) with meaningful filenames ──────
function readerSafeName(s) {
return (s || 'audio').replace(/[\/\\:*?"<>|]+/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60) || 'audio';
}
function readerPad(n) { return String(n).padStart(2, '0'); }
function readerPageOf(i) { return readerState.mode === 'pdf' ? (readerState.sentences[i].words[0]?.page ?? 0) + 1 : 1; }
function readerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1500);
}
const _readerDelay = ms => new Promise(r => setTimeout(r, ms));
async function readerExport(mode) {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
const indices = readerScopeIndices();
const missing = indices.filter(i => !readerState.blobCache.has(i));
if (missing.length) {
toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
await readerSynthIndices(missing);
if (readerState.synthCancel) { toast('Export cancelled', 'error'); return; }
}
const ready = indices.filter(i => readerState.blobCache.has(i));
if (!ready.length) { toast('Nothing to export', 'error'); return; }
const title = readerSafeName(readerState.title);
if (mode === 'sentence') {
const perPage = {};
for (const i of ready) {
const pg = readerPageOf(i);
perPage[pg] = (perPage[pg] || 0) + 1;
const name = readerState.mode === 'pdf'
? `${title} - p${readerPad(pg)} - ${readerPad(perPage[pg])}.mp3`
: `${title} - ${readerPad(perPage[pg])}.mp3`;
readerDownload(readerState.blobCache.get(i), name);
await _readerDelay(350);
}
toast('Exported ' + ready.length + ' MP3 files', 'success');
return;
}
// mode === 'page' → one combined MP3 per page (text mode → one file for the doc)
const byPage = new Map();
ready.forEach(i => { const pg = readerPageOf(i); if (!byPage.has(pg)) byPage.set(pg, []); byPage.get(pg).push(i); });
let files = 0;
for (const [pg, idxs] of [...byPage.entries()].sort((a, b) => a[0] - b[0])) {
const blob = new Blob(idxs.map(i => readerState.blobCache.get(i)), { type: 'audio/mpeg' });
const name = readerState.mode === 'pdf' ? `${title} - p${readerPad(pg)}.mp3` : `${title}.mp3`;
readerDownload(blob, name);
files++;
await _readerDelay(400);
}
toast('Exported ' + files + (readerState.mode === 'pdf' ? ' page MP3 file(s)' : ' MP3'), 'success');
}
// ── Chunk mode (voice consistency) ───────────────────────────────────────────
function readerRechunk(mode) {
readerState.chunkMode = mode;
if (!readerState.baseSentences.length) return;
readerStopPlayback();
// drop synthesised audio (chunk text changed) and old per-unit status boxes
readerState.blobCache.clear(); readerState.bufCache.clear(); readerState.gainCache.clear();
readerState.savedAudioIdx = new Set(); // chunking changed → previously-saved audio no longer aligns
readerState.pages.forEach(p => p.overlay.querySelectorAll('.reader-stat').forEach(e => e.remove()));
readerClearSelection();
readerState.sentences = readerGroupUnits(readerState.baseSentences, mode);
readerBuildUnitIndex();
// recreate status boxes only for pages already rendered (rest come on raster)
readerState.pages.forEach((p, i) => { if (p.rendered) readerCreatePageStatus(i); });
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.idx = 0;
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[0]);
toast('Voice consistency: ' + mode + ' — audio cleared, re-synthesise', 'success');
}
// ── Generation params (seed / temperature) + loudness normalisation ──────────
function readerGenParams() {
const out = {};
const seed = $('reader-seed')?.value.trim();
const temp = $('reader-temp')?.value.trim();
const nspd = $('reader-tts-speed')?.value.trim();
if (seed !== '' && seed != null && !isNaN(+seed)) out.seed = parseInt(seed, 10);
if (temp !== '' && temp != null && !isNaN(+temp)) out.temperature = parseFloat(temp);
if (nspd !== '' && nspd != null && !isNaN(+nspd) && parseFloat(nspd) !== 1) out.speed = parseFloat(nspd);
return Object.keys(out).length ? out : null;
}
// RMS-based gain so each chunk plays at a similar perceived loudness (clamped,
// peak-limited to avoid clipping). Cached per unit.
function readerComputeGain(idx, buf) {
if (readerState.gainCache.has(idx)) return readerState.gainCache.get(idx);
const ch = buf.getChannelData(0);
const step = Math.max(1, Math.floor(ch.length / 8000));
let sum = 0, n = 0, peak = 1e-6;
for (let i = 0; i < ch.length; i += step) { const v = ch[i]; sum += v * v; n++; if (Math.abs(v) > peak) peak = Math.abs(v); }
const rms = Math.sqrt(sum / Math.max(1, n)) || 0.0001;
let gain = 0.10 / rms; // target RMS ≈ 0.10
gain = Math.max(0.5, Math.min(gain, 4));
gain = Math.min(gain, 0.99 / peak); // never clip
readerState.gainCache.set(idx, gain);
return gain;
}
// ── Playback ─────────────────────────────────────────────────────────────────
// Fetch a chunk's audio that's already persisted on the server (synced device).
async function readerFetchServerAudio(idx) {
if (!readerState.savedId || !readerState.savedAudioIdx.has(idx)) return null;
try {
const r = await fetch(`${READER_API}/${readerState.savedId}/audio/${idx}`);
if (r.ok) { const b = await r.blob(); readerState.blobCache.set(idx, b); return b; }
} catch (_) {}
return null;
}
async function readerGetBuffer(idx) {
if (readerState.bufCache.has(idx)) return readerState.bufCache.get(idx);
let blob = readerState.blobCache.get(idx) || await readerFetchServerAudio(idx);
if (!blob) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) throw new Error('Pick a voice first');
if (!backend) throw new Error('No TTS backend selected');
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status !== 'reading') readerSetStatus(idx, 'synth');
blob = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, blob);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
}
const buf = await readerCtx().decodeAudioData(await blob.arrayBuffer());
readerState.bufCache.set(idx, buf);
return buf;
}
// Keep decoded PCM (large) only for a small window around the playhead; the
// compact MP3 blobs stay cached so re-entry just re-decodes (cheap).
function readerEvictBuffers(center) {
if (readerState.bufCache.size <= READER_BUF_WINDOW * 2 + 1) return;
for (const i of readerState.bufCache.keys()) {
if (Math.abs(i - center) > READER_BUF_WINDOW) readerState.bufCache.delete(i);
}
}
// Fetch the next chunk's audio AND pre-decode it so playback starts gaplessly.
async function readerPrefetch(idx) {
if (idx < 0 || idx >= readerState.sentences.length) return;
if (!readerState.blobCache.has(idx)) {
// prefer audio already persisted on the server, else synthesise
if (!(await readerFetchServerAudio(idx))) {
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice || !backend) return;
const instruct = $('reader-instruct')?.value.trim() || '';
if (readerState.sentences[idx].status === 'pending') readerSetStatus(idx, 'synth');
try {
const b = await fetchTtsPreviewBlob(voice, readerState.sentences[idx].text, READER_FMT, instruct, backend, false, readerGenParams());
readerState.blobCache.set(idx, b);
if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'ready');
} catch (_) { if (readerState.sentences[idx].status === 'synth') readerSetStatus(idx, 'pending'); return; }
}
}
// pre-decode (only matters while it's the immediate look-ahead within the window)
if (!readerState.bufCache.has(idx) && Math.abs(idx - readerState.idx) <= READER_BUF_WINDOW) {
try { readerState.bufCache.set(idx, await readerCtx().decodeAudioData(await readerState.blobCache.get(idx).arrayBuffer())); }
catch (_) {}
}
}
// Revert the single "reading" unit to its idle colour (no full-array scan).
function readerClearReading() {
const i = readerState.readingIdx;
if (i >= 0 && i < readerState.sentences.length && readerState.sentences[i].status === 'reading') {
readerSetStatus(i, readerState.blobCache.has(i) ? 'ready' : 'pending');
}
readerState.readingIdx = -1;
}
async function readerPlayCurrent() {
if (!readerState.playing) return;
if (readerState.idx >= readerState.sentences.length) { readerStopPlayback(); readerState.idx = 0; readerUpdateProgress(); return; }
const idx = readerState.idx;
const sentence = readerState.sentences[idx];
readerUpdateProgress();
readerSaveResume();
readerState.readingIdx = idx;
readerSetStatus(idx, 'reading');
readerHighlightSentence(sentence);
let buf;
try { buf = await readerGetBuffer(idx); }
catch (e) { toast(e.message || String(e), 'error'); readerSetStatus(idx, 'pending'); readerStopPlayback(); return; }
if (!readerState.playing || readerState.idx !== idx) return;
readerEvictBuffers(idx);
const timings = computeWordTimings(sentence.text, buf.duration);
const ctx = readerCtx();
readerStopSource();
const src = ctx.createBufferSource();
src.buffer = buf;
src.playbackRate.value = readerState.speed;
if (readerState.normalize) {
const g = ctx.createGain();
g.gain.value = readerComputeGain(idx, buf);
src.connect(g); g.connect(ctx.destination);
} else {
src.connect(ctx.destination);
}
readerState.currentSource = src;
const t0 = ctx.currentTime;
readerPrefetch(idx + 1);
src.onended = () => {
if (readerState.currentSource !== src) return;
readerState.currentSource = null;
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
readerSetStatus(idx, 'ready'); // synthesised → green
if (readerState.readingIdx === idx) readerState.readingIdx = -1;
if (!readerState.playing) return;
readerState.idx++;
readerPlayCurrent();
};
src.start(0);
const tick = () => {
if (readerState.currentSource !== src) return;
const elapsed = (ctx.currentTime - t0) * readerState.speed;
let active = 0;
for (let i = timings.length - 1; i >= 0; i--) { if (elapsed >= timings[i].start) { active = i; break; } }
readerHighlightWord(sentence, Math.min(active, sentence.words.length - 1));
readerState.raf = requestAnimationFrame(tick);
};
readerState.raf = requestAnimationFrame(tick);
}
function readerStopSource() {
if (readerState.currentSource) { try { readerState.currentSource.onended = null; readerState.currentSource.stop(0); } catch (_) {} readerState.currentSource = null; }
if (readerState.raf) { cancelAnimationFrame(readerState.raf); readerState.raf = null; }
}
function readerStopPlayback() {
readerState.playing = false;
readerStopSource();
readerClearHighlights();
// a sentence left mid-read reverts to its cached/uncached colour
readerClearReading();
readerUpdatePlayBtn();
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', false);
}
function readerPlay() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (!$('reader-voice-select')?.value) { toast('Pick a voice first', 'error'); return; }
readerCtx();
readerState.playing = true;
readerUpdatePlayBtn();
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', true);
readerPlayCurrent();
}
function readerPause() {
readerState.playing = false;
readerStopSource();
readerSaveResume();
readerPersistProgress();
readerClearReading();
readerUpdatePlayBtn();
}
function readerJumpTo(idx) {
readerState.idx = Math.max(0, Math.min(idx, readerState.sentences.length - 1));
readerStopSource();
readerClearHighlights();
readerClearReading();
readerUpdateProgress();
readerSaveResume();
if (readerState.playing) readerPlayCurrent();
else readerHighlightSentence(readerState.sentences[readerState.idx]);
}
function readerUpdatePlayBtn() {
const btn = $('reader-play'); if (!btn) return;
const ic = btn.querySelector('.mdi');
if (ic) ic.className = 'mdi ' + (readerState.playing ? 'mdi-pause' : 'mdi-play');
}
function readerUpdateProgress() {
const total = readerState.sentences.length;
const cur = total ? readerState.idx + 1 : 0;
const lbl = $('reader-progress-label'); if (lbl) lbl.textContent = cur + ' / ' + total;
const fill = $('reader-progress-fill'); if (fill) fill.style.width = (total ? (readerState.idx / total) * 100 : 0) + '%';
}
// ── Resume (persist last position per document title) ────────────────────────
function readerSaveResume() {
if (!readerState.title || !readerState.sentences.length) return;
try { localStorage.setItem(READER_RESUME_KEY, JSON.stringify({ title: readerState.title, idx: readerState.idx, total: readerState.sentences.length })); } catch (_) {}
}
function readerLoadResume() {
try {
const r = JSON.parse(localStorage.getItem(READER_RESUME_KEY) || 'null');
if (r && r.title === readerState.title && r.idx > 0 && r.idx < readerState.sentences.length) {
readerState.idx = r.idx;
return true;
}
} catch (_) {}
return false;
}
// ── Library (server-side under the config volume → syncs across devices) ──────
// Each book lives at <config>/reader_library/<id>/ (meta.json + source + audio/).
// Audio is fetched per-unit on demand so opening a book is instant on any device.
const READER_API = '/api/reader/docs';
function readerSyntheticCount() {
let n = 0;
for (let i = 0; i < readerState.sentences.length; i++) if (readerState.blobCache.has(i)) n++;
return n;
}
async function readerSaveLibrary() {
if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; }
if (readerState.mode === 'pdf' && !readerState.fileBlob) { toast('Original PDF unavailable to save', 'error'); return; }
const meta = {
title: readerState.title || 'Untitled',
kind: readerState.mode,
idx: readerState.idx,
voice: $('reader-voice-select')?.value || '',
backend: $('reader-backend-select')?.value || '',
speed: readerState.speed,
instruct: $('reader-instruct')?.value.trim() || '',
chunkMode: readerState.chunkMode,
seed: $('reader-seed')?.value.trim() || '',
temperature: $('reader-temp')?.value.trim() || '',
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
normalize: readerState.normalize,
sentenceCount: readerState.sentences.length,
pageCount: readerState.pages.length,
synthCount: readerSyntheticCount(),
updated: new Date().toISOString(),
};
const btn = $('reader-save-lib'); if (btn) btn.disabled = true;
try {
if (readerState.savedId) meta.id = readerState.savedId;
const r = await fetch(READER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) });
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
const fresh = !readerState.savedId;
readerState.savedId = (await r.json()).id;
// upload the source document once (it's needed to re-render on another device)
if (fresh || !readerState.sourceUploaded) {
const ext = readerState.mode === 'pdf' ? 'pdf' : 'txt';
const body = readerState.mode === 'pdf' ? readerState.fileBlob : new Blob([readerState.docText || ''], { type: 'text/plain' });
const sr = await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`, { method: 'PUT', body });
if (sr.ok) readerState.sourceUploaded = true;
}
// upload ONLY chunks not already on the server (incremental)
let uploaded = 0;
for (let i = 0; i < readerState.sentences.length; i++) {
if (readerState.blobCache.has(i) && !readerState.savedAudioIdx.has(i)) {
const ar = await fetch(`${READER_API}/${readerState.savedId}/audio/${i}`, { method: 'PUT', body: readerState.blobCache.get(i) });
if (ar.ok) { readerState.savedAudioIdx.add(i); uploaded++; }
}
}
toast('Saved (' + meta.synthCount + ' / ' + meta.sentenceCount + ' synthesised' + (uploaded ? ', +' + uploaded + ' new' : '') + ')', 'success');
readerRenderLibrary();
} catch (e) { toast('Save failed: ' + (e.message || e), 'error'); }
finally { if (btn) btn.disabled = false; }
}
// Light update of just the reading position (called on stop/pause/leave)
async function readerPersistProgress() {
if (!readerState.savedId) return;
try {
await fetch(`${READER_API}/${readerState.savedId}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }),
});
} catch (_) {}
}
async function readerDeleteLibraryDoc(id) {
try {
await fetch(`${READER_API}/${id}`, { method: 'DELETE' });
if (readerState.savedId === id) { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; }
readerRenderLibrary();
} catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); }
}
async function readerOpenLibraryDoc(id) {
let rec;
try { const r = await fetch(`${READER_API}/${id}`); if (!r.ok) throw new Error(r.statusText); rec = await r.json(); }
catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
readerResetDoc();
readerState.title = rec.title;
const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = rec.title;
// restore controls (chunk mode must be set BEFORE the document is grouped)
readerState.chunkMode = rec.chunkMode || 'sentence';
if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode;
if ($('reader-seed')) $('reader-seed').value = rec.seed || '';
if ($('reader-temp')) $('reader-temp').value = rec.temperature || '';
if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed;
readerState.normalize = rec.normalize !== false;
if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize;
if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend);
if (rec.voice && $('reader-voice-select')) readerSetSelectValue('reader-voice-select', rec.voice);
if (typeof rec.speed === 'number') {
readerState.speed = rec.speed;
if ($('reader-speed')) $('reader-speed').value = rec.speed;
const sl = $('reader-speed-label'); if (sl) sl.textContent = rec.speed.toFixed(2).replace(/0$/, '') + '×';
}
if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || '';
// fetch the source document from the server
let sourceBlob = null;
try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {}
try {
if (rec.kind === 'pdf') {
if (!sourceBlob) {
// PDF upload failed at save time — let user re-supply the file
const inp = document.createElement('input');
inp.type = 'file'; inp.accept = '.pdf';
inp.onchange = async () => {
const file = inp.files[0]; if (!file) return;
const put = await fetch(`${READER_API}/${id}/source?ext=pdf`, { method: 'PUT', body: file });
if (!put.ok) { toast('Re-upload failed', 'error'); return; }
toast('PDF restored — opening…', 'success');
readerState.savedId = id; readerState.sourceUploaded = true;
readerState.fileBlob = file;
await readerLoadPdf(file);
};
inp.click();
toast('PDF source missing — please re-select the original file', 'error');
return;
}
readerState.fileBlob = sourceBlob;
// No "Opening book…" toast here — the "Reading PDF… page N / total"
// progress pill (readerShowParseProgress, inside readerLoadPdf) appears
// within the same instant and says the same thing with more detail, so
// the two used to visibly stack on top of each other.
await readerLoadPdf(sourceBlob);
} else {
const text = sourceBlob ? await sourceBlob.text() : (rec.text || '');
readerState.docText = text;
await readerLoadText(text);
}
} catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; }
if (!readerState.sentences.length) { toast('Document had no readable text', 'error'); return; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
readerState.savedId = id;
readerState.sourceUploaded = true; // already on the server
readerState.savedAudioIdx = new Set();
// mark which units already have audio on the server (fetched lazily on play)
if (rec.sentenceCount === readerState.sentences.length) {
(rec.audioIdx || []).forEach(i => { if (i < readerState.sentences.length) { readerState.savedAudioIdx.add(i); readerSetStatus(i, 'ready'); } });
} else if ((rec.audioIdx || []).length) {
toast('Document changed — re-synthesis needed', 'error');
}
readerState.idx = Math.max(0, Math.min(rec.idx || 0, readerState.sentences.length - 1));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
const importArea = $('reader-import-area'); if (importArea) importArea.hidden = true;
readerUpdateToolbarVisibility(true);
$('reader-synthbar').hidden = false;
const pr = $('reader-page-range');
if (pr) {
pr.hidden = readerState.mode !== 'pdf';
if (readerState.mode === 'pdf') {
const n = readerState.pages.length;
if ($('reader-page-from')) { $('reader-page-from').max = n; $('reader-page-from').value = 1; }
if ($('reader-page-to')) { $('reader-page-to').max = n; $('reader-page-to').value = n; }
}
}
readerUpdateScopeLabel();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast('Opened "' + rec.title + '" · resuming at sentence ' + (readerState.idx + 1), 'success');
if (typeof window.navReaderView === 'function') window.navReaderView('main');
else showReaderView('main');
}
function readerSetSelectValue(id, value) {
const sel = $(id); if (!sel) return;
if (![...sel.options].some(o => o.value === value)) {
const o = document.createElement('option'); o.value = o.textContent = value; sel.appendChild(o);
}
sel.value = value;
if (id === 'reader-voice-select' && window.VoicePicker) VoicePicker.setValue(id, value);
}
async function readerRenderLibrary() {
const card = $('reader-library-card'), list = $('reader-lib-list');
if (!card || !list) return;
let all = [];
try { const r = await fetch(READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
if (!all.length) { list.innerHTML = '<div style="padding: 20px; text-align: center; color: var(--subtext);">No saved books yet.</div>'; return; }
list.innerHTML = all.map(rec => {
const total = rec.sentenceCount || 0;
const synth = rec.synthCount || 0;
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
const synthPct = total ? Math.round((synth / total) * 100) : 0;
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
let h = 0;
const titleStr = rec.title || 'Untitled';
for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360;
const h2 = (h + 40) % 360;
const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`;
// A timestamp busts the cache if the cover was updated
const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`;
const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`;
return `<div class="reh-book reader-book" data-id="${rec.id}" title="${escHtml(rec.title)}" ${bgStyle}>
<div class="reh-book-actions">
<button class="reh-book-act reader-book-del" data-del="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
</div>
<div class="reh-book-title">${escHtml(rec.title)}</div>
<div class="reh-book-meta">
${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
<div class="reh-book-progress" title="${synthPct}% audio"><div style="width:${synthPct}%"></div></div>
<div style="margin-top:4px;opacity:.8"><span class="mdi ${rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline'}"></span> ${readPct}% read · ${date}</div>
</div>
</div>`;
}).join('');
list.querySelectorAll('.reader-book').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.reader-book-del')) return;
readerOpenLibraryDoc(el.dataset.id);
});
const delBtn = el.querySelector('.reader-book-del');
if (delBtn) {
delBtn.addEventListener('click', e => {
e.stopPropagation();
if (el.querySelector('.reader-book-del-confirm')) return;
const confirmOverlay = document.createElement('div');
confirmOverlay.className = 'reader-book-del-confirm';
confirmOverlay.style.cssText = 'position:absolute; inset:0; background:rgba(0,0,0,0.85); color:#fff; display:flex; flex-direction:column; justify-content:center; align-items:center; border-radius:inherit; z-index:10; padding:12px; text-align:center; box-sizing:border-box;';
confirmOverlay.innerHTML = `
<div style="font-weight:600; margin-bottom:8px; font-size:14px;">Delete Book?</div>
<div style="font-size:11px; opacity:0.8; margin-bottom:12px; line-height:1.4;">Audio files will be removed.</div>
<div style="display:flex; gap:8px;">
<button class="btn-secondary btn-sm" id="btn-cancel-del" style="background:rgba(255,255,255,0.15); border:none; color:#fff; padding:6px 12px;">Cancel</button>
<button class="btn-primary btn-sm" id="btn-confirm-del" style="background:var(--red,#ef4444); border:none; color:#fff; padding:6px 12px;">Delete</button>
</div>
`;
confirmOverlay.addEventListener('click', ce => ce.stopPropagation());
confirmOverlay.querySelector('#btn-cancel-del').addEventListener('click', ce => {
ce.stopPropagation();
confirmOverlay.remove();
});
confirmOverlay.querySelector('#btn-confirm-del').addEventListener('click', async ce => {
ce.stopPropagation();
confirmOverlay.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px;"></span>';
try {
const r = await fetch(`${READER_API}/${el.dataset.id}`, { method: 'DELETE' });
if (!r.ok) throw new Error('Failed to delete book');
toast('Book deleted', 'success');
readerRenderLibrary();
} catch (err) {
toast(err.message, 'error');
confirmOverlay.remove();
}
});
el.appendChild(confirmOverlay);
});
}
// Drag & drop for cover image
el.addEventListener('dragover', e => {
if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) {
e.preventDefault();
el.style.borderColor = 'var(--accent)';
}
});
el.addEventListener('dragleave', () => { el.style.borderColor = ''; });
el.addEventListener('drop', async e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); e.stopPropagation();
el.style.borderColor = '';
const f = e.dataTransfer.files?.[0];
if (!f || !f.type.startsWith('image/')) { toast('Please drop an image file', 'error'); return; }
try {
const id = el.dataset.id;
const r = await fetch(`${READER_API}/${id}/cover`, { method: 'PUT', body: f });
if (!r.ok) throw new Error(r.statusText);
// Touch the updated timestamp so the cache buster updates on re-render
await fetch(`${READER_API}/${id}/progress`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updated: new Date().toISOString() }),
});
toast('Cover image updated', 'success');
readerRenderLibrary();
} catch (err) {
toast('Failed to upload cover: ' + (err.message || err), 'error');
}
});
});
list.querySelectorAll('.reader-book-del').forEach(btn => btn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('Delete this saved document and its audio?')) readerDeleteLibraryDoc(btn.dataset.del);
}));
}
// ── Wiring ───────────────────────────────────────────────────────────────────
$('reader-file-input')?.addEventListener('change', function () {
const f = this.files?.[0]; this.value = '';
readerImportFile(f);
});
$('reader-paste-load')?.addEventListener('click', readerImportPastedText);
$('reader-paste-text')?.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
readerImportPastedText();
}
});
$('reader-fetch-voices-btn')?.addEventListener('click', readerFetchVoices);
$('reader-backend-select')?.addEventListener('change', () => {
const sel = $('reader-voice-select'); if (sel) sel.innerHTML = '<option value="">— fetch voices —</option>';
if (window.VoicePicker) {
VoicePicker.upgrade('reader-voice-select');
VoicePicker.populate('reader-voice-select', []);
}
readerUpdateBackendHint();
});
$('reader-chunk-mode')?.addEventListener('change', function () { readerRechunk(this.value); });
$('reader-normalize')?.addEventListener('change', function () { readerState.normalize = this.checked; });
$('reader-play')?.addEventListener('click', () => { readerState.playing ? readerPause() : readerPlay(); });
$('reader-stop')?.addEventListener('click', () => { readerStopPlayback(); readerState.idx = 0; readerSaveResume(); readerUpdateProgress(); });
$('reader-prev')?.addEventListener('click', () => readerJumpTo(readerState.idx - 1));
$('reader-next')?.addEventListener('click', () => readerJumpTo(readerState.idx + 1));
$('reader-speed')?.addEventListener('input', function () {
readerState.speed = parseFloat(this.value) || 1;
const lbl = $('reader-speed-label'); if (lbl) lbl.textContent = readerState.speed.toFixed(2).replace(/0$/, '') + '×';
if (readerState.currentSource) readerState.currentSource.playbackRate.value = readerState.speed;
});
// Zoom controls
$('reader-zoom-fitw')?.addEventListener('click', () => readerApplyZoom('fit-width'));
$('reader-zoom-fith')?.addEventListener('click', () => readerApplyZoom('fit-height'));
$('reader-zoom-two') ?.addEventListener('click', () => readerApplyZoom('two'));
$('reader-zoom-in') ?.addEventListener('click', () => { readerState.scale = Math.min(4, readerState.scale * 1.2); readerApplyZoom('custom'); });
$('reader-zoom-out') ?.addEventListener('click', () => { readerState.scale = Math.max(0.2, readerState.scale / 1.2); readerApplyZoom('custom'); });
// ── Document search ──────────────────────────────────────────────────────────
let _readerSearchTerm = '';
let _readerSearchHits = [];
let _readerSearchCurrent = -1;
let _readerSearchTimer = null;
const READER_SEARCH_MAX_HITS = 2500;
function readerSearchTextMap(words) {
let text = '';
const spans = [];
for (let i = 0; i < words.length; i++) {
const raw = String(words[i]?.text || '').trim();
if (!raw) continue;
if (text) text += ' ';
const start = text.length;
text += raw.toLowerCase();
spans.push({ wordIndex: i, start, end: text.length });
}
return { text, spans };
}
function readerFindSearchGroups(sentence, term) {
const words = sentence?.words || [];
const needle = String(term || '').trim().replace(/\s+/g, ' ').toLowerCase();
if (!needle) return [];
const mapped = readerSearchTextMap(words);
const groups = [];
let pos = 0;
while ((pos = mapped.text.indexOf(needle, pos)) !== -1) {
const end = pos + needle.length;
const idxs = mapped.spans
.filter(sp => sp.end > pos && sp.start < end)
.map(sp => sp.wordIndex);
if (idxs.length) groups.push(idxs);
pos = Math.max(pos + 1, end);
}
return groups;
}
function readerRemoveSearchHighlights() {
document.querySelectorAll('.reader-search-hit').forEach(el => el.remove());
document.querySelectorAll('.reader-word.search-hit, .reader-word.search-current').forEach(el => {
el.classList.remove('search-hit', 'search-current');
});
}
function readerSetSearchStatus(text) {
const st = $('reader-search-status');
if (st) st.textContent = text || '';
}
function readerClearSearch({ clearInput = false } = {}) {
_readerSearchTerm = '';
_readerSearchHits = [];
_readerSearchCurrent = -1;
readerRemoveSearchHighlights();
readerSetSearchStatus('');
if (clearInput) {
const inp = $('reader-search');
if (inp) inp.value = '';
}
}
function readerCollectSearchHits(term) {
const hits = [];
const sents = readerState.sentences || [];
for (let si = 0; si < sents.length; si++) {
const s = sents[si];
const groups = readerFindSearchGroups(s, term);
for (const wordIndexes of groups) {
const page = s.words?.[wordIndexes[0]]?.page ?? 0;
hits.push({ sentenceIndex: si, page, wordIndexes, nodes: [] });
if (hits.length >= READER_SEARCH_MAX_HITS) return hits;
}
}
return hits;
}
function readerPaintSearchHighlights() {
readerRemoveSearchHighlights();
if (!_readerSearchTerm || !_readerSearchHits.length) return;
const scale = readerState.scale || 1;
_readerSearchHits.forEach((hit, hi) => {
const s = readerState.sentences[hit.sentenceIndex];
if (!s) return;
hit.nodes = [];
for (const wi of hit.wordIndexes) {
const w = s.words?.[wi];
if (!w) continue;
if (readerState.mode === 'pdf') {
const pg = readerState.pages[w.page];
if (!pg?.overlay) continue;
const el = document.createElement('div');
el.className = 'reader-search-hit' + (hi === _readerSearchCurrent ? ' search-current' : '');
Object.assign(el.style, {
left: (w.x * scale) + 'px',
top: (w.top * scale) + 'px',
width: (Math.max(w.w, 2) * scale) + 'px',
height: (Math.max(w.h, 2) * scale) + 'px',
});
pg.overlay.appendChild(el);
hit.nodes.push(el);
} else if (w.el) {
w.el.classList.add('search-hit');
if (hi === _readerSearchCurrent) w.el.classList.add('search-current');
hit.nodes.push(w.el);
}
}
});
}
function readerUpdateSearchStatus() {
if (!_readerSearchTerm) { readerSetSearchStatus(''); return; }
if (!readerState.sentences?.length) { readerSetSearchStatus('No text loaded'); return; }
if (!_readerSearchHits.length) { readerSetSearchStatus('0/0'); return; }
const current = _readerSearchCurrent >= 0 ? _readerSearchCurrent + 1 : 1;
const hit = _readerSearchHits[Math.max(0, _readerSearchCurrent)];
const capped = _readerSearchHits.length >= READER_SEARCH_MAX_HITS ? '+' : '';
const page = readerState.mode === 'pdf' ? ' · p.' + ((hit?.page ?? 0) + 1) : '';
readerSetSearchStatus(current + '/' + _readerSearchHits.length + capped + page);
}
function readerJumpToSearchHit(idx) {
if (!_readerSearchHits.length) return;
_readerSearchCurrent = (idx + _readerSearchHits.length) % _readerSearchHits.length;
readerPaintSearchHighlights();
readerUpdateSearchStatus();
const hit = _readerSearchHits[_readerSearchCurrent];
const node = hit?.nodes?.[0];
if (node) {
readerEnsureVisible(node);
} else if (readerState.mode === 'pdf') {
const pg = readerState.pages[hit.page];
if (pg?.pageDiv) {
readerRenderPage(hit.page);
pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
function readerSearchApply(term, { jump = true } = {}) {
term = String(term || '').trim();
if (!term) { readerClearSearch(); return; }
_readerSearchTerm = term;
_readerSearchHits = readerCollectSearchHits(term);
_readerSearchCurrent = _readerSearchHits.length ? 0 : -1;
readerPaintSearchHighlights();
readerUpdateSearchStatus();
if (jump && _readerSearchHits.length) readerJumpToSearchHit(0);
}
function readerSearchStep(direction) {
const term = $('reader-search')?.value.trim() || _readerSearchTerm;
if (!_readerSearchHits.length || term !== _readerSearchTerm) {
readerSearchApply(term, { jump: true });
return;
}
readerJumpToSearchHit(_readerSearchCurrent + (direction === -1 ? -1 : 1));
}
$('reader-search')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); readerSearchStep(e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { readerClearSearch({ clearInput: true }); }
});
$('reader-search')?.addEventListener('input', e => {
clearTimeout(_readerSearchTimer);
const term = e.target.value.trim();
if (!term) { readerClearSearch(); return; }
_readerSearchTimer = setTimeout(() => readerSearchApply(term, { jump: true }), 160);
});
$('reader-search-prev')?.addEventListener('click', () => readerSearchStep(-1));
$('reader-search-next')?.addEventListener('click', () => readerSearchStep(1));
$('reader-search-clear')?.addEventListener('click', () => readerClearSearch({ clearInput: true }));
// Library + export
$('reader-save-lib')?.addEventListener('click', readerSaveLibrary);
$('reader-export-btn')?.addEventListener('click', () => readerExport($('reader-export-mode')?.value || 'page'));
// Batch synthesis + range selection
$('reader-synth-all')?.addEventListener('click', readerSynthAll);
$('reader-synth-cancel')?.addEventListener('click', () => { readerState.synthCancel = true; });
$('reader-select-toggle')?.addEventListener('click', () => {
if (readerState.selecting) readerSetSelecting(false); // "Done"
else { readerClearSelection(); readerSetSelecting(true); }
});
$('reader-sel-clear')?.addEventListener('click', readerClearSelection);
// Esc leaves select mode
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && readerState.selecting) { readerSetSelecting(false); }
});
$('reader-page-from')?.addEventListener('change', readerUpdateScopeLabel);
$('reader-page-to') ?.addEventListener('change', readerUpdateScopeLabel);
// Click a PDF sentence's status box to set a selection point (in select mode)
$('reader-doc')?.addEventListener('click', e => {
if (!readerState.selecting || readerState.mode !== 'pdf') return;
const stat = e.target.closest('.reader-stat'); if (!stat) return;
const si = parseInt(stat.dataset.si);
if (!isNaN(si)) readerPickSentence(si);
});
// Re-raster pages that scroll into view (zoom invalidates cached canvases)
$('reader-doc')?.addEventListener('scroll', (function () {
let t = null;
return () => { if (t) return; t = setTimeout(() => { t = null; readerRenderVisible(); }, 120); };
})());
// Keep fit-width/height/two correct on window resize
window.addEventListener('resize', (function () {
let t = null;
return () => { if (readerState.mode !== 'pdf' || readerState.zoomMode === 'custom') return; clearTimeout(t); t = setTimeout(() => readerApplyZoom(readerState.zoomMode), 200); };
})());
// Drag & drop over the dropzone + the whole reader doc
(function setupReaderDropzone() {
const targets = ['reader-dropzone', 'reader-doc'].map(id => $(id)).filter(Boolean);
targets.forEach(el => {
el.addEventListener('dragover', e => { if (e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files')) { e.preventDefault(); el.classList.add('dragover'); } });
el.addEventListener('dragleave', () => el.classList.remove('dragover'));
el.addEventListener('drop', e => {
if (!(e.dataTransfer && [...(e.dataTransfer.types || [])].includes('Files'))) return;
e.preventDefault(); el.classList.remove('dragover');
readerImportFile(e.dataTransfer.files?.[0]);
});
});
})();
window.showReaderView = function (view) {
const main = $('reader-main-view');
const cast = $('reader-audiobook-panel');
const lib = $('reader-library-card');
if (main) main.hidden = (view !== 'main');
if (cast) {
cast.hidden = (view !== 'cast');
if (view !== 'cast') {
cast.style.display = '';
cast.style.flexDirection = '';
cast.style.minHeight = '';
}
if (view === 'cast' && !cast.innerHTML.trim()) {
cast.innerHTML = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
cast.className = 'ab-castpanel-inline card';
}
}
if (lib) {
lib.hidden = (view !== 'library');
if (view === 'library') {
lib.classList.remove('card'); // Remove card styling so it takes full width/height naturally
lib.style.boxShadow = 'none';
lib.style.border = 'none';
lib.style.background = 'transparent';
readerRenderLibrary(); // Re-fetch on open
}
}
if (typeof refreshWorkflowCrumbs === 'function') refreshWorkflowCrumbs(view === 'cast' ? 'cast' : 'source');
};
window.navReaderView = function (view) {
window.showReaderView(view);
};
window.readerJumpToPage = function (pageNum) {
const pg = parseInt(pageNum, 10);
if (!pg) return Promise.resolve(false);
if (typeof navTo === 'function') navTo('s-reader');
window.showReaderView('main');
let tries = 0;
const waitFrame = () => new Promise(resolve => requestAnimationFrame(resolve));
const jump = async () => {
await waitFrame();
const pageState = readerState.pages?.[pg - 1];
const pageDiv = pageState?.pageDiv;
if (pageDiv && !pageDiv.hidden) {
await readerRenderPage(pg - 1);
pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
readerRenderVisible();
return true;
}
if (tries++ < 20) {
await new Promise(resolve => setTimeout(resolve, 100));
return jump();
}
if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info');
return false;
};
return jump();
};
window.readerOnShow = async function () {
const sel = $('reader-backend-select');
if (sel && (!sel.value || sel.options.length <= 1)) {
if (typeof availableTtsBackends === 'function' && !availableTtsBackends().length &&
typeof refreshTtsBackendAvailability === 'function') {
try { await refreshTtsBackendAvailability(); } catch (_) {}
}
if (typeof availableTtsBackends === 'function' && availableTtsBackends().length) {
sel.innerHTML = ttsBackendOptions(sel.value);
sel.disabled = false;
}
}
readerUpdateBackendHint();
if (window.VoicePicker) VoicePicker.upgrade('reader-voice-select');
readerRenderLibrary();
if (window._readerStartView) {
window.showReaderView(window._readerStartView);
window._readerStartView = null;
}
};