// ── 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 = '';
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
? ' Good for long-form narration. For maximum steadiness, increase “Voice consistency”.'
: ' 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 Seed with a low Temperature (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 = ' 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).
// PDF fonts sometimes render a closing quote/bracket glyph as its own
// separate text-content item (confirmed live on a scanned book, for the
// German closing guillemet «) — joining every word with an unconditional
// leading space then bakes a stray space in right before it ("hier. «"
// instead of "hier.«"), which the casting LLM then faithfully preserves
// since it's instructed to reproduce the exact source text. Punctuation
// that only ever closes something never wants a space before it.
const READER_NO_LEADING_SPACE_RE = /^[«"'’”)\]]+$/;
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 && !READER_NO_LEADING_SPACE_RE.test(w.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;
}
// Races `promise` against a timer, rejecting instead of leaving the caller
// awaiting forever. A hung (never-settling, not rejecting) render/OCR call
// on one page — confirmed live as the PDF import progress bar getting stuck
// on a single page indefinitely, most likely an image-heavy or blank page
// wedging the Tesseract worker — silently stalled the entire sequential
// extraction loop with no error and no way to recover short of reloading.
function _readerTimeout(promise, ms, label) {
return new Promise(function (resolve, reject) {
const t = setTimeout(function () { reject(new Error((label || 'operation') + ' timed out after ' + ms + 'ms')); }, ms);
promise.then(function (v) { clearTimeout(t); resolve(v); }, function (e) { clearTimeout(t); reject(e); });
});
}
// 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.
// Decorative chapter-heading images commonly combine a small icon/border
// graphic ABOVE or AROUND the actual number/title text (confirmed live: a
// real book's heading was an octagonal badge with a bold border and an icon
// sitting on top of the text) — Tesseract's default full-page layout
// analysis gets thrown off by the graphic, either finding no text at all
// (high confidence, empty result) or confidently misreading the border as a
// stray character. Tightening the crop to exclude the graphic and telling
// Tesseract to expect a single line of text (PSM 7) fixed this in testing:
// the same heading that returned "" or ">" under default settings read back
// as the correct "3.Kapitel" at 80% confidence once cropped to just the
// bottom ~55% of the gap (where centered chapter-title text typically sits,
// below any icon) with PSM 7. Tried first since it's the more surgical,
// less error-prone read; the untrimmed full-gap crop remains as a fallback
// for headings that are already plain text with no surrounding graphic.
async function _readerOcrAttempt(worker, canvas, psm) {
await worker.setParameters({ tessedit_pageseg_mode: psm });
const { data } = await _readerTimeout(worker.recognize(canvas), 20000, 'Heading OCR');
const text = (data.text || '').replace(/\s+/g, ' ').trim();
if (!text || (data.confidence ?? 0) < READER_OCR_MIN_CONFIDENCE) return null;
return text;
}
async function readerOcrPageHeading(page, base, pageIdx, gapPx) {
let crop, tightCrop;
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 _readerTimeout(page.render({ canvasContext: crop.getContext('2d'), viewport }).promise, 15000, 'Page render');
let text = null;
if (cropH > 40) {
const y0 = Math.round(cropH * 0.45);
const inset = Math.round(crop.width * 0.03);
tightCrop = document.createElement('canvas');
tightCrop.width = Math.max(crop.width - inset * 2, 1);
tightCrop.height = cropH - y0;
tightCrop.getContext('2d').putImageData(crop.getContext('2d').getImageData(inset, y0, tightCrop.width, tightCrop.height), 0, 0);
text = await _readerOcrAttempt(worker, tightCrop, '7');
}
if (!text) text = await _readerOcrAttempt(worker, crop, '3');
if (!text) return [];
// Stray single-symbol tokens (a leftover border-line fragment misread as
// "|" or similar) don't affect confidence enough to fail the threshold,
// but do end up glued onto the real heading text — confirmed live: a
// clean "3.Kapitel" recognition also produced a trailing "|" token,
// which broke chapter-detection's stricter "nothing after the heading
// but a number/subtitle" check by leaving that stray symbol behind it.
const tokens = text.split(' ').filter(t => t && /[a-zäöüßàâçéèêëîïôûùüÿñæœ0-9]/i.test(t));
if (!tokens.length) return [];
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; }
if (tightCrop) { tightCrop.width = 0; tightCrop.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
let content;
try {
content = await _readerTimeout(page.getTextContent(), 20000, 'Page text extraction');
} catch (e) {
console.warn('getTextContent failed/timed out on page', p, e);
content = { items: [] };
}
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);
// A page with zero extracted text — no real text layer AND heading OCR
// either found nothing or failed confidence (common for a page that's
// ENTIRELY a decorative image, e.g. a chapter-divider graphic with no
// recognizable characters at all) — used to contribute nothing to
// readerState.sentences whatsoever. Since page numbers downstream
// (audiobookScopeText's pageMarks, used to reconstruct page breaks after
// LLM speaker-attribution) are derived entirely from real units' own
// .words[0].page, a page with no units is invisible to that system —
// not just missing its own content, but silently shifting every
// SUBSEQUENT page number in the rehearsal/export view out of sync with
// the actual PDF (confirmed live: reported page breaks drifting from
// the source PDF on a book with several such divider pages). A blank
// placeholder unit (empty text — never spoken, never shown as a line;
// audiobookScopeText already skips any unit with no text) keeps this
// page's mere existence visible to page tracking either way.
if (!pageUnits.length) {
pageUnits.push({ text: '', words: [{ page: pageIdx, x: 0, top: 0, w: 0, h: 0, text: '' }], status: 'pending', _stat: null, paraStart: false });
}
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 = ' Reading PDF… page 0 / ' + total + '';
// Fixed to the viewport (not sticky within the doc pane) — sticky's
// top:40vh drifted toward the bottom on long documents since its
// containing block grew with every page appended, instead of staying
// anchored a fixed distance from the actual visible top of the screen.
el.style.cssText = 'position:fixed; top:80px; left:50%; transform:translateX(-50%); z-index:100; 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
}
});
}
// A canvas sized directly from page-size * zoom with no upper bound can
// demand a multi-hundred-MB backing buffer on an oversized page (some
// scanned/cover pages declare a MediaBox far larger than a normal printed
// page) at high zoom — confirmed live as a full tab crash (Chromium
// "SIGTRAP") zooming a cover page to ~400%. Independent of the zoom-button
// cap (which only bounds the requested scale, not the page's own intrinsic
// size), clamp the actual rendered backing-buffer resolution to a safe
// pixel budget and let the canvas's CSS size — not its buffer size — carry
// the visual zoom; an oversized page just renders a bit softer instead of
// crashing the tab.
// Shrinking the OUTPUT canvas (as of 1.17.46/47) assumes the render cost
// scales with the target size — confirmed live that it doesn't always:
// the tab still hung at high zoom. If PDF.js is decoding an embedded
// image at ITS OWN native resolution before any downscaling happens, that
// decode runs synchronously on the main thread before our code (including
// the render timeout) ever gets control back — nothing awaitable can
// rescue a hang that already owns the thread. The only actually reliable
// mitigation is to keep the requested scale small enough that this page's
// embedded resource is never decoded at a pathological resolution in the
// first place, so these budgets are deliberately much smaller than before.
const READER_MAX_CANVAS_PIXELS = 2000000; // ~2MP
const READER_MAX_CANVAS_SIDE = 1800;
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 });
let renderViewport = viewport;
const overArea = viewport.width * viewport.height > READER_MAX_CANVAS_PIXELS;
const overSide = viewport.width > READER_MAX_CANVAS_SIDE || viewport.height > READER_MAX_CANVAS_SIDE;
if (overArea || overSide) {
let fit = overArea ? Math.sqrt(READER_MAX_CANVAS_PIXELS / (viewport.width * viewport.height)) : 1;
fit = Math.min(fit, READER_MAX_CANVAS_SIDE / Math.max(viewport.width, viewport.height));
renderViewport = pg.page.getViewport({ scale: scale * fit });
}
const canvas = document.createElement('canvas');
canvas.className = 'reader-canvas';
canvas.width = Math.floor(renderViewport.width);
canvas.height = Math.floor(renderViewport.height);
canvas.style.width = Math.floor(viewport.width) + 'px';
canvas.style.height = Math.floor(viewport.height) + 'px';
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: renderViewport });
// Even a capped-resolution canvas can sit on a page whose embedded
// image is itself huge (some codecs decode at native resolution before
// any downscaling) — a timeout here is the actual guarantee against a
// hung tab, independent of whatever the real bottleneck turns out to
// be. Cancelling leaves the page unrendered so scrolling past and back
// can retry it rather than leaving a permanently broken page.
await _readerTimeout(pg.renderTask.promise, 15000, 'Page render');
} catch (_) {
pg.rendered = false; canvas.remove();
try { pg.renderTask?.cancel(); } catch (_) {}
}
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 start 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 ' + (si + 1) + ' — now click the end sentence');
} else {
readerState.selEnd = si; readerState.selAnchored = false;
const r = readerSelRange();
readerSelHint('Selected ' + (r[1] - r[0] + 1) + ' sentences — click a new start, or Done');
}
readerPaintSelection();
readerUpdateScopeLabel();
}
function readerClearSelection() {
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
readerPaintSelection();
readerUpdateScopeLabel();
if (readerState.selecting) readerSelHint('Click the start 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
// {done, failed} — `failed` used to be swallowed entirely (a sentence whose
// TTS call errored just got its status reverted to 'pending' and `done` was
// incremented anyway), so a mid-book backend blip silently produced a
// merged/exported file with paragraphs missing and no indication anything
// had gone wrong. Callers now get the failed-index list back and must
// surface it instead of reporting a clean success.
// Same fix as Try a Voice / Studio: Fish-Speech ignores the free-text
// #reader-instruct field entirely (it only reads an inline [tag] in the text
// itself) — the emotion quick-pick applies as a tag on that backend instead.
function _readerTextForSynth(text) {
const backend = $('reader-backend-select')?.value || '';
const emotion = $('reader-emotion-select')?.value || '';
if (!/fish/i.test(backend) || !emotion) return text;
if (/^\s*\[/.test(text)) return text;
const tag = typeof _rehEmotionEnglishTag === 'function' ? _rehEmotionEnglishTag(emotion) : '';
return tag ? `[${tag}] ${text}` : text;
}
(function initReaderEmotionPicker() {
const sel = $('reader-emotion-select');
if (!sel || typeof REH_EMOTIONS === 'undefined') return;
REH_EMOTIONS.forEach(e => {
if (!e.value) return;
const o = document.createElement('option');
o.value = e.value;
o.textContent = `${e.emoji} ${e.label}`;
sel.appendChild(o);
});
sel.addEventListener('change', () => {
const backend = $('reader-backend-select')?.value || '';
if (!/fish/i.test(backend)) {
const instructInput = $('reader-instruct');
if (instructInput && sel.value) instructInput.value = sel.value;
}
});
})();
async function readerSynthIndices(targets) {
targets = targets.filter(i => !readerState.blobCache.has(i));
if (!targets.length || readerState.synthRunning) return { done: 0, failed: [] };
const voice = $('reader-voice-select')?.value;
const backend = $('reader-backend-select')?.value;
if (!voice) { toast('Pick a voice first', 'error'); return { done: 0, failed: [] }; }
if (!backend) { toast('No TTS backend selected', 'error'); return { done: 0, failed: [] }; }
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 failed = [];
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();
try {
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, _readerTextForSynth(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 (e) {
failed.push(i);
if (readerState.sentences[i]?.status === 'synth') readerSetStatus(i, 'pending');
console.error('[reader] synth failed for sentence', i, e);
}
done++; update();
}
};
const N = Math.min(2, targets.length); // bounded concurrency — don't overload the engine
await Promise.all(Array.from({ length: N }, worker));
} finally {
readerState.synthRunning = false;
if (prog) prog.hidden = true;
}
return { done, failed };
}
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, failed } = await readerSynthIndices(targets);
if (readerState.synthCancel) { toast('Synthesis cancelled (' + done + ' done)', 'error'); return; }
if (failed.length) { toast(done - failed.length + ' / ' + done + ' sentences synthesised — ' + failed.length + ' failed, see red markers', 'error'); return; }
toast('Synthesised ' + done + ' sentences', '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));
let failedCount = 0;
if (missing.length) {
toast('Synthesising ' + missing.length + ' missing sentence(s) before export…', 'success');
const { failed } = await readerSynthIndices(missing);
if (readerState.synthCancel) { toast('Export cancelled', 'error'); return; }
failedCount = failed.length;
}
const ready = indices.filter(i => readerState.blobCache.has(i));
if (!ready.length) { toast('Nothing to export', 'error'); return; }
// A segment that fails synthesis used to just vanish from the merged/
// exported file with no trace — the export "succeeded" while quietly
// missing paragraphs. Block the export instead: better a clear stop than a
// corrupted audiobook that only reveals the gap when someone listens.
if (failedCount) {
toast(failedCount + ' sentence(s) failed to synthesise — fix them (see red markers) before exporting, or missing audio will silently drop from the file', '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, _readerTextForSynth(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, _readerTextForSynth(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 /reader_library// (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 = '
No saved books yet.
'; 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 `
This stage will show the live passage-by-passage extraction view, prompt editor, and generated sheets once you start a cast.
'
+ '
'
+ ''
+ ''
+ ''
+ '
';
chars.className = '';
}
}
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' : view === 'chars' ? 'chars' : '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;
}
_readerEnsureImportVisible();
};
// #reader-config-card ("Voice & synthesis settings") also holds the only
// paste/drag-and-drop import controls (#reader-import-area) — collapsed by
// default (data-collapse-default="closed"), which is fine once a document is
// loaded, but leaves a first-time or post-delete user staring at an empty
// page with no visible way to import anything (confirmed live: deleting the
// current book landed on an empty box with the import controls hidden inside
// the collapsed card above it). data-collapse-default is only read once at
// initCollapsibleCards() init time — this mirrors its `apply(true)` directly
// rather than trying to re-trigger that logic, and never persists to
// localStorage, so a user's own collapse choice made once a document IS
// loaded is left alone.
function _readerEnsureImportVisible() {
if (readerState.sentences.length) return;
const card = document.getElementById('reader-config-card');
const body = card ? card.querySelector('.card-col-body') : null;
if (body && body.hidden) {
body.hidden = false;
card.classList.remove('card-col-closed');
}
}