// ── 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 = ''; ids.forEach(id => { const o = document.createElement('option'); o.value = o.textContent = id; sel.appendChild(o); }); if (prev && ids.includes(prev)) sel.value = prev; toast('Fetched ' + ids.length + ' voices', 'success'); } catch (e) { toast('Fetch failed: ' + (e.message || e), 'error'); } finally { if (btn) btn.disabled = false; } } // Guidance for steady long-form narration (addresses voice drift between chunks): // some backends re-sample timbre per request; flag those and suggest remedies. function readerUpdateBackendHint() { const el = $('reader-backend-hint'); if (!el) return; const id = $('reader-backend-select')?.value || ''; const b = (typeof backendById === 'function') ? backendById(id) : null; const steady = id && ((b && b.style_aware) || /design|custom|kokoro|magpie/i.test(id)); el.innerHTML = steady ? ' Good for long-form narration. For maximum steadiness, increase “Voice consistency”.' : ' Cloned / zero-shot voices re-sample each chunk, so the voice can drift between sections. To steady it: raise “Voice consistency” to paragraph/page, set a fixed Seed with a low Temperature (e.g. 0.3), keep “Normalise loudness” on, or pick a style-aware backend.'; el.classList.toggle('reader-hint-warn', !steady); } // ── Import ────────────────────────────────────────────────────────────────── 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 = ' Reading PDF… page 0 / ' + total + ''; 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 start sentence…' : ''); if (!readerState.selecting) readerPaintSelection(); // drop the transient anchor styling } function readerSelHint(html) { const h = $('reader-sel-hint'); if (!h) return; h.innerHTML = html; h.hidden = !html; } function readerPickSentence(si) { if (!readerState.selAnchored) { // start a fresh range readerState.selStart = si; readerState.selEnd = si; readerState.selAnchored = true; readerSelHint('Start at sentence ' + (si + 1) + ' — now click the end sentence'); } else { readerState.selEnd = si; readerState.selAnchored = false; const r = readerSelRange(); readerSelHint('Selected ' + (r[1] - r[0] + 1) + ' sentences — click a new start, or Done'); } readerPaintSelection(); readerUpdateScopeLabel(); } function readerClearSelection() { readerState.selStart = readerState.selEnd = null; readerState.selAnchored = false; readerPaintSelection(); readerUpdateScopeLabel(); if (readerState.selecting) readerSelHint('Click the start sentence…'); } function readerSelRange() { if (readerState.selStart === null || readerState.selEnd === null) return null; return [Math.min(readerState.selStart, readerState.selEnd), Math.max(readerState.selStart, readerState.selEnd)]; } function readerPaintSelection() { const range = readerSelRange(); const inSel = i => range && i >= range[0] && i <= range[1]; // While waiting for the second click, the lone start sentence is the "anchor". const anchorI = (readerState.selAnchored && range && range[0] === range[1]) ? range[0] : -1; readerState.sentences.forEach((s, i) => { const sel = inSel(i) && i !== anchorI; const anchor = i === anchorI; if (readerState.mode === 'pdf') { if (s._stat) { s._stat.classList.toggle('sel', sel); s._stat.classList.toggle('sel-anchor', anchor); } } else { s.words.forEach(w => { if (w.el) { w.el.classList.toggle('in-sel', sel); w.el.classList.toggle('sel-anchor', anchor); } }); } }); } // Indices to act on: an active selection wins, else PDF page range, else everything. function readerScopeIndices() { const all = readerState.sentences.map((_, i) => i); const range = readerSelRange(); if (range) return all.filter(i => i >= range[0] && i <= range[1]); if (readerState.mode === 'pdf') { const n = readerState.pages.length; let from = parseInt($('reader-page-from')?.value) || 1; let to = parseInt($('reader-page-to')?.value) || n; from = Math.max(1, Math.min(from, n)); to = Math.max(from, Math.min(to, n)); return all.filter(i => { const p = (readerState.sentences[i].words[0]?.page ?? 0) + 1; return p >= from && p <= to; }); } return all; } function readerUpdateScopeLabel() { const scopeEl = $('reader-synth-scope'); const range = readerSelRange(); if (scopeEl) { if (range) scopeEl.textContent = 'selection'; else if (readerState.mode === 'pdf') scopeEl.textContent = 'pages'; else scopeEl.textContent = 'all'; } const info = $('reader-sel-info'), txt = $('reader-sel-text'); if (info) info.hidden = !range; if (txt && range) txt.textContent = 'sentences ' + (range[0] + 1) + '–' + (range[1] + 1) + ' (' + (range[1] - range[0] + 1) + ')'; } // Synthesise the given sentence indices (skips already-cached). Returns 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 /reader_library// (meta.json + source + audio/). // Audio is fetched per-unit on demand so opening a book is instant on any device. const READER_API = '/api/reader/docs'; function readerSyntheticCount() { let n = 0; for (let i = 0; i < readerState.sentences.length; i++) if (readerState.blobCache.has(i)) n++; return n; } async function readerSaveLibrary() { if (!readerState.sentences.length) { toast('Import a document first', 'error'); return; } if (readerState.mode === 'pdf' && !readerState.fileBlob) { toast('Original PDF unavailable to save', 'error'); return; } const meta = { title: readerState.title || 'Untitled', kind: readerState.mode, idx: readerState.idx, voice: $('reader-voice-select')?.value || '', backend: $('reader-backend-select')?.value || '', speed: readerState.speed, instruct: $('reader-instruct')?.value.trim() || '', chunkMode: readerState.chunkMode, seed: $('reader-seed')?.value.trim() || '', temperature: $('reader-temp')?.value.trim() || '', tts_speed: parseFloat($('reader-tts-speed')?.value) || 1, normalize: readerState.normalize, sentenceCount: readerState.sentences.length, pageCount: readerState.pages.length, synthCount: readerSyntheticCount(), updated: new Date().toISOString(), }; const btn = $('reader-save-lib'); if (btn) btn.disabled = true; try { if (readerState.savedId) meta.id = readerState.savedId; const r = await fetch(READER_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta) }); if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); const fresh = !readerState.savedId; readerState.savedId = (await r.json()).id; // upload the source document once (it's needed to re-render on another device) if (fresh || !readerState.sourceUploaded) { const ext = readerState.mode === 'pdf' ? 'pdf' : 'txt'; const body = readerState.mode === 'pdf' ? readerState.fileBlob : new Blob([readerState.docText || ''], { type: 'text/plain' }); const sr = await fetch(`${READER_API}/${readerState.savedId}/source?ext=${ext}`, { method: 'PUT', body }); if (sr.ok) readerState.sourceUploaded = true; } // upload ONLY chunks not already on the server (incremental) let uploaded = 0; for (let i = 0; i < readerState.sentences.length; i++) { if (readerState.blobCache.has(i) && !readerState.savedAudioIdx.has(i)) { const ar = await fetch(`${READER_API}/${readerState.savedId}/audio/${i}`, { method: 'PUT', body: readerState.blobCache.get(i) }); if (ar.ok) { readerState.savedAudioIdx.add(i); uploaded++; } } } toast('Saved (' + meta.synthCount + ' / ' + meta.sentenceCount + ' synthesised' + (uploaded ? ', +' + uploaded + ' new' : '') + ')', 'success'); readerRenderLibrary(); } catch (e) { toast('Save failed: ' + (e.message || e), 'error'); } finally { if (btn) btn.disabled = false; } } // Light update of just the reading position (called on stop/pause/leave) async function readerPersistProgress() { if (!readerState.savedId) return; try { await fetch(`${READER_API}/${readerState.savedId}/progress`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ idx: readerState.idx, updated: new Date().toISOString() }), }); } catch (_) {} } async function readerDeleteLibraryDoc(id) { try { await fetch(`${READER_API}/${id}`, { method: 'DELETE' }); if (readerState.savedId === id) { readerState.savedId = null; readerState.savedAudioIdx = new Set(); readerState.sourceUploaded = false; } readerRenderLibrary(); } catch (e) { toast('Delete failed: ' + (e.message || e), 'error'); } } async function readerOpenLibraryDoc(id) { let rec; try { const r = await fetch(`${READER_API}/${id}`); if (!r.ok) throw new Error(r.statusText); rec = await r.json(); } catch (e) { toast('Open failed: ' + (e.message || e), 'error'); return; } readerResetDoc(); readerState.title = rec.title; const titleEl = $('reader-doc-title'); if (titleEl) titleEl.textContent = rec.title; // restore controls (chunk mode must be set BEFORE the document is grouped) readerState.chunkMode = rec.chunkMode || 'sentence'; if ($('reader-chunk-mode')) $('reader-chunk-mode').value = readerState.chunkMode; if ($('reader-seed')) $('reader-seed').value = rec.seed || ''; if ($('reader-temp')) $('reader-temp').value = rec.temperature || ''; if ($('reader-tts-speed') && typeof rec.tts_speed === 'number') $('reader-tts-speed').value = rec.tts_speed; readerState.normalize = rec.normalize !== false; if ($('reader-normalize')) $('reader-normalize').checked = readerState.normalize; if (rec.backend && $('reader-backend-select')) readerSetSelectValue('reader-backend-select', rec.backend); if (rec.voice && $('reader-voice-select')) readerSetSelectValue('reader-voice-select', rec.voice); if (typeof rec.speed === 'number') { readerState.speed = rec.speed; if ($('reader-speed')) $('reader-speed').value = rec.speed; const sl = $('reader-speed-label'); if (sl) sl.textContent = rec.speed.toFixed(2).replace(/0$/, '') + '×'; } if ($('reader-instruct')) $('reader-instruct').value = rec.instruct || ''; // fetch the source document from the server let sourceBlob = null; try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {} try { if (rec.kind === 'pdf') { if (!sourceBlob) { 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 = '
No saved books yet.
'; return; } list.innerHTML = all.map(rec => { const total = rec.sentenceCount || 0; const synth = rec.synthCount || 0; const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0; const synthPct = total ? Math.round((synth / total) * 100) : 0; const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : ''; let h = 0; const titleStr = rec.title || 'Untitled'; for (let i = 0; i < titleStr.length; i++) h = (h * 31 + titleStr.charCodeAt(i)) % 360; const h2 = (h + 40) % 360; const c1 = `hsl(${h}, 55%, 42%)`, c2 = `hsl(${h2}, 58%, 30%)`; // A timestamp busts the cache if the cover was updated const coverUrl = `${READER_API}/${rec.id}/cover?t=${new Date(rec.updated||Date.now()).getTime()}`; const bgStyle = rec.hasCover ? `style="background-image: linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.8) 100%), url('${coverUrl}'); background-size: cover; background-position: center; color: #fff;"` : `style="--bk1:${c1};--bk2:${c2}"`; return `
${escHtml(rec.title)}
${total} sentences${rec.pageCount ? ' · ' + rec.pageCount + ' pg' : ''}
${readPct}% read · ${date}
`; }).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 = `
Delete Book?
Audio files will be removed.
`; 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 = ''; 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 = ''; 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'); }); // 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 = '

No active casting session

Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.


'; 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; } };