- 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>
1522 lines
69 KiB
JavaScript
1522 lines
69 KiB
JavaScript
// ── 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;
|
||
}
|
||
};
|
||
|
||
|