diora-web/static/js/app/60-tts.js
mrwnslz 656520fe8e
Some checks failed
Build and push Docker image / build (push) Successful in 14s
Test / test (push) Successful in 8m23s
Test / e2e (push) Failing after 1m54s
Refactoring Schritt 2: app.js nach Bereichen in static/js/app/ aufgeteilt; sw v106
32 klassische Skripte in der alten Reihenfolge (01-core … 99-init), ohne
Änderung am Code darin: geschnitten nur zwischen Anweisungen, je Datei
'use strict' wie vorher für die ganze Datei. Geprüft (acorn): kein Code, der
beim Laden läuft, braucht einen Namen aus einer späteren Datei. Globale
Funktionen und let/const bleiben global, die App-Schichten (native.js,
native-eink.js, …) umhüllen sie wie bisher. Template und sw.js-SHELL listen
die Dateien; diora-android build_web nimmt static/js rekursiv.

Getestet: 14 Browser-Tests (e2e), Desktop-App (Buch, Lesestand, Leiste im
Reader, Hell/Dunkel).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:30:43 +02:00

337 lines
11 KiB
JavaScript

'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 <mark>. 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 = `
<button type="button" class="tts-pause-btn" title="Pause/Weiter">⏸</button>
<button type="button" class="tts-stop-btn" title="Vorlesen beenden">■</button>
`;
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();
}