tts-voice-creator-clone-and.../static/js/reader.js
mARTin-B78 4f795e820c feat: noise gate, PDF search, sidebar active highlight, stats collapse, tooltip + dialogue fixes
- Conversation: adjustable noise gate slider (RMS threshold + 300 ms
  minimum burst duration) prevents short noise spikes from triggering
  STT; level meter shows gate position as a blue marker
- Conversation stats panel now collapsible (chevron button) to free
  chat width; floating expand button restores it; state persists
- Read Aloud: text search input in PDF toolbar (Enter = next hit,
  Shift+Enter = previous, Esc = clear)
- Sidebar: tooltip now works for all item types including sub-items
  that had no .nav-label span (text extracted by stripping icon/badge)
- Sidebar active section indicator added (.nav-tree-item.active was
  previously unstyled — active section now has bg + right accent bar)
- Casting audiobook: prompt instructs LLM to handle ?« / !« endings
  and unclosed » at passage end as dialogue; deterministic fallback
  also handles unclosed opening quote

Bumps to v1.12.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:48:48 +02:00

1522 lines
69 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
};
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
function readerCtx() {
if (!readerState.audioCtx) readerState.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
if (readerState.audioCtx.state === 'suspended') readerState.audioCtx.resume().catch(() => {});
return readerState.audioCtx;
}
// ── 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();
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;
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 ──────────────────────────────────────────────────────────────────
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('Reading PDF…', 'success');
await readerLoadPdf(file);
} else {
const text = await file.text();
readerState.docText = text;
readerLoadText(text);
}
if (!readerState.sentences.length) { toast('No readable text found in this document', 'error'); return; }
if (readerState.mode === 'text') readerState.sentences.forEach((s, i) => readerSetStatus(i, 'pending'));
$('reader-transport').hidden = false;
$('reader-dropzone').hidden = true;
$('reader-toolbar').hidden = readerState.mode !== 'pdf';
$('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 = readerLoadResume();
readerUpdateProgress();
readerHighlightSentence(readerState.sentences[readerState.idx]);
toast(resumed
? 'Resumed at sentence ' + (readerState.idx + 1) + ' / ' + readerState.sentences.length
: 'Loaded · ' + readerState.sentences.length + ' sentences — press play', 'success');
} catch (e) { toast('Import failed: ' + (e.message || e), 'error'); }
}
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;
if (readerState.io) { readerState.io.disconnect(); readerState.io = null; }
readerState.synthRunning = false;
readerState.synthCancel = false;
readerSetSelecting(false);
readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false;
const sb = $('reader-synthbar'); if (sb) sb.hidden = true;
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'); }
}
// 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) {
if (!cur) cur = { text: '', words: [], status: 'pending', _stat: null };
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 }));
}
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 };
cur.text += (cur.text ? ' ' : '') + s.text;
cur.words.push(...s.words);
}
if (cur) units.push(cur);
return units;
}
// ── PDF import: build scale-1 geometry; render lazily at current scale ───────
async function readerLoadPdf(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;
readerState.mode = 'pdf';
readerState.pdfDoc = pdf;
const doc = $('reader-doc');
const nPages = pdf.numPages;
const progress = readerShowParseProgress(nPages);
// Pre-fetch page 1 geometry to define scale/zoom quickly for skeletons
const page1 = await pdf.getPage(1);
if (seq !== readerState._seq) { progress.done(); return; }
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 });
}
// Apply zoom and rasterize immediately so the user sees the book!
readerApplyZoom('fit-width');
readerSetupLazyRaster();
// 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;
// We do NOT change pageDiv.style.width here, it will be updated by readerApplyZoom / readerRenderPage
// 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;
const pageWords = [];
for (const item of content.items) {
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;
}
}
// 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();
}
// Lightweight floating parse-progress line shown in the document pane for big PDFs.
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:16px;"></span> <span>Reading PDF… page 0 / ' + total + '</span>';
el.style.cssText = 'position:sticky; top:12px; z-index:100; margin:0 auto -42px; background:var(--accent); color:#fff; padding:6px 14px; border-radius:20px; font-size:12px; display:inline-flex; align-items:center; gap:8px; box-shadow:0 4px 12px rgba(0,0,0,.25); font-weight:600;';
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);
}));
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;
const dr = doc.getBoundingClientRect();
readerState.pages.forEach((pg, i) => {
const r = pg.pageDiv.getBoundingClientRect();
if (r.bottom > dr.top - 800 && r.top < dr.bottom + 800) readerRenderPage(i);
});
readerEvictCanvases(dr);
}
// 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) 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 ────────────────────
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,}/);
paras.forEach((para, pi) => {
const pEl = document.createElement('p');
pEl.className = 'reader-text-para';
para.split('\n').forEach((line, li, arr) => {
line.trim().split(/\s+/).forEach(tok => {
if (!tok) return;
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 });
});
if (li < arr.length - 1) pEl.appendChild(document.createElement('br'));
});
pane.appendChild(pEl);
});
readerState.baseSentences = readerBuildSentences(words);
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;
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();
}
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();
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) { toast('Saved PDF data missing', 'error'); return; }
readerState.fileBlob = sourceBlob;
toast('Opening book…', 'success');
await readerLoadPdf(sourceBlob);
} else {
const text = sourceBlob ? await sourceBlob.text() : (rec.text || '');
readerState.docText = text;
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;
$('reader-toolbar').hidden = readerState.mode !== 'pdf';
$('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;
}
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-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>';
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 _readerSearchIdx = -1; // last hit sentence index
function readerSearchGo(term, direction) {
if (!term) { const st = $('reader-search-status'); if (st) st.textContent = ''; return; }
const lc = term.toLowerCase();
const sents = readerState.sentences;
if (!sents || !sents.length) { const st = $('reader-search-status'); if (st) st.textContent = 'No text loaded'; return; }
const dir = direction === -1 ? -1 : 1;
const start = _readerSearchIdx < 0 ? 0 : (_readerSearchIdx + dir + sents.length) % sents.length;
for (let i = 0; i < sents.length; i++) {
const idx = (start + i * dir + sents.length * sents.length) % sents.length;
const s = sents[idx];
const txt = (s.words || []).map(w => w.text).join(' ').toLowerCase();
if (txt.includes(lc)) {
_readerSearchIdx = idx;
const pageIdx = s.words?.[0]?.page ?? 0;
const pgDiv = readerState.pages[pageIdx]?.pageDiv;
if (pgDiv) pgDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
const st = $('reader-search-status');
const total = sents.filter(s2 => (s2.words||[]).map(w=>w.text).join(' ').toLowerCase().includes(lc)).length;
if (st) st.textContent = total ? `p.${pageIdx + 1}` : '';
return;
}
}
const st = $('reader-search-status');
if (st) st.textContent = 'Not found';
}
$('reader-search')?.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); readerSearchGo(e.target.value.trim(), e.shiftKey ? -1 : 1); }
if (e.key === 'Escape') { e.target.value = ''; _readerSearchIdx = -1; const st = $('reader-search-status'); if (st) st.textContent = ''; }
});
$('reader-search')?.addEventListener('input', e => {
_readerSearchIdx = -1;
if (!e.target.value) { const st = $('reader-search-status'); if (st) st.textContent = ''; }
});
// 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.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
}
}
};
window.readerOnShow = function () {
if (window._readerStartView) {
window.showReaderView(window._readerStartView);
window._readerStartView = null;
}
};