'use strict'; // --------------------------------------------------------------------------- // Read-aloud (TTS) — server-side Piper synthesis, one sentence per request. // EPUB only (no block model for PDFs). Highlights the sentence currently // playing and scrolls it into view; playback advances sentence-by-sentence, // then block-by-block, until stopped or the book ends. // --------------------------------------------------------------------------- const TTS_MAX_CHARS = 480; // stay under the server's 500-char cap with margin let ttsActive = false; let ttsPaused = false; let ttsRunToken = 0; let ttsAudio = null; let ttsCurrentMark = null; let ttsBarEl = null; let ttsLang = localStorage.getItem('diora_tts_lang') || 'de'; let _ttsBlocks = null; // the book's blocks while reading aloud let _ttsCur = null; // what's being read: {bi, si, sentence, block} function setTtsLang(lang) { ttsLang = lang; localStorage.setItem('diora_tts_lang', lang); } function _ttsSplitSentences(text) { const raw = text.split(/(?<=[.!?])\s+/).map(s => s.trim()).filter(Boolean); const pieces = raw.length ? raw : [text]; const out = []; for (const piece of pieces) { let rest = piece; while (rest.length > TTS_MAX_CHARS) { let cut = rest.lastIndexOf(' ', TTS_MAX_CHARS); if (cut <= 0) cut = TTS_MAX_CHARS; out.push(rest.slice(0, cut).trim()); rest = rest.slice(cut).trim(); } if (rest) out.push(rest); } return out; } async function _ttsFetchAudio(sentence) { const resp = await fetch('/tts/synthesize/', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({text: sentence, lang: ttsLang}), }); if (!resp.ok) throw new Error('tts request failed'); return await resp.blob(); } function _ttsClearHighlight() { if (ttsCurrentMark && ttsCurrentMark.parentNode) { const parent = ttsCurrentMark.parentNode; while (ttsCurrentMark.firstChild) parent.insertBefore(ttsCurrentMark.firstChild, ttsCurrentMark); parent.removeChild(ttsCurrentMark); parent.normalize(); } ttsCurrentMark = null; } // Finds `sentence` as a substring of block.textContent starting at fromOffset // (so repeated sentence text earlier in the block isn't matched again), turns // it into a DOM Range via the same char-offset addressing highlights use, and // wraps it in a . Returns the offset to resume searching from. function _ttsHighlightSentence(block, sentence, fromOffset) { // Listening is reading: where the voice is counts as the reading position. _noteReaderInteraction(); _ttsClearHighlight(); const full = block.textContent; const idx = full.indexOf(sentence, fromOffset); if (idx === -1) return fromOffset; const start = _nodeAtCharOffset(block, idx); const end = _nodeAtCharOffset(block, idx + sentence.length); if (!start || !end) return idx + sentence.length; try { const range = document.createRange(); range.setStart(start.node, start.offset); range.setEnd(end.node, end.offset); const mark = document.createElement('mark'); mark.className = 'tts-current'; range.surroundContents(mark); ttsCurrentMark = mark; _ttsScrollIntoView(mark); } catch (e) {} return idx + sentence.length; } function _ttsScrollIntoView(el) { const contentEl = $('reader-content'); if (!contentEl) return; const top = el.getBoundingClientRect().top - contentEl.getBoundingClientRect().top; if (top < 40 || top > contentEl.clientHeight - 80) { _suppressScrollJumpDetect(); contentEl.scrollBy({top: top - contentEl.clientHeight * 0.3, behavior: 'smooth'}); } } function _ttsPlayBlob(blob) { return new Promise((resolve) => { const url = URL.createObjectURL(blob); const audio = new Audio(url); ttsAudio = audio; const done = () => { URL.revokeObjectURL(url); resolve(); }; audio.addEventListener('ended', done); audio.addEventListener('error', done); if (!ttsPaused) audio.play().catch(done); }); } // Walks blocks/sentences from startBlockIndex (and its sentence startSentence) // onward, fetching one sentence ahead while the current one plays so there's // no gap between them. async function _ttsPlayLoop(blocks, startBlockIndex, runToken, startSentence = 0) { function* sentenceStream() { for (let bi = startBlockIndex; bi < blocks.length; bi++) { const block = blocks[bi]; const text = (block.textContent || '').trim(); if (!text) continue; const sentences = _ttsSplitSentences(text); for (let si = bi === startBlockIndex ? startSentence : 0; si < sentences.length; si++) { yield {block, sentence: sentences[si], bi, si}; } } } const iter = sentenceStream(); let cur = iter.next(); if (cur.done) { stopReadAloud(); return; } let curFetch = _ttsFetchAudio(cur.value.sentence); let lastBlock = null; let blockSearchOffset = 0; while (!cur.done) { if (runToken !== ttsRunToken) return; const {block, sentence} = cur.value; const next = iter.next(); const nextFetch = next.done ? null : _ttsFetchAudio(next.value.sentence).catch(() => null); let audioBlob; try { audioBlob = await curFetch; } catch (e) { stopReadAloud(); return; } if (runToken !== ttsRunToken) return; if (block !== lastBlock) { lastBlock = block; // Started in the middle of a block (previous/next sentence): find it from there. blockSearchOffset = _ttsOffsetOf(block, cur.value.si); } _ttsCur = {bi: cur.value.bi, si: cur.value.si, sentence, block}; blockSearchOffset = _ttsHighlightSentence(block, sentence, blockSearchOffset); _ttsNotify(); await _ttsPlayBlob(audioBlob); if (runToken !== ttsRunToken) return; cur = next; curFetch = nextFetch; } if (runToken === ttsRunToken) stopReadAloud(); } function _ttsTogglePause() { ttsPaused = !ttsPaused; if (ttsAudio) { if (ttsPaused) ttsAudio.pause(); else ttsAudio.play().catch(() => {}); } _ttsUpdateBar(); _ttsNotify(); } // ---- read-aloud for a player bar (the shared dock in native-podcast.js) ---------- // window.__dioraTts: state(), toggle(), stop(), next(), prev(); a 'diora-tts' // event on window after every change. A dock that shows it sets // window.__dioraTtsDock = true, and the reader's own little bar stays away. function _ttsState() { const meta = (currentBookId && bookMetaCache[currentBookId]) || {}; const cur = _ttsCur; const chapterEl = cur && cur.block.closest('[data-epub-src]'); const blocks = _ttsBlocks || []; return { active: ttsActive, paused: ttsPaused, bookId: currentBookId, title: meta.title || '', author: meta.author || '', chapter: chapterEl ? _tocTitleForChapterSrc(chapterEl.dataset.epubSrc) : '', sentence: cur ? cur.sentence : '', progress: cur && blocks.length ? cur.bi / blocks.length : 0, }; } function _ttsNotify() { try { window.dispatchEvent(new CustomEvent('diora-tts', {detail: _ttsState()})); } catch (e) {} } // Read from another sentence on: the one before/after the current one. function _ttsRestartAt(bi, si) { const blocks = _ttsBlocks; if (!ttsActive || !blocks) return; ttsRunToken++; const runToken = ttsRunToken; if (ttsAudio) { try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} ttsAudio = null; } _ttsPlayLoop(blocks, bi, runToken, si); } // Where sentence si of a block starts in its text (for the highlight). function _ttsOffsetOf(block, si) { const full = block.textContent || ''; let off = 0; _ttsSplitSentences(full.trim()).slice(0, si).forEach(s => { const k = full.indexOf(s, off); if (k >= 0) off = k + s.length; }); return off; } function _ttsSentenceCount(block) { const text = (block.textContent || '').trim(); return text ? _ttsSplitSentences(text).length : 0; } function _ttsStep(dir) { const cur = _ttsCur; const blocks = _ttsBlocks; if (!cur || !blocks) return; if (dir > 0) { if (cur.si + 1 < _ttsSentenceCount(cur.block)) return _ttsRestartAt(cur.bi, cur.si + 1); for (let bi = cur.bi + 1; bi < blocks.length; bi++) if (_ttsSentenceCount(blocks[bi])) return _ttsRestartAt(bi, 0); stopReadAloud(); } else { if (cur.si > 0) return _ttsRestartAt(cur.bi, cur.si - 1); for (let bi = cur.bi - 1; bi >= 0; bi--) { const n = _ttsSentenceCount(blocks[bi]); if (n) return _ttsRestartAt(bi, n - 1); } _ttsRestartAt(cur.bi, 0); } } window.__dioraTts = { state: _ttsState, toggle: () => (ttsActive ? _ttsTogglePause() : startReadAloud()), stop: () => stopReadAloud(), next: () => _ttsStep(1), prev: () => _ttsStep(-1), }; function _ttsUpdateBar() { if (!ttsBarEl) return; const btn = ttsBarEl.querySelector('.tts-pause-btn'); if (btn) btn.textContent = ttsPaused ? '▶' : '⏸'; } function _ttsShowBar() { _ttsRemoveBar(); if (window.__dioraTtsDock) return; // the player bar shows it const bar = document.createElement('div'); bar.className = 'tts-bar'; bar.innerHTML = ` `; document.body.appendChild(bar); ttsBarEl = bar; bar.querySelector('.tts-pause-btn').addEventListener('click', _ttsTogglePause); bar.querySelector('.tts-stop-btn').addEventListener('click', stopReadAloud); } function _ttsRemoveBar() { if (ttsBarEl) { ttsBarEl.remove(); ttsBarEl = null; } } function toggleReadAloud() { if (ttsActive) stopReadAloud(); else startReadAloud(); } function startReadAloud() { if (ttsActive) return; if (currentPdfDoc) { const toast = document.createElement('div'); toast.className = 'reader-toast'; toast.textContent = 'Vorlesen ist aktuell nur für EPUB-Bücher verfügbar.'; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2200); return; } const contentEl = $('reader-content'); if (!contentEl) return; const blocks = Array.from(contentEl.querySelectorAll(EPUB_BLOCK_SELECTOR)); if (!blocks.length) return; const [anchorBlock] = _anchorParts(getPositionAnchor(contentEl)); const startIndex = (anchorBlock >= 0 && anchorBlock < blocks.length) ? anchorBlock : 0; ttsActive = true; ttsPaused = false; ttsRunToken++; const runToken = ttsRunToken; _ttsBlocks = blocks; _ttsCur = null; const btn = $('reader-tts-btn'); if (btn) { btn.classList.add('active'); btn.title = 'Vorlesen beenden'; } _ttsShowBar(); _ttsNotify(); _ttsPlayLoop(blocks, startIndex, runToken); } function stopReadAloud() { ttsRunToken++; ttsActive = false; ttsPaused = false; if (ttsAudio) { try { ttsAudio.pause(); ttsAudio.src = ''; } catch (e) {} ttsAudio = null; } _ttsClearHighlight(); _ttsRemoveBar(); _ttsBlocks = null; _ttsCur = null; const btn = $('reader-tts-btn'); if (btn) { btn.classList.remove('active'); btn.title = 'Vorlesen'; } _ttsNotify(); }