The streaming attribution endpoint decoded the LLM's SSE response with requests' guessed encoding (Latin-1 fallback when no charset is declared), mangling every German umlaut. Forced UTF-8 explicitly. The "LLM Thinking" pane duplicated the passage text for models that ignore the <think> instruction and stream straight into JSON - it now only shows real reasoning when present, and otherwise labels raw output honestly instead of passing it off as thinking. Also fixed three UI bugs found while testing a live multi-hour cast: - A-/A+ font buttons had no effect (a hardcoded font-size on .ab-cv-row always overrode the CSS variable they set). - Typing a name + Enter in the "Assign to" popup (and drag-to-assign, which reuses it) silently did nothing after the first cast/recast run in a session - the popup is a page-lifetime singleton but its input handlers closed over the first run's now-stale assignName/closePopup. Every popup open now repoints them at the current run. - "Split text to Unknown Speaker" split at the wrong spot when the selected phrase repeated earlier in the same paragraph (indexOf found the first occurrence, not the dragged one). Now uses the exact DOM range offset instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4183 lines
218 KiB
JavaScript
4183 lines
218 KiB
JavaScript
// ── Book → multi-speaker audiobook ──────────────────────────────────────────
|
||
//
|
||
// Bridges the Read Aloud reader and the Script Rehearser: an LLM scans the
|
||
// document (in the current scope — selection / page range / whole book),
|
||
// attributes every segment to a speaker ("Narrator" or a character) with an
|
||
// emotion, then hands the result to the Script Rehearser as a cast-able script
|
||
// so each character gets its own voice. The rehearser is the editable preview:
|
||
// you fix any mis-attribution, cast voices, and synthesise there.
|
||
//
|
||
// Reuses: /api/attribute-dialogue (LLM), readerState + readerScopeIndices()
|
||
// (reader.js), parseScript / detectCharacters / rehState / rehDefaultLlmUrl
|
||
// (rehearser.js), splitTextIntoChunks (generation.js), $ / toast (utils.js).
|
||
|
||
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
|
||
const AUDIOBOOK_WARMUP_TIMEOUT_MS = 240000;
|
||
// A chunk's max_tokens (routes/conversation.py) can reach ~4000 on slow local models
|
||
// (~15 tok/s on modest hardware), i.e. up to ~4.5 minutes of pure generation time —
|
||
// the old 90s timeout aborted almost every passage before the model finished.
|
||
const AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS = 360000;
|
||
const AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS = 180000;
|
||
const AUDIOBOOK_RECAST_TIMEOUT_MS = 240000;
|
||
const AUDIOBOOK_RECAST_CONTEXT_CHARS = 4200;
|
||
const AUDIOBOOK_RECAST_TARGETS_PER_CALL = 6;
|
||
const AUDIOBOOK_DRAFT_AUTOSAVE_MS = 5 * 60 * 1000;
|
||
const _audiobook = { running: false, cancel: false };
|
||
window._audiobook = _audiobook;
|
||
let _abDraftAutosaveTimer = null;
|
||
let _abLastServerDraftErrorAt = 0;
|
||
|
||
async function audiobookFetchWithTimeout(url, options = {}, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
|
||
const parentSignal = options.signal;
|
||
const ac = new AbortController();
|
||
let timedOut = false;
|
||
const timer = setTimeout(() => {
|
||
timedOut = true;
|
||
ac.abort();
|
||
}, timeoutMs);
|
||
const onParentAbort = () => ac.abort(parentSignal?.reason);
|
||
if (parentSignal) {
|
||
if (parentSignal.aborted) onParentAbort();
|
||
else parentSignal.addEventListener('abort', onParentAbort, { once: true });
|
||
}
|
||
try {
|
||
return await fetch(url, { ...options, signal: ac.signal });
|
||
} catch (err) {
|
||
if (timedOut) {
|
||
const timeoutErr = new Error(`Timed out after ${Math.ceil(timeoutMs / 1000)}s`);
|
||
timeoutErr.name = 'TimeoutError';
|
||
throw timeoutErr;
|
||
}
|
||
throw err;
|
||
} finally {
|
||
clearTimeout(timer);
|
||
if (parentSignal) parentSignal.removeEventListener('abort', onParentAbort);
|
||
}
|
||
}
|
||
|
||
function audiobookTimeoutSeconds(timeoutMs) {
|
||
return Math.max(5, Math.round(timeoutMs / 1000));
|
||
}
|
||
|
||
// Streamed attribution: reads the SSE feed from /api/attribute-dialogue/stream,
|
||
// pushing each delta into view.thinking() so the user can watch the LLM work,
|
||
// and resolves with the final parsed result. Uses an INACTIVITY timeout (reset
|
||
// on every received chunk) rather than an overall one — a passage legitimately
|
||
// takes minutes on a slow model, but silence that long means the stream died.
|
||
// Throws on any failure; callers fall back to the blocking endpoint. A user
|
||
// Stop (outer signal) propagates as AbortError; a stalled stream does not.
|
||
async function audiobookAttributeStream(body, view, outerSignal, idleTimeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) {
|
||
const ctl = new AbortController();
|
||
const onAbort = () => ctl.abort();
|
||
if (outerSignal) {
|
||
if (outerSignal.aborted) ctl.abort();
|
||
else outerSignal.addEventListener('abort', onAbort, { once: true });
|
||
}
|
||
let idleTimer = null;
|
||
const armIdle = () => {
|
||
clearTimeout(idleTimer);
|
||
idleTimer = setTimeout(() => ctl.abort(), idleTimeoutMs);
|
||
};
|
||
try {
|
||
armIdle();
|
||
const r = await fetch('/api/attribute-dialogue/stream', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
signal: ctl.signal, body: JSON.stringify({ ...body, want_reasoning: true }),
|
||
});
|
||
if (!r.ok || !r.body) throw new Error('stream HTTP ' + r.status);
|
||
const reader = r.body.getReader();
|
||
const dec = new TextDecoder();
|
||
let buf = '', result = null;
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
armIdle();
|
||
buf += dec.decode(value, { stream: true });
|
||
let at;
|
||
while ((at = buf.indexOf('\n\n')) >= 0) {
|
||
const line = buf.slice(0, at).trim();
|
||
buf = buf.slice(at + 2);
|
||
if (!line.startsWith('data:')) continue;
|
||
let d;
|
||
try { d = JSON.parse(line.slice(5)); } catch (_) { continue; }
|
||
if (d.t && view?.thinking) view.thinking(d.t);
|
||
if (d.error) throw new Error(d.error);
|
||
if (d.done) result = d.result || null;
|
||
}
|
||
}
|
||
if (!result) throw new Error('stream ended without result');
|
||
return result;
|
||
} catch (err) {
|
||
// An idle-timeout abort is a stream failure (fall back), not a user Stop.
|
||
if (err?.name === 'AbortError' && !(outerSignal && outerSignal.aborted)) {
|
||
throw new Error('stream idle timeout');
|
||
}
|
||
throw err;
|
||
} finally {
|
||
clearTimeout(idleTimer);
|
||
if (outerSignal) outerSignal.removeEventListener('abort', onAbort);
|
||
}
|
||
}
|
||
|
||
function audiobookDialogueKey(text) {
|
||
return String(text || '')
|
||
.normalize('NFKC')
|
||
.replace(/[»«„“”"‘’'`]+/g, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function audiobookSegmentText(seg) {
|
||
if (!seg) return '';
|
||
if (seg.type === 'narration' || !seg.speaker || /^Unknown|Unbekannt/i.test(seg.speaker)) return seg.text || '';
|
||
return `"${seg.text || ''}"`;
|
||
}
|
||
|
||
// Word-under-cursor for the casting feed WITHOUT wrapping every word in its
|
||
// own <span> — an earlier version did exactly that, and at book scale
|
||
// (1500+ segments × dozens of words) the ~100k extra DOM nodes with hover
|
||
// styles froze the page on every feed redraw. Instead this asks the browser
|
||
// which text position sits under the pointer (caretRangeFromPoint /
|
||
// caretPositionFromPoint — both native and cheap), then expands to word
|
||
// boundaries. Returns { word, range } or null; the Range provides the rect
|
||
// for the hover highlight overlay and popup anchoring.
|
||
function _abWordRangeAtPoint(x, y) {
|
||
let node = null, offset = 0;
|
||
if (document.caretRangeFromPoint) {
|
||
const r = document.caretRangeFromPoint(x, y);
|
||
if (!r) return null;
|
||
node = r.startContainer; offset = r.startOffset;
|
||
} else if (document.caretPositionFromPoint) {
|
||
const p = document.caretPositionFromPoint(x, y);
|
||
if (!p) return null;
|
||
node = p.offsetNode; offset = p.offset;
|
||
} else return null;
|
||
if (!node || node.nodeType !== 3) return null;
|
||
const text = node.nodeValue || '';
|
||
const isW = ch => ch != null && /[\p{L}\p{N}'’-]/u.test(ch);
|
||
if (offset >= text.length) offset = text.length - 1;
|
||
if (!isW(text[offset])) {
|
||
if (offset > 0 && isW(text[offset - 1])) offset--;
|
||
else return null;
|
||
}
|
||
let a = offset, b = offset;
|
||
while (a > 0 && isW(text[a - 1])) a--;
|
||
while (b + 1 < text.length && isW(text[b + 1])) b++;
|
||
const word = text.slice(a, b + 1).trim();
|
||
if (!word || word.length < 2) return null;
|
||
const range = document.createRange();
|
||
range.setStart(node, a);
|
||
range.setEnd(node, b + 1);
|
||
return { word, range };
|
||
}
|
||
|
||
function audiobookJoinSegmentText(a, b) {
|
||
const left = String(a || '');
|
||
const right = String(b || '');
|
||
if (!left) return right;
|
||
if (!right) return left;
|
||
if (/\s$/.test(left) || /^\s/.test(right)) return left + right;
|
||
if (/^[,.;:!?»«”"')\]]/.test(right)) return left + right;
|
||
if (/[([{„“"']$/.test(left)) return left + right;
|
||
return left + ' ' + right;
|
||
}
|
||
|
||
function audiobookRecastGroups(unknownIdxs, segs) {
|
||
const groups = [];
|
||
let cur = [];
|
||
for (const idx of unknownIdxs) {
|
||
const prev = cur.length ? cur[cur.length - 1] : -Infinity;
|
||
const shortGap = idx <= prev + 2
|
||
&& segs.slice(prev + 1, idx).every(s => !s || s.type === 'narration' || /^Unknown|Unbekannt/i.test(s.speaker || ''));
|
||
if (!cur.length || (shortGap && cur.length < AUDIOBOOK_RECAST_TARGETS_PER_CALL)) cur.push(idx);
|
||
else { groups.push(cur); cur = [idx]; }
|
||
}
|
||
if (cur.length) groups.push(cur);
|
||
return groups;
|
||
}
|
||
|
||
function audiobookRecastContext(segs, group) {
|
||
const targetStart = group[0];
|
||
const targetEnd = group[group.length - 1] + 1;
|
||
let start = Math.max(0, targetStart - 5);
|
||
let end = Math.min(segs.length, targetEnd + 4);
|
||
const render = () => segs.slice(start, end).map(audiobookSegmentText).join(' ').trim();
|
||
let text = render();
|
||
while (text.length > AUDIOBOOK_RECAST_CONTEXT_CHARS && (start < targetStart || end > targetEnd)) {
|
||
if (end > targetEnd) end--;
|
||
text = render();
|
||
if (text.length <= AUDIOBOOK_RECAST_CONTEXT_CHARS) break;
|
||
if (start < targetStart) start++;
|
||
text = render();
|
||
}
|
||
const recent = audiobookRecastRecent(segs, start, end);
|
||
return { text, start, end, recent };
|
||
}
|
||
|
||
function audiobookRecastRecent(segs, start, end) {
|
||
const lines = [];
|
||
for (let i = Math.max(0, start - 10); i < Math.min(segs.length, end + 8); i++) {
|
||
const s = segs[i];
|
||
if (!s || s.type !== 'dialogue' || !s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)) continue;
|
||
lines.push(`${i < start ? 'before' : i >= end ? 'after' : 'near'}: ${s.speaker}: ${(s.text || '').slice(0, 100)}`);
|
||
}
|
||
return lines.slice(-12).join('\n');
|
||
}
|
||
|
||
function audiobookFindReturnedSegment(targetSeg, returned, used) {
|
||
const targetKey = audiobookDialogueKey(targetSeg?.text);
|
||
if (!targetKey) return null;
|
||
let loose = null;
|
||
for (let i = 0; i < returned.length; i++) {
|
||
if (used.has(i)) continue;
|
||
const cand = returned[i];
|
||
if (!cand) continue;
|
||
const candKey = audiobookDialogueKey(cand.text);
|
||
if (!candKey) continue;
|
||
if (candKey === targetKey) return { idx: i, seg: cand };
|
||
const minLen = Math.min(candKey.length, targetKey.length);
|
||
if (minLen >= 18 && (candKey.includes(targetKey) || targetKey.includes(candKey))) {
|
||
loose = loose || { idx: i, seg: cand };
|
||
}
|
||
}
|
||
return loose;
|
||
}
|
||
|
||
function audiobookFindReturnedSegmentSequence(targetSeg, returned, used) {
|
||
const targetKey = audiobookDialogueKey(targetSeg?.text);
|
||
if (!targetKey) return null;
|
||
for (let i = 0; i < returned.length; i++) {
|
||
if (used.has(i) || !returned[i]) continue;
|
||
let joined = '';
|
||
const idxs = [];
|
||
for (let j = i; j < returned.length; j++) {
|
||
if (used.has(j) || !returned[j]) break;
|
||
joined = audiobookJoinSegmentText(joined, returned[j].text || '');
|
||
idxs.push(j);
|
||
const key = audiobookDialogueKey(joined);
|
||
if (key === targetKey) return { idxs, segs: idxs.map(k => returned[k]) };
|
||
if (key.length > targetKey.length * 1.35 + 40) break;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// String coercer (mirrors library.js _libStr)
|
||
function _abStr(v) {
|
||
if (v == null) return '';
|
||
if (typeof v === 'string') return v;
|
||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||
return String(v);
|
||
}
|
||
|
||
// ── Autosave / draft recovery ─────────────────────────────────────────────────
|
||
// Saves the accumulated segments to localStorage after every chunk so a page
|
||
// refresh or browser crash doesn't lose hours of casting work.
|
||
|
||
const _AB_DRAFT_KEY = 'ttsvc_ab_draft';
|
||
|
||
// The book this casting session belongs to. When a saved library book is open,
|
||
// the draft is keyed by its id so reopening the SAME book always restores its
|
||
// cast — independent of any drift in the extracted text fingerprint (the old
|
||
// text-only key silently lost the cast whenever PDF re-extraction differed even
|
||
// slightly). Falls back to the global key for unsaved/ad-hoc documents.
|
||
function _abBookId() {
|
||
return (window.readerState && readerState.savedId) || _audiobook.bookId || null;
|
||
}
|
||
function _abDraftKey(bookId) {
|
||
return bookId ? (_AB_DRAFT_KEY + '_' + bookId) : _AB_DRAFT_KEY;
|
||
}
|
||
|
||
function _abTextId(text) {
|
||
// Cheap fingerprint — no need for a full hash; length + first/last chars is enough
|
||
// to detect doc changes without storing the source text itself.
|
||
const s = (text.slice(0, 300) + text.slice(-300)).replace(/\s+/g, '');
|
||
let h = 5381;
|
||
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
|
||
return h.toString(36) + '_' + text.length;
|
||
}
|
||
|
||
function _abSafeFilename(name, fallback = 'cast') {
|
||
const s = String(name || fallback || 'cast')
|
||
.replace(/[\\/:*?"<>|]+/g, '_')
|
||
.replace(/\s+/g, '_')
|
||
.replace(/^_+|_+$/g, '');
|
||
return (s || fallback || 'cast').slice(0, 120);
|
||
}
|
||
|
||
// Mirrors routes/reader.py's _cast_to_md — a readable Markdown script (plain
|
||
// paragraphs for narration, **SPEAKER** (emotion): "line" for dialogue,
|
||
// grouped under page headings), used when there's no server-side bookId to
|
||
// export through. One-way export for reading/sharing, not meant to round-trip.
|
||
function _abCastMarkdown(data) {
|
||
const payload = data || {};
|
||
const segments = Array.isArray(payload.segments) ? payload.segments : [];
|
||
const speakers = [...new Set(
|
||
segments.filter(s => s?.type === 'dialogue' && s.speaker).map(s => String(s.speaker))
|
||
)].sort();
|
||
const lines = [`# ${payload.title || 'Cast Script'}`, ''];
|
||
const metaBits = [];
|
||
if (payload.savedAt) metaBits.push('exported ' + new Date(payload.savedAt).toISOString().slice(0, 16).replace('T', ' '));
|
||
metaBits.push(`${speakers.length} character${speakers.length !== 1 ? 's' : ''}`);
|
||
metaBits.push(`${segments.length} segment${segments.length !== 1 ? 's' : ''}`);
|
||
lines.push(`*${metaBits.join(' · ')}*`, '');
|
||
if (speakers.length) lines.push(`**Characters:** ${speakers.join(', ')}`, '');
|
||
let lastPage = null;
|
||
for (const seg of segments) {
|
||
const text = String(seg?.text || '').trim();
|
||
if (!text) continue;
|
||
if (seg.page != null && seg.page !== lastPage) {
|
||
lines.push('---', '', `## Page ${seg.page}`, '');
|
||
lastPage = seg.page;
|
||
}
|
||
if (seg.type === 'dialogue') {
|
||
const speaker = String(seg.speaker || 'Narrator');
|
||
const tag = seg.emotion ? ` *(${seg.emotion})*` : '';
|
||
lines.push(`**${speaker.toUpperCase()}**${tag}: "${text}"`);
|
||
} else {
|
||
lines.push(text);
|
||
}
|
||
lines.push('');
|
||
}
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// One-time migration for drafts saved before segments carried a .page number:
|
||
// re-derive each segment's page from the draft's pageMarks via the old
|
||
// forward-only offset search and stamp it on. Mirrors the legacy render-time
|
||
// fallback exactly (segments before the first mark stay unstamped → unlabeled
|
||
// leading card), but runs once at draft load instead of on every redraw, so
|
||
// the feed renderer stays single-path.
|
||
function _abStampSegmentPages(segs, pageMarks, text) {
|
||
if (!Array.isArray(segs) || !segs.length || segs.some(s => s && s.page != null)) return;
|
||
const marks = (pageMarks || []).slice().sort((a, b) => (a.offset || 0) - (b.offset || 0));
|
||
const src = text || '';
|
||
if (!marks.length || !src) return;
|
||
let markIdx = 0, searchPos = 0, cur = null;
|
||
if (marks[0].offset <= 2) { cur = marks[0].page + 1; markIdx = 1; }
|
||
for (const s of segs) {
|
||
const probe = String(s.text || '').trim().slice(0, 24);
|
||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||
const pos = at >= 0 ? at : searchPos;
|
||
if (at >= 0) searchPos = at + probe.length;
|
||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||
cur = marks[markIdx].page + 1;
|
||
markIdx++;
|
||
}
|
||
if (cur != null) s.page = cur;
|
||
}
|
||
}
|
||
|
||
function _abSaveDraft(segs, roster, text, done, total) {
|
||
const bookId = _abBookId();
|
||
const payload = {
|
||
bookId: bookId,
|
||
title: window.readerState?.title || '',
|
||
textId: _abTextId(text),
|
||
segments: segs,
|
||
roster: roster,
|
||
pageMarks: _audiobook.pageMarks || [],
|
||
rehId: _audiobook.rehId || null,
|
||
done: done,
|
||
total: total,
|
||
savedAt: Date.now()
|
||
};
|
||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(payload)); } catch (_) {}
|
||
// Mirror to server when a library book is open (fire-and-forget)
|
||
if (bookId) {
|
||
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}).then(r => {
|
||
if (!r.ok) throw new Error(r.statusText || `HTTP ${r.status}`);
|
||
}).catch(err => {
|
||
console.warn('[audiobook] server draft autosave failed:', err);
|
||
const now = Date.now();
|
||
if (now - _abLastServerDraftErrorAt > 60000) {
|
||
_abLastServerDraftErrorAt = now;
|
||
if (typeof toast === 'function') toast('Server autosave failed. Browser draft is still saved.', 'error');
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
let _abExportBusy = false;
|
||
async function audiobookExportCastMd() {
|
||
// Re-entry guard: while the page is busy, a user's queued-up clicks can all
|
||
// fire at once when the main thread unblocks — without this, that meant a
|
||
// burst of identical downloads and one save-dialog per copy to cancel.
|
||
if (_abExportBusy) return;
|
||
_abExportBusy = true;
|
||
setTimeout(() => { _abExportBusy = false; }, 2000);
|
||
const segs = _audiobook.segments || [];
|
||
if (!segs.length) { toast('No cast to export', 'error'); return; }
|
||
const bookId = _abBookId();
|
||
if (_audiobook.lastText) {
|
||
_abSaveDraft(segs, _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
|
||
}
|
||
const title = window.readerState?.title || _audiobook.title || 'audiobook';
|
||
if (bookId) {
|
||
try {
|
||
const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast/export`);
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||
const blob = await r.blob();
|
||
const disp = r.headers.get('Content-Disposition') || '';
|
||
const match = disp.match(/filename="([^"]+)"/i);
|
||
const name = match?.[1] || `${_abSafeFilename(title)}_cast.zip`;
|
||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||
else {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = name;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 500);
|
||
}
|
||
toast('Exported cast + character sheets (.zip)', 'success');
|
||
return;
|
||
} catch (err) {
|
||
toast('Server export failed, downloading local cast copy', 'error');
|
||
}
|
||
}
|
||
const payload = {
|
||
bookId,
|
||
title,
|
||
textId: _audiobook.lastText ? _abTextId(_audiobook.lastText) : '',
|
||
segments: segs,
|
||
roster: _audiobook.roster || [],
|
||
pageMarks: _audiobook.pageMarks || [],
|
||
rehId: _audiobook.rehId || null,
|
||
done: _audiobook.completedChunks || 0,
|
||
total: _audiobook.completedTotal || 0,
|
||
savedAt: Date.now(),
|
||
};
|
||
const blob = new Blob([_abCastMarkdown(payload)], { type: 'text/markdown;charset=utf-8' });
|
||
if (typeof readerDownload === 'function') readerDownload(blob, `${_abSafeFilename(title)}_cast.md`);
|
||
else {
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `${_abSafeFilename(title)}_cast.md`;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(a.href), 500);
|
||
}
|
||
}
|
||
|
||
function _abStartDraftAutosave(saveNow) {
|
||
_abStopDraftAutosave();
|
||
if (typeof saveNow !== 'function') return;
|
||
_abDraftAutosaveTimer = setInterval(saveNow, AUDIOBOOK_DRAFT_AUTOSAVE_MS);
|
||
const saveOnLeave = () => saveNow();
|
||
_audiobook._draftSaveOnLeave = saveOnLeave;
|
||
window.addEventListener('pagehide', saveOnLeave);
|
||
window.addEventListener('beforeunload', saveOnLeave);
|
||
}
|
||
|
||
function _abStopDraftAutosave() {
|
||
if (_abDraftAutosaveTimer) clearInterval(_abDraftAutosaveTimer);
|
||
_abDraftAutosaveTimer = null;
|
||
if (_audiobook._draftSaveOnLeave) {
|
||
window.removeEventListener('pagehide', _audiobook._draftSaveOnLeave);
|
||
window.removeEventListener('beforeunload', _audiobook._draftSaveOnLeave);
|
||
_audiobook._draftSaveOnLeave = null;
|
||
}
|
||
}
|
||
|
||
async function _abEnsureLibraryBook() {
|
||
const rs = window.readerState;
|
||
if (!rs || rs.savedId || !rs.sentences?.length) return !!rs?.savedId;
|
||
if (rs.mode === 'pdf' && !rs.fileBlob) return false;
|
||
|
||
const meta = {
|
||
title: rs.title || 'Untitled',
|
||
kind: rs.mode,
|
||
idx: rs.idx,
|
||
voice: $('reader-voice-select')?.value || '',
|
||
backend: $('reader-backend-select')?.value || '',
|
||
speed: rs.speed,
|
||
instruct: $('reader-instruct')?.value.trim() || '',
|
||
chunkMode: rs.chunkMode,
|
||
seed: $('reader-seed')?.value.trim() || '',
|
||
temperature: $('reader-temp')?.value.trim() || '',
|
||
tts_speed: parseFloat($('reader-tts-speed')?.value) || 1,
|
||
normalize: rs.normalize,
|
||
sentenceCount: rs.sentences.length,
|
||
pageCount: rs.pages?.length || 0,
|
||
synthCount: 0,
|
||
updated: new Date().toISOString(),
|
||
};
|
||
|
||
try {
|
||
const r = await fetch('/api/reader/docs', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(meta),
|
||
});
|
||
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText);
|
||
rs.savedId = (await r.json()).id;
|
||
_audiobook.bookId = rs.savedId;
|
||
|
||
const ext = rs.mode === 'pdf' ? 'pdf' : 'txt';
|
||
const body = rs.mode === 'pdf'
|
||
? rs.fileBlob
|
||
: new Blob([rs.docText || audiobookScopeText() || ''], { type: 'text/plain' });
|
||
const sr = await fetch(`/api/reader/docs/${encodeURIComponent(rs.savedId)}/source?ext=${ext}`, { method: 'PUT', body });
|
||
if (sr.ok) rs.sourceUploaded = true;
|
||
if (typeof readerRenderLibrary === 'function') readerRenderLibrary();
|
||
return true;
|
||
} catch (err) {
|
||
console.warn('[audiobook] could not create reader-library autosave target:', err);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function _abLoadDraftServer(bookId) {
|
||
if (!bookId) return null;
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
try {
|
||
const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`);
|
||
if (!r.ok) {
|
||
if (attempt === 0 && (r.status === 404 || r.status >= 500)) {
|
||
await new Promise(resolve => setTimeout(resolve, 650));
|
||
continue;
|
||
}
|
||
return null;
|
||
}
|
||
const d = await r.json();
|
||
if (d && Array.isArray(d.segments) && d.segments.length) return d;
|
||
return null;
|
||
} catch (_) {
|
||
if (attempt === 0) await new Promise(resolve => setTimeout(resolve, 650));
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function _abLoadDraft(text) {
|
||
const bookId = _abBookId();
|
||
const textId = _abTextId(text);
|
||
const title = String(window.readerState?.title || '').trim().toLowerCase();
|
||
try {
|
||
// 1. Per-book localStorage — fastest, no network
|
||
if (bookId) {
|
||
const raw = localStorage.getItem(_abDraftKey(bookId));
|
||
if (raw) {
|
||
const d = JSON.parse(raw);
|
||
if (d && Array.isArray(d.segments) && d.segments.length) return d;
|
||
}
|
||
}
|
||
// 2. Global draft (ad-hoc docs) — must match the text fingerprint
|
||
const raw = localStorage.getItem(_AB_DRAFT_KEY);
|
||
if (raw) {
|
||
const d = JSON.parse(raw);
|
||
if (d && Array.isArray(d.segments) && d.segments.length && d.textId === textId) return d;
|
||
}
|
||
|
||
// 3. Recovery fallback: the same saved book can receive a new id after an
|
||
// import/save cycle. Scan older per-book drafts with the same title so a good
|
||
// cast does not appear lost just because the storage key changed.
|
||
let best = null;
|
||
for (let i = 0; i < localStorage.length; i++) {
|
||
const key = localStorage.key(i);
|
||
if (!key || !key.startsWith(_AB_DRAFT_KEY + '_')) continue;
|
||
if (bookId && key === _abDraftKey(bookId)) continue;
|
||
try {
|
||
const cand = JSON.parse(localStorage.getItem(key) || 'null');
|
||
if (!cand || !Array.isArray(cand.segments) || !cand.segments.length) continue;
|
||
const candTitle = String(cand.title || '').trim().toLowerCase();
|
||
const exactText = cand.textId && cand.textId === textId;
|
||
const sameTitle = title && candTitle && candTitle === title;
|
||
if (!exactText && !sameTitle) continue;
|
||
const score = (exactText ? 1000000 : 0) + (sameTitle ? 100000 : 0) + cand.segments.length;
|
||
if (!best || score > best.score) best = { score, draft: cand };
|
||
} catch (_) {}
|
||
}
|
||
if (best?.draft) return best.draft;
|
||
} catch (_) { return null; }
|
||
return null;
|
||
}
|
||
|
||
function _abClearDraft() {
|
||
const bookId = _abBookId();
|
||
try { localStorage.removeItem(_abDraftKey(bookId)); } catch (_) {}
|
||
if (bookId) {
|
||
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'DELETE' }).catch(() => {});
|
||
}
|
||
}
|
||
|
||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||
// German »...«/„...“, French «...», single ‘...’/›...‹, CJK 「...」『...』, em-dash speech.
|
||
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/;
|
||
function audiobookHasDialogue(t) { return AB_DIALOGUE_RE.test(t || ''); }
|
||
|
||
// Join words hyphenated across a PDF line break ("Schwer- tes" → "Schwertes")
|
||
// so the audiobook reads cleanly and speaker tags aren't split.
|
||
function audiobookDehyphenate(t) { return (t || '').replace(/([a-zäöüß])-\s+(?=[a-zäöüßA-ZÄÖÜ])/g, '$1'); }
|
||
|
||
// Speech-tag heuristic so the book still casts with REAL names when the LLM is down.
|
||
const AB_SPEECH_VERBS = '(?:sagte|fragte|rief|antwortete|erwiderte|entgegnete|meinte|flüsterte|wisperte|raunte|murmelte|brummte|knurrte|brüllte|schrie|stammelte|fauchte|zischte|seufzte|lachte|kicherte|befahl|wiederholte|fuhr\\s+fort|said|asked|replied|answered|whispered|murmured|muttered|shouted|cried|called|exclaimed|added|continued)';
|
||
const AB_NOTNAME = new Set([
|
||
'Der', 'Die', 'Das', 'Den', 'Dem', 'Ein', 'Eine', 'Einen', 'Er', 'Sie', 'Es', 'Ich', 'Du', 'Wir', 'Ihr', 'Man',
|
||
'Und', 'Aber', 'Da', 'Dann', 'Doch', 'So', 'Nun', 'Jetzt', 'The', 'He', 'She', 'It', 'They', 'A', 'An', 'And', 'But', 'Then', 'Now',
|
||
// common sentence-initial adverbs / interjections / abstractions that are NOT characters
|
||
'Sofort', 'Plötzlich', 'Endlich', 'Schließlich', 'Stille', 'Schweigen', 'Stimme', 'Stimmen', 'Frage', 'Antwort',
|
||
'Gelächter', 'Wieder', 'Gleich', 'Sogleich', 'Langsam', 'Leise', 'Laut', 'Kaum', 'Vielleicht', 'Natürlich', 'Wirklich',
|
||
'Ja', 'Nein', 'Komm', 'Warte', 'Halt', 'Geh', 'Hier', 'Dort', 'Oben', 'Unten', 'Schon', 'Noch', 'Auch', 'Nur', 'Immer', 'Nie']);
|
||
const _AB_NAME = "([A-ZÄÖÜ][A-Za-zäöüß'\\-]+)";
|
||
|
||
// Person/role nouns that are plausible speakers when no proper name is found.
|
||
// A generic "der/die + any capitalized noun" fallback assigned scenery words
|
||
// as characters (PLATZ from "auf dem Platz", GESICHTER, KLEINIGKEIT) — a
|
||
// closed whitelist can't make that class of mistake.
|
||
const AB_PERSON_NOUNS = new Set(['Mann','Frau','Junge','Mädchen','Alte','Alter','Fremde','Fremder','Krieger','Kriegerin',
|
||
'Wächter','Wache','Soldat','Hauptmann','Ork','Ritter','Magier','Magierin','Zwerg','Elf','Elfe','Händler','Wirt','Wirtin',
|
||
'Bauer','Priester','Priesterin','Nachbar','Nachbarin','Sklave','Sklavin','Verweser','Inquisitor','General','König','Königin',
|
||
'Prinz','Prinzessin','Fürst','Fürstin','Baron','Baronin','Bote','Diener','Dienerin','Knabe','Kind','Reiter','Reiterin',
|
||
'Bogenschütze','Schmied','Schmiedin','Heiler','Heilerin','Gelehrte','Gelehrter','Kapitän','Anführer','Anführerin']);
|
||
|
||
// Deterministic Unknown-resolution — the LLM keeps missing mechanical
|
||
// patterns no matter how explicitly the prompt spells them out (measured:
|
||
// ~44% Unknown on a full book WITH the rules in the prompt), so apply them
|
||
// in code where they cannot be ignored:
|
||
// A. Colon rule: the narration right before a quote ends with ":" → its
|
||
// last-mentioned character (roster name, whitelisted person noun, or the
|
||
// "Name … und sagte:" subject) speaks.
|
||
// B. Post-quote inquit: the narration right after a quote starts with a
|
||
// speech verb + Name, "Der X, der gesprochen hatte", or the impersonal
|
||
// "ertönte es … . Name …" formula → that character spoke.
|
||
// C. Two-person alternation: an Unknown whose two nearest preceding dialogue
|
||
// lines have two different known speakers gets the earlier of the two
|
||
// (strict turn-taking) — only when both neighbours are unambiguous.
|
||
// Only fills segments still Unknown; never overrides an LLM attribution.
|
||
function audiobookResolveUnknowns(segs, prevTail, roster) {
|
||
const isUnknown = s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker));
|
||
const isNamed = s => s?.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt|Narrator$/i.test(s.speaker);
|
||
const names = (roster || []).filter(n => n && !/^(Narrator|Unknown|Unbekannt)/i.test(n)).sort((a, b) => b.length - a.length);
|
||
const lastNameIn = (text) => {
|
||
let best = null, bestAt = -1;
|
||
for (const n of names) {
|
||
const at = text.lastIndexOf(n);
|
||
if (at > bestAt) { bestAt = at; best = n; }
|
||
}
|
||
if (best) return best;
|
||
// "Marcian unterdrückte seinen Ärger und sagte:" — subject of the final
|
||
// speech clause, provided it isn't a sentence-opener adverb/article.
|
||
const clause = text.match(new RegExp(_AB_NAME + "[^.!?:]{0,80}\\b(?:und\\s+)?(?:" + AB_SPEECH_VERBS + ")[^:]{0,60}:\\s*$"));
|
||
if (clause && !AB_NOTNAME.has(clause[1]) && !AB_PERSON_NOUNS.has(clause[1])) return clause[1];
|
||
// whitelisted person nouns only — never arbitrary capitalized nouns
|
||
const m = [...text.matchAll(/\b(?:[Dd]er|[Dd]ie|[Dd]en|[Dd]em|[Ee]in|[Ee]ine)\s+([A-ZÄÖÜ][a-zäöüß]{2,})\b/g)]
|
||
.map(x => x[1]).filter(n => AB_PERSON_NOUNS.has(n));
|
||
return m.length ? m[m.length - 1] : null;
|
||
};
|
||
const all = [...(prevTail || []), ...segs];
|
||
const offset = (prevTail || []).length;
|
||
const resolved = [];
|
||
for (let i = offset; i < all.length; i++) {
|
||
const s = all[i];
|
||
if (!isUnknown(s)) continue;
|
||
const prev = all[i - 1], next = all[i + 1];
|
||
let who = null;
|
||
if (prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim())) {
|
||
who = lastNameIn(String(prev.text || ''));
|
||
}
|
||
if (!who && next?.type === 'narration') {
|
||
const nt = String(next.text || '').trim();
|
||
let m = nt.match(new RegExp('^\\W{0,3}(?:' + AB_SPEECH_VERBS + ')\\s+(?:der|die)?\\s*' + _AB_NAME));
|
||
if (!m) m = nt.match(new RegExp('^(?:Der|Die)\\s+' + _AB_NAME + ',\\s+(?:der|die)\\s+gesprochen hatte'));
|
||
// "ertönte es plötzlich über ihm. Karyla hatte …" — impersonal formula,
|
||
// the next sentence's subject is the speaker.
|
||
if (!m) m = nt.match(new RegExp('^\\W{0,3}(?:ertönte|erklang|tönte|drang|kam)\\b[^.!?]{0,60}\\bes\\b[^.!?]{0,60}[.!?]\\s*' + _AB_NAME));
|
||
if (m && !AB_NOTNAME.has(m[1])) who = m[1];
|
||
}
|
||
const prevEndsColon = prev?.type === 'narration' && /:\s*$/.test(String(prev.text || '').trim());
|
||
if (!who && !prevEndsColon) {
|
||
// Two-person alternation, strictly: the two nearest preceding dialogue
|
||
// lines carry two DIFFERENT known names → this line belongs to the one
|
||
// who didn't just speak. Guards: never when the preceding narration
|
||
// ends with ":" (that colon introduces someone the rules above already
|
||
// failed to identify — alternation would be a guess, not a deduction),
|
||
// never across page boundaries, and only within a short window.
|
||
const prevDialogues = [];
|
||
for (let j = i - 1; j >= 0 && j >= i - 6 && prevDialogues.length < 2; j--) {
|
||
if (all[j]?.page != null && s.page != null && all[j].page !== s.page) break;
|
||
if (all[j]?.type !== 'dialogue') continue;
|
||
if (!isNamed(all[j])) { prevDialogues.length = 0; break; } // an unresolved line breaks the chain
|
||
prevDialogues.push(all[j].speaker);
|
||
}
|
||
if (prevDialogues.length === 2 && prevDialogues[0] !== prevDialogues[1]) who = prevDialogues[1];
|
||
}
|
||
if (who && !/^(Narrator|Unknown|Unbekannt)$/i.test(who)) {
|
||
s.speaker = who;
|
||
resolved.push(who);
|
||
}
|
||
}
|
||
return resolved;
|
||
}
|
||
const _AB_TAG_NAME = "[A-ZÄÖÜ][A-Za-zäöüß'\\-]+";
|
||
const AB_SPEECH_TAG_ONLY_RE = new RegExp(
|
||
'^\\s*[,.;:!?–-]*\\s*(?:' +
|
||
'(?:' + AB_SPEECH_VERBS + ')\\b\\s+(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|' + _AB_TAG_NAME + ')' +
|
||
'|' +
|
||
'(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|' + _AB_TAG_NAME + ')\\s+(?:' + AB_SPEECH_VERBS + ')\\b' +
|
||
')' +
|
||
'(?:\\s+(?:mit|in|leise|laut|kalt|heiser|erstickt|ruhig|zornig|wütend|ängstlich|spöttisch|verächtlich|fragend|flüsternd|schrill|dumpf|slowly|coldly|quietly|softly|angrily|hoarsely)\\b[\\s\\S]*)?' +
|
||
'[.!?…]*\\s*$',
|
||
'i'
|
||
);
|
||
function audiobookIsSpeechTagOnly(text) {
|
||
const t = String(text || '').trim();
|
||
if (!t || t.length > 180) return false;
|
||
if (/[»«„“”"‟‚‘’›‹『「]/.test(t)) return false;
|
||
return AB_SPEECH_TAG_ONLY_RE.test(t);
|
||
}
|
||
function audiobookGuessSpeaker(after, before) {
|
||
let m;
|
||
const ok = n => (n && !AB_NOTNAME.has(n)) ? n : null;
|
||
// NOTE: no 'i' flag — names must be genuinely capitalized; German speech verbs
|
||
// after a quote are lowercase, so this rejects pronouns like "sagte er".
|
||
// after the quote: ", sagte Riskan" / "sagte Riskan" (verb → name)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + AB_SPEECH_VERBS + '\\s+(?:der|die|das|ein|eine)?\\s*' + _AB_NAME).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// after the quote: ", Riskan sagte" (name → verb)
|
||
if ((m = new RegExp('^[\\s,;–-]*' + _AB_NAME + '\\s+' + AB_SPEECH_VERBS).exec(after || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
// before the quote: "Riskan sagte:" / "Riskan fragte"
|
||
if ((m = new RegExp(_AB_NAME + '\\s+' + AB_SPEECH_VERBS + '[\\s:,–-]*$').exec(before || ''))) { const r = ok(m[1]); if (r) return r; }
|
||
return null;
|
||
}
|
||
|
||
// Deterministic fallback: split a passage into narration + dialogue by quotation
|
||
// spans and attribute speakers from the surrounding speech tags. Used when the LLM
|
||
// is unavailable so dialogue — and as many speakers as possible — are never lost.
|
||
const AB_QUOTE_PAIRS = {
|
||
'»': '«', '«': '»', '„': '“', '“': '”', '"': '"',
|
||
'「': '」', '『': '』', '‘': '’', '‚': '‘', '›': '‹', '‹': '›'
|
||
};
|
||
|
||
// Constant pattern — compiled once, not per sentence-ending character scanned.
|
||
const _AB_UNCLOSED_TAG_RE = new RegExp(
|
||
'^\\s*(?:[,;:–-]\\s*)?(?:' + AB_SPEECH_VERBS + '|(?:er|sie|es|ich|du|wir|ihr|he|she|it|they|I|we|you)\\s+' + AB_SPEECH_VERBS + ')\\b',
|
||
'i'
|
||
);
|
||
|
||
function _abUnclosedQuoteEnd(raw, closeIdx) {
|
||
const limit = closeIdx >= 0 ? closeIdx : raw.length;
|
||
for (let i = 0; i < limit; i++) {
|
||
if (!/[.!?]/.test(raw[i])) continue;
|
||
const quote = raw.slice(0, i + 1).trim();
|
||
if (quote.length < 2) continue;
|
||
const after = raw.slice(i + 1, Math.min(limit, i + 140));
|
||
if (_AB_UNCLOSED_TAG_RE.test(after)) return i + 1;
|
||
}
|
||
if (closeIdx < 0) {
|
||
const m = raw.match(/^([\s\S]{2,180}?[.!?])(?:\s|$)/);
|
||
if (m && raw.slice(m[1].length).trim().length > 40) return m[1].length;
|
||
}
|
||
return closeIdx >= 0 ? closeIdx : raw.length;
|
||
}
|
||
|
||
function _abOrphanClosingQuoteSpan(text, closeIdx, minStart) {
|
||
if (!'«”’‹」』'.includes(text[closeIdx])) return null;
|
||
const before = text.slice(minStart, closeIdx);
|
||
const prev = before.match(/\S(?=\s*$)/)?.[0] || '';
|
||
if (!/[.!?]/.test(prev)) return null;
|
||
const trimmedLen = before.trimEnd().length;
|
||
let localStart = 0;
|
||
const boundaryRe = /[\n\r]|[.!?:]\s+/g;
|
||
let m;
|
||
while ((m = boundaryRe.exec(before))) {
|
||
const next = m.index + m[0].length;
|
||
if (next < trimmedLen - 1) localStart = next;
|
||
}
|
||
const quote = before.slice(localStart).trim();
|
||
if (!/^[\s\S]{2,220}[.!?]$/.test(quote)) return null;
|
||
return { start: minStart + localStart, end: closeIdx + 1, quote };
|
||
}
|
||
|
||
function audiobookSplitByQuotes(text) {
|
||
const spans = [];
|
||
for (let i = 0; i < text.length; i++) {
|
||
const open = text[i];
|
||
const close = AB_QUOTE_PAIRS[open];
|
||
if (!close) continue;
|
||
const minStart = spans.length ? spans[spans.length - 1].end : 0;
|
||
const orphan = _abOrphanClosingQuoteSpan(text, i, minStart);
|
||
if (orphan) {
|
||
spans.push(orphan);
|
||
i = orphan.end - 1;
|
||
continue;
|
||
}
|
||
if ((open === '«' || open === '»') && i > 0 && !/[\s([{—–-]/.test(text[i - 1])) continue;
|
||
const raw = text.slice(i + 1);
|
||
const closeIdx = raw.indexOf(close);
|
||
const endInRaw = _abUnclosedQuoteEnd(raw, closeIdx);
|
||
const quote = raw.slice(0, endInRaw).trim();
|
||
if (!quote) continue;
|
||
const consumedClose = closeIdx >= 0 && endInRaw === closeIdx;
|
||
spans.push({ start: i, end: i + 1 + endInRaw + (consumedClose ? 1 : 0), quote });
|
||
i = spans[spans.length - 1].end - 1;
|
||
}
|
||
if (!spans.length) return [{ speaker: 'Narrator', type: 'narration', text, emotion: '' }];
|
||
const out = []; let last = 0;
|
||
for (let k = 0; k < spans.length; k++) {
|
||
const sp = spans[k];
|
||
const pre = text.slice(last, sp.start);
|
||
if (pre.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: pre.trim(), emotion: '' });
|
||
if (sp.quote) {
|
||
const after = text.slice(sp.end, k + 1 < spans.length ? spans[k + 1].start : text.length);
|
||
const speaker = audiobookGuessSpeaker(after, pre) || 'Unknown';
|
||
out.push({ speaker, type: 'dialogue', text: sp.quote, emotion: '' });
|
||
}
|
||
last = sp.end;
|
||
}
|
||
const tail = text.slice(last).trimEnd();
|
||
if (tail.trim()) out.push({ speaker: 'Narrator', type: 'narration', text: tail.trim(), emotion: '' });
|
||
return audiobookTurnTaking(out);
|
||
}
|
||
|
||
// Fill 'Unknown' dialogue speakers by two-person alternation — but only once TWO
|
||
// distinct named speakers are established nearby (conservative: won't guess in a
|
||
// monologue, so it rarely invents a wrong name).
|
||
function audiobookTurnTaking(segs) {
|
||
let a = null, b = null; // two most recent distinct named speakers (b = latest)
|
||
for (const s of segs) {
|
||
if (s.type !== 'dialogue') continue;
|
||
if (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)) {
|
||
if (s.speaker !== b) { a = b; b = s.speaker; }
|
||
} else if (a && b && a !== b) {
|
||
s.speaker = a; // the other of the two → alternate
|
||
const t = a; a = b; b = t; // rotate so the next Unknown alternates back
|
||
}
|
||
}
|
||
return segs;
|
||
}
|
||
|
||
function audiobookIsRouterModel(model) {
|
||
return /^auto[-_ ]?router/i.test(String(model || '').trim());
|
||
}
|
||
|
||
function audiobookSafeLlmModel(model) {
|
||
const m = String(model || '').trim();
|
||
const saved = String((typeof _appSettings !== 'undefined' && _appSettings && _appSettings.llm_model) || '').trim();
|
||
if (audiobookIsRouterModel(m) && saved && !audiobookIsRouterModel(saved)) return saved;
|
||
return m || saved;
|
||
}
|
||
|
||
function audiobookLlmUrl() { return $('reh-llm-url')?.value.trim() || (typeof _appSettings !== 'undefined' && _appSettings?.llm_url) || (typeof rehDefaultLlmUrl === 'function' ? rehDefaultLlmUrl() : ''); }
|
||
function audiobookLlmModel() { return audiobookSafeLlmModel($('reh-llm-model')?.value || ''); }
|
||
// Book language for the attribution LLM: the manual dropdown wins, otherwise
|
||
// detect it from the text itself (stop-word heuristic, utils.js detectLang) so
|
||
// the server's "keep names and wording in <language>" hint and grammar rules
|
||
// are applied even when nobody touched the dropdown.
|
||
function audiobookLang(text) {
|
||
const manual = $('reh-design-lang')?.value || '';
|
||
if (manual) return manual;
|
||
const sample = String(text || _audiobook.lastText || '').slice(0, 4000);
|
||
return (sample && typeof detectLang === 'function') ? (detectLang(sample) || '') : '';
|
||
}
|
||
|
||
function audiobookAttributeBody(fields, promptOverride = null) {
|
||
const prompt = typeof promptOverride === 'string' ? promptOverride.trim() : '';
|
||
return prompt ? { ...fields, audiobook_prompt: prompt } : fields;
|
||
}
|
||
|
||
function audiobookCurrentCastLlm(panel) {
|
||
const url = panel?.querySelector('#ab-cv-llm-url')?.value.trim() || audiobookLlmUrl();
|
||
const rawModel = panel?.querySelector('#ab-cv-llm-select')?.value || audiobookLlmModel();
|
||
return { url, model: audiobookSafeLlmModel(rawModel) };
|
||
}
|
||
|
||
function audiobookSaveLlmChoice(url, model) {
|
||
const cleanUrl = String(url || '').trim();
|
||
const cleanModel = audiobookSafeLlmModel(model);
|
||
if (typeof _appSettings !== 'undefined' && _appSettings) {
|
||
if (cleanUrl) _appSettings.llm_url = cleanUrl;
|
||
if (cleanModel) _appSettings.llm_model = cleanModel;
|
||
}
|
||
const patch = {};
|
||
if (cleanUrl) patch.llm_url = cleanUrl;
|
||
if (cleanModel) patch.llm_model = cleanModel;
|
||
if (Object.keys(patch).length) {
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(patch)
|
||
}).catch(() => {});
|
||
}
|
||
const rehModel = $('reh-llm-model');
|
||
if (rehModel && cleanModel && [...rehModel.options].some(o => o.value === cleanModel)) rehModel.value = cleanModel;
|
||
return { url: cleanUrl, model: cleanModel };
|
||
}
|
||
|
||
// Gather the plain text of the current reader scope (selection > page range > all).
|
||
// Also records PDF page boundaries as character offsets in the returned text
|
||
// (_audiobook.pageMarks) so saving to the Rehearser can reconstruct page breaks.
|
||
function audiobookScopeText() {
|
||
_audiobook.pageMarks = [];
|
||
if (typeof readerScopeIndices !== 'function' || !readerState?.sentences?.length) return '';
|
||
const idxs = readerScopeIndices();
|
||
const anchors = []; // {page, anchor} — first sentence of each new page
|
||
let lastPage = null;
|
||
for (const i of idxs) {
|
||
const u = readerState.sentences[i];
|
||
const pg = u?.words?.[0]?.page;
|
||
if (pg != null && pg !== lastPage) {
|
||
anchors.push({ page: pg, anchor: (u.text || '').trim().slice(0, 40) });
|
||
lastPage = pg;
|
||
}
|
||
}
|
||
// Join units with a blank line wherever one starts a real paragraph (per
|
||
// readerMarkParagraphBreaks/.paraStart) instead of unconditionally with a
|
||
// single space — otherwise chapter headings and every paragraph break in
|
||
// the book collapse into one run-on blob before the LLM ever sees the text.
|
||
let raw = '';
|
||
for (const i of idxs) {
|
||
const u = readerState.sentences[i];
|
||
if (!u?.text) continue;
|
||
raw += !raw ? u.text : (u.paraStart ? '\n\n' : ' ') + u.text;
|
||
}
|
||
// Collapse only horizontal whitespace runs and cap excessive blank lines —
|
||
// a plain /\s+/ collapse here would erase the \n\n paragraph markers just inserted.
|
||
raw = raw.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim();
|
||
const text = audiobookDehyphenate(raw); // mend PDF line-break hyphenation for clean speech + tag matching
|
||
// Locate each page anchor in the final text → page-break offsets.
|
||
let from = 0;
|
||
for (const a of anchors) {
|
||
const probe = audiobookDehyphenate(a.anchor).slice(0, 24);
|
||
if (!probe) continue;
|
||
const pos = text.indexOf(probe, from);
|
||
if (pos >= 0) { _audiobook.pageMarks.push({ offset: pos, page: a.page }); from = pos; }
|
||
}
|
||
return text;
|
||
}
|
||
|
||
// ── Progress overlay ─────────────────────────────────────────────────────────
|
||
|
||
function audiobookProgress(total) {
|
||
let ov = document.getElementById('audiobook-overlay');
|
||
if (!ov) {
|
||
ov = document.createElement('div');
|
||
ov.id = 'audiobook-overlay';
|
||
ov.className = 'audiobook-overlay';
|
||
ov.innerHTML = `<div class="audiobook-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
||
<div class="audiobook-msg" id="audiobook-msg">Analysing…</div>
|
||
<div class="reader-synth-track"><div class="reader-synth-fill" id="audiobook-fill"></div></div>
|
||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="audiobook-cancel">Cancel</button></div>
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#audiobook-cancel').addEventListener('click', () => { _audiobook.cancel = true; });
|
||
}
|
||
ov.hidden = false;
|
||
const fill = ov.querySelector('#audiobook-fill');
|
||
const msg = ov.querySelector('#audiobook-msg');
|
||
return {
|
||
update(done, label) { if (fill) fill.style.width = (done / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||
done() { ov.hidden = true; },
|
||
};
|
||
}
|
||
|
||
// Delegates to the character library's canonical colour implementation
|
||
// (clNormalizeColor / clHslToHex / clNameHue in characters-library.js) so
|
||
// avatar colours can't drift between views; only the 'Narrator' default for a
|
||
// missing name lives here.
|
||
function _abNormalizeColor(color, fallbackName) {
|
||
return clNormalizeColor(color, fallbackName || 'Narrator');
|
||
}
|
||
|
||
function _abDefaultCharacterColor(name) {
|
||
if (/^Unknown|Unbekannt/i.test(name || '')) return '#a83232';
|
||
return _abNormalizeColor(null, name);
|
||
}
|
||
|
||
function _abRecordColor(rec, name) {
|
||
return _abNormalizeColor(rec?.color || rec?.sheet?.color, name || rec?.name);
|
||
}
|
||
|
||
// Escape a segment's text and underline any known character names. Module-level
|
||
// so the review/preview overlay (audiobookShowPreview) can use it too — the cast
|
||
// view (audiobookCastView) defines its own roster-coloured version that shadows
|
||
// this inside its closure. Names default to the current run's roster.
|
||
function highlightText(text, names) {
|
||
if (!text) return '';
|
||
let html = escHtml(text);
|
||
const list = (names || (_audiobook && _audiobook.roster) || [])
|
||
.filter(n => n && n.toLowerCase() !== 'narrator' && !/^Unknown|Unbekannt/i.test(n))
|
||
.sort((a, b) => b.length - a.length);
|
||
list.forEach((name) => {
|
||
if (name.length < 2) return;
|
||
const safe = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||
const color = _abDefaultCharacterColor(name);
|
||
html = html.replace(new RegExp(`\\b(${safe})\\b`, 'gi'),
|
||
m => `<span style="border-bottom: 2px solid ${color}; font-weight: 600;">${m}</span>`);
|
||
});
|
||
return html;
|
||
}
|
||
|
||
// Live casting view: a scrolling feed of attributed lines + a character roster
|
||
// that fills up as speakers are discovered. Far clearer than a bare bar.
|
||
function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||
const panel = document.getElementById('reader-audiobook-panel');
|
||
if (!panel) return;
|
||
|
||
if (typeof window.navReaderView === 'function') window.navReaderView('cast');
|
||
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
|
||
else {
|
||
const mainView = document.getElementById('reader-main-view');
|
||
if (mainView) mainView.hidden = true;
|
||
panel.hidden = false;
|
||
}
|
||
|
||
panel.className = 'ab-castpanel-inline card';
|
||
panel.style.display = '';
|
||
panel.style.flexDirection = '';
|
||
panel.style.minHeight = '';
|
||
|
||
panel.innerHTML = `
|
||
<div class="ab-castpanel-head" id="ab-cv-head">
|
||
<span class="mdi mdi-drama-masks"></span>
|
||
<span class="ab-castpanel-title">Casting audiobook</span>
|
||
<span class="ab-castpanel-count" id="ab-cv-count">passage 0 / ${total}</span>
|
||
<button class="ab-castpanel-btn" id="ab-cv-settings-toggle" title="Show / hide casting settings" style="width:auto; padding:2px 6px; margin-left:6px;"><span class="mdi mdi-chevron-up" id="ab-cv-settings-chevron"></span></button>
|
||
<span style="flex:1"></span>
|
||
<span id="ab-cv-settings" style="display:flex; align-items:center; gap:4px;">
|
||
<label for="ab-cv-llm-url" style="font-size:12px; font-weight:600; color:var(--subtext); margin-right:4px;">LLM Engine</label>
|
||
<select id="ab-cv-llm-url" style="width:260px; margin-right:4px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none; appearance:auto; cursor:pointer;" aria-label="LLM API URL">
|
||
<option value="${escHtml(llmUrl)}">${escHtml(llmUrl || '— Select an endpoint —')}</option>
|
||
</select>
|
||
<button class="ab-castpanel-btn" id="ab-cv-llm-refresh" style="margin-right:4px;" title="Fetch models"><span class="mdi mdi-refresh"></span></button>
|
||
<select id="ab-cv-llm-select" style="width:220px; padding:2px 6px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" aria-label="LLM Model" title="Change LLM model for character extraction">
|
||
<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>
|
||
</select>
|
||
<span style="margin-right:8px;"></span>
|
||
<button class="ab-castpanel-btn" id="ab-cv-prompt-btn" title="Edit Casting Prompt" style="display:flex; align-items:center; gap:4px; padding:4px 8px; font-weight:600; font-size:12px; width:auto; height:auto; min-height:28px;"><span class="mdi mdi-text-box-edit-outline"></span> Prompt <span class="mdi mdi-chevron-down" id="ab-cv-prompt-chevron"></span></button>
|
||
</span>
|
||
</div>
|
||
<div class="ab-castpanel-prompt" id="ab-cv-prompt-panel" hidden style="padding:10px 12px; background:var(--panel); border-bottom:1px solid var(--border);">
|
||
<textarea id="ab-cv-prompt-text" rows="8" style="width:100%; font-family:monospace; font-size:13px; font-weight:500; padding:10px; line-height:1.5; border:1px solid var(--border); border-radius:6px; background:var(--surface); color:var(--text); resize:vertical;" placeholder="LLM casting instructions..."></textarea>
|
||
<div style="display:flex; justify-content:flex-end; margin-top:8px; gap:8px; align-items:center;">
|
||
<select id="ab-cv-prompt-lib" class="btn-secondary btn-sm" style="max-width:200px; outline:none; border:1px solid var(--border); border-radius:4px; padding:4px 8px; font-size:12px; background:var(--surface);" aria-label="Load a saved prompt">
|
||
<option value="">— Load saved prompt —</option>
|
||
</select>
|
||
<button class="btn-secondary btn-sm" id="ab-cv-prompt-del" title="Delete selected prompt" style="display:none; color:var(--error);"><span class="mdi mdi-delete-outline"></span></button>
|
||
<input type="text" id="ab-cv-prompt-name" style="width:140px; padding:4px 8px; font-size:12px; border:1px solid var(--border); border-radius:4px; background:var(--surface); color:var(--text); outline:none;" placeholder="Preset name...">
|
||
<button class="btn-primary btn-sm" id="ab-cv-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
|
||
</div>
|
||
</div>
|
||
<div class="ab-cv-body">
|
||
<div class="ab-cv-feed-wrap">
|
||
<div class="ab-cv-feed" id="ab-cv-feed">
|
||
<div class="ab-skel-feed">
|
||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:78%"></div><div class="ab-skel-line" style="width:55%"></div></div>
|
||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:68%"></div></div>
|
||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:91%"></div><div class="ab-skel-line" style="width:42%"></div></div>
|
||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:73%"></div><div class="ab-skel-line" style="width:30%"></div></div>
|
||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:60%"></div></div>
|
||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:82%"></div></div>
|
||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:88%"></div><div class="ab-skel-line" style="width:48%"></div></div>
|
||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div>
|
||
</div>
|
||
</div>
|
||
<button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button>
|
||
</div>
|
||
<div class="ab-cv-side" id="ab-cv-side">
|
||
<div class="ab-cv-side-head">
|
||
<span class="ab-cv-side-title">Characters found</span>
|
||
<button class="ab-cv-side-collapse" id="ab-cv-side-collapse" type="button" title="Collapse to avatars"><span class="mdi mdi-chevron-left"></span></button>
|
||
</div>
|
||
<div class="ab-cv-chars" id="ab-cv-chars">
|
||
<div class="ab-skel-chars">
|
||
${[38,62,45,28,54,35].map(w => `<div class="ab-skel-char-row"><div class="ab-skel-dot"></div><div class="ab-skel-line" style="width:${w}%"></div><div class="ab-skel-num"></div></div>`).join('')}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="ab-castpanel-foot" id="ab-cv-foot" style="border-top:1px solid var(--border); padding:10px 12px; display:flex; justify-content:flex-end; align-items:center;">
|
||
<span class="ab-cv-status" id="ab-cv-status-msg" style="color:var(--subtext); font-size:12px; flex:1; display:none;">Ready to cast.</span>
|
||
<div class="reader-synth-track ab-castpanel-bar" hidden>
|
||
<div class="reader-synth-fill" id="ab-cv-fill"></div>
|
||
<div id="ab-cv-fill-text"></div>
|
||
</div>
|
||
<button class="btn-secondary btn-sm" id="ab-cv-cancel" style="color:var(--red); margin-right:8px;"><span class="mdi mdi-close"></span> Cancel</button>
|
||
<button class="btn-secondary btn-sm" id="ab-cv-start-recast-unk" style="display:none; margin-right:8px; color:var(--error);" title="Re-run only Unknown segments"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button>
|
||
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Re-run entire document"><span class="mdi mdi-refresh"></span> Recast all</button>
|
||
<button class="btn-primary btn-sm" id="ab-cv-start-cast" style="display:none;"><span class="mdi mdi-play"></span> Cast now</button>
|
||
</div>`;
|
||
|
||
const AB_DEFAULT_PROMPT = `Du bist ein erfahrener Drehbuchautor und Hörbuch-Regisseur. Deine Aufgabe ist es, einen Auszug aus einem deutschen Roman zu analysieren und ihn perfekt in einzelne Segmente für Erzähler und Dialoge (wörtliche Rede) zu unterteilen.
|
||
|
||
WICHTIGE DEFINITION VON DIALOG:
|
||
Text ist NUR dann 'dialogue', wenn er explizit in Anführungszeichen steht (z.B. »...«, „...“, "...", «...», <<...>>) oder mit einem Gedankenstrich (—) beginnt. ALLES ANDERE, einschließlich Handlungsbeschreibungen (Inquit-Formeln), inneren Gedanken und Beschreibungen, MUSS als 'narration' (Erzähler) deklariert werden.
|
||
Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).
|
||
|
||
ABSATZ- UND KAPITELSTRUKTUR:
|
||
Eine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer 'narration'/'Narrator', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.
|
||
|
||
GRAMMATIK-REGELN FÜR NARRATION:
|
||
- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").
|
||
- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.
|
||
- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.
|
||
- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.
|
||
|
||
ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ein Lektor):
|
||
1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
|
||
2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht).
|
||
3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat.
|
||
4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
|
||
5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende.
|
||
6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
|
||
7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
|
||
8. Namenlose Sprecher: Auch Rollenbezeichnungen aus dem Text sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge').
|
||
9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
|
||
10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unmöglich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegenüber 'Unknown'.
|
||
|
||
FÜR JEDES SEGMENT GIBST DU FOLGENDES AUS:
|
||
- speaker: 'Narrator' für Narration/Erzählertext, oder den EXAKTEN Namen des Charakters für gesprochene Dialoge.
|
||
- type: 'narration' oder 'dialogue'
|
||
- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen).
|
||
- emotion: Bei Dialogen 1-2 deutsche Wörter, die den Tonfall beschreiben (z.B. wütend, flüsternd, ängstlich). Bei Narration leer lassen ('').
|
||
|
||
STRIKTE FORMAT- UND TEXTREGELN:
|
||
- Mische NIEMALS Narration und Dialog im selben Segment! Trenne sie strikt. Wenn ein Zitat durch eine Handlungsanweisung unterbrochen wird (»Nein«, sagte sie, »halt.«), erstelle 3 Segmente: dialogue ("Nein"), narration (", sagte sie, "), dialogue ("halt.").
|
||
- »Text?« und »Text!« sind vollständige Dialoge — das ?« bzw. !« schließt das Zitat ab, auch wenn es ungewohnt aussieht.
|
||
- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt ("flüsterte er", "sagte sie", "rief Uriens") oder normale Erzählerhandlung weitergeht.
|
||
- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. "Der Tod trägt rot. «"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.
|
||
- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'.
|
||
- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!`;
|
||
|
||
const normalizeCastingPrompt = (prompt) => {
|
||
let p = prompt || AB_DEFAULT_PROMPT;
|
||
p = p.replace(
|
||
"- Wenn ein Auszug mit einem offenen »-Zitat endet (kein schließendes «), behandle den Text ab » bis Textende als 'dialogue'.",
|
||
"- PDF-/OCR-SCHUTZ: Wenn ein » oder « offensichtlich fehlt, darf dieser eine Fehler NICHT den Rest der Passage als Dialog verschlucken. Schließe ein offenes »-Zitat am ersten plausiblen Satzende (? ! .), besonders wenn danach eine Inquit-Formel folgt (\"flüsterte er\", \"sagte sie\", \"rief Uriens\") oder normale Erzählerhandlung weitergeht.\n- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und entferne das einzelne « aus dem ausgegebenen Text.\n- Nur wenn ein offenes » wirklich am Ende des Auszugs steht und danach KEINE Erzählerhandlung/Inquit-Formel mehr folgt, behandle den Text ab » bis Textende als 'dialogue'."
|
||
);
|
||
p = p.replace(
|
||
"- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen.",
|
||
"- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' ENTFERNST du die umschließenden Anführungszeichen vollständig (nie nur ein einzelnes » oder « stehen lassen)."
|
||
);
|
||
p = p.replace(
|
||
"- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren!",
|
||
"- Lasse NIEMALS Wörter aus, fasse nicht zusammen, dupliziere nichts und erfinde keinen Text. Der kombinierte Text all deiner Segmente MUSS den Originaltext exakt und lückenlos Wort für Wort rekonstruieren — abgesehen von entfernten äußeren Dialog-Anführungszeichen!"
|
||
);
|
||
// Upgrade older saved prompts to the deduction-rule set (colon rule,
|
||
// post-quote inquits, pronoun resolution, strict ping-pong, role names)
|
||
// that cut down false "Unknown"/"Narrator" attributions.
|
||
if (!/Doppelpunkt-Regel/.test(p)) {
|
||
p = p.replace(
|
||
`ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv):
|
||
1. Direkte Zuordnung: Achte auf Wörter wie "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) zum tatsächlichen Namen auf.
|
||
2. Handlungs-Hinweise (Action Beats): Wenn ein Charakter eine Handlung ausführt und direkt davor/danach wörtliche Rede steht, spricht meist dieser Charakter (z.B. "Thomas trat ans Fenster. »Es regnet.«").
|
||
3. Das Ping-Pong-Prinzip: Wenn zwei Personen sprechen, wechseln sie sich ab. Verfolge diese Kette lückenlos zurück zur letzten eindeutigen Nennung.
|
||
4. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt die Aussage zum Wissen oder Tonfall eines bestimmten Charakters?
|
||
5. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
|
||
6. Unbekannte Sprecher: Nur wenn eine Zuordnung durch Kontext absolut nicht möglich ist, verwende 'Unknown'. Rate nicht blind, aber bevorzuge immer einen namentlich genannten Charakter gegenüber 'Unknown'.`,
|
||
`ANALYSE-REGELN FÜR DIE ZUORDNUNG DES SPRECHERS (Sei deduktiv — arbeite wie ein Lektor):
|
||
1. Direkte Zuordnung: "sagte [Name]", "fragte er", "rief sie". Löse Pronomen (er/sie) IMMER zum tatsächlichen Namen auf: Das Pronomen meint die zuletzt genannte Person passenden Geschlechts.
|
||
2. Doppelpunkt-Regel: Endet ein Erzählersatz mit ":", spricht das Subjekt dieses Satzes das folgende Zitat (z.B. "Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter spricht das nächste Zitat; "Ein anderer fragte verschlafen:" → dieser andere spricht).
|
||
3. Nachgestellte Zuordnung: Der Erzählersatz NACH einem Zitat verrät oft den Sprecher — auch bei unpersönlicher Formel: "»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach das Zitat.
|
||
4. Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einem Zitat handelt, spricht meist (z.B. "Thomas trat ans Fenster. »Es regnet.«"; "Verächtlich sah sie ihn an. »Das wirst du schon noch merken.«" → sie spricht, und "sie" ist die zuletzt genannte Frau).
|
||
5. Adressaten-Regel: "X wandte sich an Y" / "X sah Y an" → X spricht das nächste Zitat, und Y ist der wahrscheinlichste Antwortende.
|
||
6. Das Ping-Pong-Prinzip: Sprechen zwei Personen miteinander, wechseln sie sich strikt ab — auch über viele Zitate OHNE Inquit-Formeln hinweg. Verfolge die Kette lückenlos zurück zur letzten eindeutigen Nennung und führe sie bis zum Szenenende fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
|
||
7. Gruppen-Dialoge (3+ Personen): An wen richtet sich die Aussage? Passt sie zum Wissen oder Tonfall eines bestimmten Charakters?
|
||
8. Namenlose Sprecher: Auch Rollenbezeichnungen aus dem Text sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge').
|
||
9. Wiederverwendung: Nutze EXAKT die Namen aus der Liste der bekannten Charaktere, FALLS der Name dort aufgeführt ist. Wenn ein neuer Charakter spricht, extrahiere seinen Namen direkt aus dem Text (z.B. 'Karyla', 'Uriens').
|
||
10. 'Unknown' NUR, wenn eine Zuordnung trotz aller obigen Regeln absolut unmöglich ist. Bevorzuge immer einen genannten Charakter oder eine Rollenbezeichnung gegenüber 'Unknown'.`
|
||
);
|
||
}
|
||
if (!/GRAMMATIK-REGELN FÜR NARRATION/.test(p)) {
|
||
p = p.replace(
|
||
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).',
|
||
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nGRAMMATIK-REGELN FÜR NARRATION:\n- Inquit-Formeln / Sprecher-Tags sind IMMER narration: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name (z.B. "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.").\n- Action Beats sind IMMER narration: Ein Charakter handelt, blickt, geht, lacht, schweigt, hebt die Hand, dreht sich um, usw. — auch wenn direkt davor/danach Dialog steht.\n- Grammatische Probe: Wenn der Text eine Erzähler-Aussage ÜBER das Sprechen ist (Verb + Sprecher + Art und Weise), ist es narration. Nur die tatsächlich geäußerten Wörter innerhalb der Anführungszeichen sind dialogue.\n- Satzfragmente mit führendem Komma/Punkt wie ", sagte sie leise." oder ". fragte Uriens." sind niemals eigenständige Dialoge; sie gehören als narration zum Erzählertext.'
|
||
);
|
||
}
|
||
// Paragraph/heading structure is now preserved as blank lines upstream
|
||
// (readerMarkParagraphBreaks); tell older saved prompts how to read it.
|
||
if (!/ABSATZ- UND KAPITELSTRUKTUR/.test(p)) {
|
||
p = p.replace(
|
||
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).',
|
||
'Markiere Text NIEMALS als Dialog, nur weil der Name eines Charakters erwähnt wird! (z.B. "Karyla rannte zur Tür." ist Narration, KEIN Dialog).\n\nABSATZ- UND KAPITELSTRUKTUR:\nEine Leerzeile im Text markiert einen echten Absatzwechsel (oder einen Kapitel-/Szenenanfang). Eine sehr kurze, alleinstehende Zeile direkt vor einer Leerzeile (z.B. "1. Kapitel", "Prolog", ein Zahlwort) ist eine Kapitelüberschrift — immer \'narration\'/\'Narrator\', niemals Dialog. Behalte Leerzeilen als eigenständige narration-Segmente oder als Teil des umgebenden Erzähler-Segments bei; erfinde daraus keinen Dialog und lösche sie nicht aus dem rekonstruierten Text.'
|
||
);
|
||
}
|
||
return p;
|
||
};
|
||
|
||
const rawPrompt = (typeof _appSettings !== 'undefined' && _appSettings.audiobook_prompt) ? _appSettings.audiobook_prompt : AB_DEFAULT_PROMPT;
|
||
const globalPrompt = normalizeCastingPrompt(rawPrompt);
|
||
if (globalPrompt !== rawPrompt && typeof _appSettings !== 'undefined') {
|
||
_appSettings.audiobook_prompt = globalPrompt;
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: globalPrompt }) }).catch(() => {});
|
||
}
|
||
panel.querySelector('#ab-cv-prompt-text').value = globalPrompt;
|
||
|
||
const closePanel = () => {
|
||
panel.hidden = true;
|
||
panel.innerHTML = '';
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||
if (typeof window.navReaderView === 'function') window.navReaderView('main');
|
||
else if (typeof window.showReaderView === 'function') window.showReaderView('main');
|
||
else {
|
||
const mv = document.getElementById('reader-main-view');
|
||
if (mv) mv.hidden = false;
|
||
}
|
||
};
|
||
|
||
const setStoppingState = () => {
|
||
const cancelBtn = panel.querySelector('#ab-cv-cancel');
|
||
if (cancelBtn) {
|
||
cancelBtn.disabled = true;
|
||
cancelBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Stopping...';
|
||
cancelBtn.title = 'Stopping the current casting request';
|
||
}
|
||
const status = panel.querySelector('#ab-cv-status-msg');
|
||
if (status) {
|
||
status.style.display = 'inline-block';
|
||
status.textContent = 'Stopping... completed passages will stay saved.';
|
||
}
|
||
};
|
||
|
||
panel.querySelector('#ab-cv-cancel').addEventListener('click', () => {
|
||
if (_audiobook.running) {
|
||
_audiobook.cancel = true;
|
||
setStoppingState();
|
||
if (typeof _audiobook.abort === 'function') _audiobook.abort();
|
||
return;
|
||
}
|
||
closePanel();
|
||
});
|
||
|
||
panel.querySelector('#ab-cv-prompt-btn').addEventListener('click', () => {
|
||
const p = panel.querySelector('#ab-cv-prompt-panel');
|
||
p.hidden = !p.hidden;
|
||
const chevron = panel.querySelector('#ab-cv-prompt-chevron');
|
||
if (chevron) {
|
||
chevron.className = p.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
|
||
}
|
||
});
|
||
|
||
// Collapse the whole settings block (LLM engine + prompt) to free vertical space.
|
||
(function () {
|
||
const tBtn = panel.querySelector('#ab-cv-settings-toggle');
|
||
const sBox = panel.querySelector('#ab-cv-settings');
|
||
const chev = panel.querySelector('#ab-cv-settings-chevron');
|
||
function apply(collapsed) {
|
||
if (sBox) sBox.style.display = collapsed ? 'none' : 'flex';
|
||
if (collapsed) { const p = panel.querySelector('#ab-cv-prompt-panel'); if (p) p.hidden = true; }
|
||
if (chev) chev.className = 'mdi ' + (collapsed ? 'mdi-chevron-down' : 'mdi-chevron-up');
|
||
}
|
||
let collapsed = false;
|
||
try { collapsed = localStorage.getItem('ttsvc_ab_settings_collapsed') === '1'; } catch (_) {}
|
||
apply(collapsed);
|
||
if (tBtn) tBtn.addEventListener('click', () => {
|
||
collapsed = !collapsed;
|
||
try { localStorage.setItem('ttsvc_ab_settings_collapsed', collapsed ? '1' : '0'); } catch (_) {}
|
||
apply(collapsed);
|
||
});
|
||
})();
|
||
|
||
// Collapse the character sidebar to just avatar dots when space is tight,
|
||
// or you just want more room for the script itself.
|
||
(function () {
|
||
const side = panel.querySelector('#ab-cv-side');
|
||
const body = panel.querySelector('.ab-cv-body');
|
||
const cBtn = panel.querySelector('#ab-cv-side-collapse');
|
||
function apply(collapsed) {
|
||
if (side) side.classList.toggle('is-collapsed', collapsed);
|
||
if (body) body.classList.toggle('side-collapsed', collapsed);
|
||
if (cBtn) cBtn.querySelector('.mdi').className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left');
|
||
if (cBtn) cBtn.title = collapsed ? 'Expand character list' : 'Collapse to avatars';
|
||
}
|
||
let collapsed = false;
|
||
try { collapsed = localStorage.getItem('ttsvc_ab_side_collapsed') === '1'; } catch (_) {}
|
||
apply(collapsed);
|
||
if (cBtn) cBtn.addEventListener('click', () => {
|
||
collapsed = !collapsed;
|
||
try { localStorage.setItem('ttsvc_ab_side_collapsed', collapsed ? '1' : '0'); } catch (_) {}
|
||
apply(collapsed);
|
||
});
|
||
})();
|
||
|
||
// Prompt Library logic
|
||
let savedPrompts = [];
|
||
try { savedPrompts = JSON.parse(localStorage.getItem('ttsvc_ab_prompts') || '[]'); } catch (_) { savedPrompts = []; }
|
||
const libSelect = panel.querySelector('#ab-cv-prompt-lib');
|
||
const delBtn = panel.querySelector('#ab-cv-prompt-del');
|
||
const promptText = panel.querySelector('#ab-cv-prompt-text');
|
||
|
||
const renderPromptLib = (selectedIdx = -1) => {
|
||
libSelect.innerHTML = '<option value="">— Load saved prompt —</option>' +
|
||
savedPrompts.map((p, i) => `<option value="${i}">${escHtml(p.name)}</option>`).join('');
|
||
if (selectedIdx >= 0) {
|
||
libSelect.value = selectedIdx;
|
||
delBtn.style.display = 'block';
|
||
} else {
|
||
libSelect.value = '';
|
||
delBtn.style.display = 'none';
|
||
}
|
||
};
|
||
renderPromptLib();
|
||
|
||
libSelect.addEventListener('change', () => {
|
||
const idx = parseInt(libSelect.value);
|
||
const nameInput = panel.querySelector('#ab-cv-prompt-name');
|
||
if (!isNaN(idx) && savedPrompts[idx]) {
|
||
promptText.value = savedPrompts[idx].prompt;
|
||
if (nameInput) nameInput.value = savedPrompts[idx].name;
|
||
delBtn.style.display = 'block';
|
||
} else {
|
||
if (nameInput) nameInput.value = '';
|
||
delBtn.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
delBtn.addEventListener('click', () => {
|
||
const idx = parseInt(libSelect.value);
|
||
if (isNaN(idx)) return;
|
||
if (confirm('Delete this saved prompt preset?')) {
|
||
savedPrompts.splice(idx, 1);
|
||
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
|
||
renderPromptLib();
|
||
toast('Prompt deleted', 'success');
|
||
}
|
||
});
|
||
|
||
panel.querySelector('#ab-cv-prompt-save').addEventListener('click', async () => {
|
||
const val = promptText.value.trim();
|
||
if (!val) { toast('Prompt is empty', 'error'); return; }
|
||
const nameInput = panel.querySelector('#ab-cv-prompt-name');
|
||
const name = nameInput.value.trim() || 'Custom Prompt ' + (savedPrompts.length + 1);
|
||
|
||
let targetIdx = parseInt(libSelect.value);
|
||
if (!isNaN(targetIdx) && savedPrompts[targetIdx] && savedPrompts[targetIdx].name === name) {
|
||
savedPrompts[targetIdx].prompt = val;
|
||
} else {
|
||
savedPrompts.push({ name, prompt: val });
|
||
targetIdx = savedPrompts.length - 1;
|
||
}
|
||
|
||
localStorage.setItem('ttsvc_ab_prompts', JSON.stringify(savedPrompts));
|
||
renderPromptLib(targetIdx);
|
||
toast('Prompt preset saved', 'success');
|
||
|
||
// Also save as global default for next time
|
||
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = val;
|
||
try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: val }) }); } catch (e) {}
|
||
});
|
||
|
||
// Sync the LLM engine dropdown with global options
|
||
const syncEngineSelect = () => {
|
||
const localSel = panel.querySelector('#ab-cv-llm-url');
|
||
const globalSel = document.getElementById('llm-active-url');
|
||
if (globalSel && localSel) {
|
||
const currentVal = localSel.value;
|
||
localSel.innerHTML = globalSel.innerHTML;
|
||
localSel.value = currentVal || globalSel.value || '';
|
||
}
|
||
};
|
||
syncEngineSelect();
|
||
|
||
// Fetch available models and populate the select drop-down
|
||
const loadModels = () => {
|
||
const urlInput = panel.querySelector('#ab-cv-llm-url');
|
||
const sel = panel.querySelector('#ab-cv-llm-select');
|
||
if (!sel || !urlInput) return;
|
||
const currentUrl = urlInput.value.trim();
|
||
const oldVal = sel.value;
|
||
sel.innerHTML = '<option value="">loading...</option>';
|
||
fetch('/api/conversation/llm-models' + (currentUrl ? '?url=' + encodeURIComponent(currentUrl) : ''))
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.models && d.models.length) {
|
||
const preferred = audiobookSafeLlmModel(oldVal || defaultModel);
|
||
sel.innerHTML = d.models.map(m => `<option value="${escHtml(m)}">${escHtml(m)}</option>`).join('');
|
||
if (preferred && d.models.includes(preferred)) sel.value = preferred;
|
||
else if (oldVal && d.models.includes(oldVal) && !audiobookIsRouterModel(oldVal)) sel.value = oldVal;
|
||
else if (d.models.includes(defaultModel)) sel.value = defaultModel;
|
||
} else {
|
||
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
|
||
}
|
||
}).catch(() => {
|
||
sel.innerHTML = `<option value="${escHtml(defaultModel)}">${escHtml(defaultModel || '— fetch models —')}</option>`;
|
||
});
|
||
};
|
||
loadModels();
|
||
panel.querySelector('#ab-cv-llm-refresh').addEventListener('click', loadModels);
|
||
const urlInput = panel.querySelector('#ab-cv-llm-url');
|
||
if (urlInput) urlInput.addEventListener('change', loadModels);
|
||
|
||
const applyPromptAndRun = (callback) => {
|
||
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
|
||
const choice = audiobookCurrentCastLlm(panel);
|
||
const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
|
||
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt;
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) })
|
||
.finally(() => {
|
||
if (callback) callback(savedChoice.url, savedChoice.model);
|
||
});
|
||
};
|
||
|
||
if (isIdle) {
|
||
const hasSegs = _audiobook.segments && _audiobook.segments.length > 0;
|
||
panel.querySelector('#ab-cv-status-msg').style.display = 'inline-block';
|
||
|
||
if (hasSegs) {
|
||
const recastUnkBtn = panel.querySelector('#ab-cv-start-recast-unk');
|
||
const recastBtn = panel.querySelector('#ab-cv-start-recast');
|
||
recastUnkBtn.style.display = 'inline-block';
|
||
recastBtn.style.display = 'inline-block';
|
||
recastUnkBtn.addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
|
||
recastBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
|
||
panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to recast.';
|
||
} else {
|
||
const castBtn = panel.querySelector('#ab-cv-start-cast');
|
||
castBtn.style.display = 'inline-block';
|
||
castBtn.addEventListener('click', () => applyPromptAndRun(audiobookCast));
|
||
}
|
||
} else {
|
||
panel.querySelector('#ab-cv-cancel').innerHTML = '<span class="mdi mdi-stop"></span> Stop Casting';
|
||
}
|
||
|
||
const fill = panel.querySelector('#ab-cv-fill'), count = panel.querySelector('#ab-cv-count');
|
||
const feed = panel.querySelector('#ab-cv-feed'), chars = panel.querySelector('#ab-cv-chars');
|
||
const roster = new Map(); // name -> { count, color }
|
||
const characterRecords = new Map(); // lower-case name -> character-library record
|
||
// Like clIdentityNames (characters-library.js) but preserves original case —
|
||
// highlightText dedups and colour-keys by the display-cased name. The token
|
||
// splitting itself is the library's canonical clSplitIdentityTokens.
|
||
const identityNames = (recOrSheet) => {
|
||
const s = recOrSheet?.sheet || recOrSheet || {};
|
||
const out = new Set();
|
||
const add = (v, opts = {}) => clSplitIdentityTokens(v, opts).forEach(x => out.add(x));
|
||
add(recOrSheet?.name || s.name);
|
||
['aliases', 'first_name', 'last_name', 'full_name', 'title'].forEach(k => add(s[k], { aliases: k === 'aliases' }));
|
||
return [...out];
|
||
};
|
||
let _hlVer = 0; // bumped whenever character records change
|
||
let _hlCache = { ver: -1, rosterSize: -1, regex: null, byLower: new Map() };
|
||
const registerCharacterRecord = (rec) => {
|
||
if (!rec?.name) return;
|
||
_hlVer++;
|
||
for (const n of identityNames(rec)) characterRecords.set(n.toLowerCase(), rec);
|
||
};
|
||
const recordForName = (name) => characterRecords.get(String(name || '').toLowerCase()) || null;
|
||
const colorFor = (name, rec) => {
|
||
const key = String(name || '').toLowerCase();
|
||
const stored = rec || characterRecords.get(key);
|
||
const color = stored ? _abRecordColor(stored, name) : _abDefaultCharacterColor(name);
|
||
if (!roster.has(name)) roster.set(name, { count: 0, color });
|
||
else roster.get(name).color = color;
|
||
return roster.get(name).color;
|
||
};
|
||
const setCharacterColor = (name, color) => {
|
||
const safe = _abNormalizeColor(color, name);
|
||
const info = roster.get(name);
|
||
if (info) info.color = safe;
|
||
const key = String(name || '').toLowerCase();
|
||
const rec = characterRecords.get(key);
|
||
if (rec) { rec.color = safe; if (rec.sheet) rec.sheet.color = safe; }
|
||
return safe;
|
||
};
|
||
const highlightText = (text) => {
|
||
if (!text) return '';
|
||
let html = escHtml(text);
|
||
// The name list + compiled regexes are invariant between segments until the
|
||
// roster or character records change — rebuilding them per segment made
|
||
// rendering O(segments × records) over a whole book. Colours stay live via
|
||
// colorFor at replace time.
|
||
if (_hlCache.ver !== _hlVer || _hlCache.rosterSize !== roster.size) {
|
||
const extraNames = [];
|
||
new Set([...characterRecords.values()]).forEach(rec => extraNames.push(...identityNames(rec)));
|
||
const seen = new Set();
|
||
const names = [];
|
||
for (const n of [...roster.keys(), ...extraNames]) {
|
||
const k = n.toLowerCase();
|
||
if (n.length < 2 || k === 'narrator' || /^unknown|unbekannt/i.test(k) || seen.has(k)) continue;
|
||
seen.add(k);
|
||
names.push(n);
|
||
}
|
||
names.sort((a, b) => b.length - a.length);
|
||
// ONE combined alternation (longest-first) applied in a single pass —
|
||
// replacing name-by-name re-scanned HTML that already contained the
|
||
// injected spans, so a shorter alias ("Larissa") could match inside a
|
||
// longer name's data-name attribute ("Schwester Larissa") and corrupt
|
||
// the markup, leaking raw style="..." text into the visible feed.
|
||
const pattern = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
||
_hlCache = {
|
||
ver: _hlVer,
|
||
rosterSize: roster.size,
|
||
regex: pattern ? new RegExp(`\\b(${pattern})\\b`, 'gi') : null,
|
||
byLower: new Map(names.map(n => [n.toLowerCase(), n])),
|
||
};
|
||
}
|
||
if (_hlCache.regex) {
|
||
html = html.replace(_hlCache.regex, (match) => {
|
||
const canon = _hlCache.byLower.get(match.toLowerCase()) || match;
|
||
return `<span class="ab-name-hit" data-name="${escHtml(canon)}" style="border-bottom: 2px solid ${colorFor(canon)}; font-weight: 600;">${match}</span>`;
|
||
});
|
||
}
|
||
return html;
|
||
};
|
||
// ── Character selection bar ────────────────────────────────────────────────
|
||
// Clicking a character shows a compact bar above the feed with avatar, name,
|
||
// prev/next line navigation, a search box, and a "Profil" button that opens
|
||
// the full character sheet (hiding the feed temporarily).
|
||
|
||
const feedWrap = panel.querySelector('.ab-cv-feed-wrap');
|
||
const jumpBtn = panel.querySelector('#ab-cv-jump-btn');
|
||
|
||
// Compact toolbar above the feed: selected character, edit history, page nav.
|
||
const _abTopbar = document.createElement('div');
|
||
_abTopbar.className = 'ab-cv-topbar';
|
||
feedWrap.insertBefore(_abTopbar, feedWrap.firstChild);
|
||
|
||
const _abBar = document.createElement('div');
|
||
_abBar.className = 'ab-char-bar';
|
||
_abBar.hidden = true;
|
||
_abTopbar.appendChild(_abBar);
|
||
|
||
const _abEditToolbar = document.createElement('div');
|
||
_abEditToolbar.className = 'ab-edit-toolbar';
|
||
_abEditToolbar.innerHTML = `
|
||
<button class="ab-edit-btn ab-edit-undo" type="button" title="Undo last cast edit" disabled><span class="mdi mdi-undo"></span></button>
|
||
<button class="ab-edit-btn ab-edit-redo" type="button" title="Redo cast edit" disabled><span class="mdi mdi-redo"></span></button>`;
|
||
_abTopbar.appendChild(_abEditToolbar);
|
||
|
||
const _abPageNav = document.createElement('div');
|
||
_abPageNav.className = 'ab-page-nav';
|
||
_abPageNav.hidden = true;
|
||
_abPageNav.innerHTML = `
|
||
<button class="ab-page-nav-btn ab-page-prev" type="button" title="Previous cast page"><span class="mdi mdi-chevron-left"></span></button>
|
||
<select class="ab-page-select" aria-label="Jump to cast page"></select>
|
||
<button class="ab-page-nav-btn ab-page-next" type="button" title="Next cast page"><span class="mdi mdi-chevron-right"></span></button>
|
||
<button class="ab-page-nav-btn ab-page-source" type="button" title="Back to this source page in Reader"><span class="mdi mdi-book-open-page-variant"></span></button>`;
|
||
_abTopbar.appendChild(_abPageNav);
|
||
|
||
// Font-size controls for the feed's paper pages. Shares the Rehearser
|
||
// Stage's persisted scale (ttsvc_reh_stage_scale) because .ab-cv-page's
|
||
// font-size is already calc(12pt * var(--reh-stage-scale)) from the shared
|
||
// paper spec — one reading size across both screens.
|
||
const _abFontCtl = document.createElement('div');
|
||
_abFontCtl.className = 'ab-font-ctl';
|
||
_abFontCtl.innerHTML = `
|
||
<button class="ab-page-nav-btn ab-font-dec" type="button" title="Decrease text size">A−</button>
|
||
<button class="ab-page-nav-btn ab-font-inc" type="button" title="Increase text size">A+</button>`;
|
||
_abTopbar.appendChild(_abFontCtl);
|
||
const _abApplyFont = () => {
|
||
let v = 1;
|
||
try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {}
|
||
feed.style.setProperty('--reh-stage-scale', String(v));
|
||
};
|
||
const _abFontStep = (d) => {
|
||
let v = 1;
|
||
try { v = parseFloat(localStorage.getItem('ttsvc_reh_stage_scale')) || 1; } catch (_) {}
|
||
v = Math.round(Math.min(2, Math.max(0.7, v + d)) * 100) / 100;
|
||
try { localStorage.setItem('ttsvc_reh_stage_scale', String(v)); } catch (_) {}
|
||
_abApplyFont();
|
||
if (typeof rehApplyStageFont === 'function') rehApplyStageFont(); // keep the Rehearser Stage in sync too
|
||
};
|
||
_abFontCtl.querySelector('.ab-font-dec').addEventListener('click', () => _abFontStep(-0.1));
|
||
_abFontCtl.querySelector('.ab-font-inc').addEventListener('click', () => _abFontStep(0.1));
|
||
_abApplyFont();
|
||
|
||
let _abCharRows = []; // rows in feed for selected character
|
||
let _abNavIdx = 0;
|
||
let _abDetailEl = null; // full profile panel (when Profil is open)
|
||
let _abCurrentPageNum = 1;
|
||
|
||
const _abClearHL = () => {
|
||
feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus')
|
||
.forEach(r => r.classList.remove('ab-char-hl', 'ab-char-focus'));
|
||
};
|
||
|
||
const _abCloseProfile = () => {
|
||
if (_abDetailEl) { _abDetailEl.remove(); _abDetailEl = null; }
|
||
feed.style.display = '';
|
||
if (jumpBtn && typeof _userScrolled !== 'undefined') jumpBtn.hidden = !_userScrolled;
|
||
_abCharRows.forEach(r => r.classList.add('ab-char-hl'));
|
||
const btn = _abBar.querySelector('.ab-char-bar-profile');
|
||
if (btn) btn.innerHTML = '<span class="mdi mdi-account-details-outline"></span> Profil';
|
||
};
|
||
|
||
const _abCloseBar = () => {
|
||
_abBar.hidden = true;
|
||
_abBar.innerHTML = '';
|
||
_abCloseProfile();
|
||
_abClearHL();
|
||
_abCharRows = [];
|
||
chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active'));
|
||
if (typeof _abSyncTopbar === 'function') _abSyncTopbar();
|
||
};
|
||
|
||
const _abNav = (dir, pool) => {
|
||
const rows = pool || _abCharRows;
|
||
if (!rows.length) return;
|
||
_abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length;
|
||
feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus'));
|
||
rows[_abNavIdx].classList.add('ab-char-focus');
|
||
rows[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
const pos = _abBar.querySelector('.ab-char-bar-pos');
|
||
if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length;
|
||
};
|
||
|
||
const _abRefreshCharacterColors = (name) => {
|
||
const names = name ? [name] : [...roster.keys()];
|
||
for (const n of names) colorFor(n);
|
||
feed.querySelectorAll('.ab-cv-row').forEach(row => {
|
||
const seg = row.__seg;
|
||
if (!seg) return;
|
||
const isNarrator = seg.type !== 'dialogue' || !seg.speaker || seg.speaker.toLowerCase() === 'narrator';
|
||
const speakerName = isNarrator ? 'Narrator' : seg.speaker;
|
||
const c = colorFor(speakerName);
|
||
const spk = row.querySelector('.ab-cv-spk');
|
||
const txt = row.querySelector('.ab-cv-txt');
|
||
if (spk && (!name || speakerName.toLowerCase() === name.toLowerCase())) spk.style.color = c;
|
||
if (txt) txt.innerHTML = highlightText(seg.text || '');
|
||
});
|
||
renderRoster();
|
||
const selected = _abBar.hidden ? null : _abBar.dataset.charName;
|
||
if (selected) {
|
||
const dot = _abBar.querySelector('.ab-char-bar-dot');
|
||
if (dot) dot.style.background = colorFor(selected);
|
||
}
|
||
};
|
||
|
||
// Open the full character profile (hides feed)
|
||
const _abShowProfile = async (name) => {
|
||
_abCloseProfile();
|
||
feed.style.display = 'none';
|
||
if (jumpBtn) jumpBtn.hidden = true;
|
||
const profileBtn = _abBar.querySelector('.ab-char-bar-profile');
|
||
if (profileBtn) profileBtn.innerHTML = '<span class="mdi mdi-arrow-left"></span> Skript';
|
||
|
||
const title = window.readerState?.title || '';
|
||
let rec = null;
|
||
try {
|
||
const all = typeof clGetAllByTagOrBook === 'function'
|
||
? await clGetAllByTagOrBook(title)
|
||
: (typeof clGetAll === 'function' ? await clGetAll() : []);
|
||
for (const r of all || []) registerCharacterRecord(r);
|
||
rec = recordForName(name);
|
||
} catch (_) {}
|
||
|
||
const detail = document.createElement('div');
|
||
detail.className = 'ab-char-detail-panel';
|
||
_abDetailEl = detail;
|
||
|
||
if (!rec) {
|
||
detail.innerHTML = '<button class="ab-char-detail-back"><span class="mdi mdi-arrow-left"></span> Zurück zum Skript</button>'
|
||
+ '<div style="padding:24px;opacity:.6;font-size:14px">Kein Charakterblatt – zuerst „Cast Characters" ausführen.</div>';
|
||
feedWrap.appendChild(detail);
|
||
detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile);
|
||
return;
|
||
}
|
||
registerCharacterRecord(rec);
|
||
|
||
const sh = rec.sheet || {};
|
||
const charColor = setCharacterColor(rec.name, rec.color || sh.color || colorFor(rec.name, rec));
|
||
const accent2 = clHslToHex((clNameHue(rec.name) + 40) % 360, 58, 30);
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur';
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const pct = rec.sheet?.moral_alignment_score != null ? Math.max(0, Math.min(100, rec.sheet.moral_alignment_score)) : null;
|
||
const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' };
|
||
const avatarHtml = rec.image
|
||
? `<div class="lcd-avatar"><img src="${rec.image}" alt="${escHtml(rec.name)}"></div>`
|
||
: `<div class="lcd-avatar" style="background:${charColor}">${escHtml((rec.name||'?')[0].toUpperCase())}</div>`;
|
||
// field(label, value, sheet-key) — key enables editing when pencil is active
|
||
const field = (lbl, val, sk) => {
|
||
const v = _abStr(val);
|
||
if (!v && !sk) return '';
|
||
return `<div class="lcd-field"><div class="lcd-field-label">${lbl}</div><div class="lcd-field-value${sk ? ' ab-editable' : ''}"${sk ? ` data-sk="${sk}"` : ''}>${escHtml(v)}</div></div>`;
|
||
};
|
||
|
||
// Sources section
|
||
const srcItems = Array.isArray(rec.sources) ? rec.sources : [];
|
||
const sourcesHtml = srcItems.length
|
||
? `<div class="lcd-section-full">
|
||
<div class="lcd-section-label"><span class="mdi mdi-map-marker-outline"></span> Quellen im Text</div>
|
||
<div class="lcd-sources">${srcItems.map(s => `<span class="lcd-source-clickable ab-cd-src" data-page="${s.page||0}">Seite ${s.page||'?'}</span>`).join('')}</div>
|
||
</div>` : '';
|
||
|
||
detail.innerHTML =
|
||
`<button class="ab-char-detail-back"><span class="mdi mdi-arrow-left"></span> Zurück zum Skript</button>
|
||
<div class="lcd-header" style="--lc1:${charColor};--lc2:${accent2}; position:relative;">
|
||
${avatarHtml}
|
||
<div class="lcd-header-body">
|
||
<div class="lcd-name">${escHtml(rec.name)}</div>
|
||
${_abStr(sh.full_name) && _abStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? `<div class="lcd-aliases">${escHtml(_abStr(sh.full_name))}</div>` : ''}
|
||
${_abStr(sh.title) ? `<div class="lcd-aliases">${escHtml(_abStr(sh.title))}</div>` : ''}
|
||
${_abStr(sh.aliases) ? `<div class="lcd-aliases">auch bekannt als ${escHtml(_abStr(sh.aliases))}</div>` : ''}
|
||
${_abStr(sh.archetype) ? `<div class="lcd-archetype">${escHtml(_abStr(sh.archetype))}</div>` : ''}
|
||
<div class="lcd-tier-gender"><span class="lcd-tier">${tierLabel}</span></div>
|
||
</div>
|
||
<button class="ab-cd-edit-btn" title="Charakterblatt bearbeiten"><span class="mdi mdi-pencil-outline"></span></button>
|
||
</div>
|
||
<div class="lcd-body ab-cd-body">
|
||
<div class="lcd-voice-top">
|
||
<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>
|
||
<span class="lcd-voice-label">${voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme</span>'}</span>
|
||
<button class="ab-cd-pick">Auswählen</button><button class="ab-cd-auto">Automatisch</button><button class="ab-cd-online">Online suchen</button>
|
||
<label class="ab-cd-color" title="Charakterfarbe ändern"><span class="mdi mdi-palette-outline"></span><input type="color" value="${charColor}" aria-label="Charakterfarbe"></label>
|
||
</div>
|
||
${pct != null ? `<div class="lcd-align-section"><div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div><div class="lcd-align-bar-wrap"><span class="lcd-align-label">Böse</span><div class="lcd-align-bar"><div class="lcd-align-dot" style="left:${pct}%"></div></div><span class="lcd-align-label">Gut</span></div><div class="lcd-align-arc">${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)</div></div>` : ''}
|
||
<div class="lcd-sections">
|
||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-card-account-details-outline"></span> Identität</div>${field('Voller Name',sh.full_name,'full_name')}${field('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Titel / Rolle',sh.title,'title')}${field('Auch bekannt als',sh.aliases,'aliases')}</div>
|
||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-account-outline"></span> Erscheinung</div>${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}</div>
|
||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-drama-masks"></span> Persönlichkeit</div>${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}</div>
|
||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-book-open-outline"></span> Geschichte</div>${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}</div>
|
||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-sword"></span> Fähigkeiten</div>${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}</div>
|
||
<div class="lcd-section-full"><div class="lcd-section-label"><span class="mdi mdi-account-group-outline"></span> Beziehungen</div>${field('',sh.relationships,'relationships')}</div>
|
||
${sourcesHtml}
|
||
</div>
|
||
</div>`;
|
||
|
||
feedWrap.appendChild(detail);
|
||
detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile);
|
||
detail.querySelector('.ab-cd-color input')?.addEventListener('input', async e => {
|
||
const color = setCharacterColor(rec.name, e.target.value);
|
||
rec.color = color;
|
||
if (!rec.sheet) rec.sheet = {};
|
||
rec.sheet.color = color;
|
||
detail.querySelector('.lcd-header')?.style.setProperty('--lc1', color);
|
||
const av = detail.querySelector('.lcd-avatar');
|
||
if (av && !av.querySelector('img')) av.style.background = color;
|
||
_abRefreshCharacterColors(rec.name);
|
||
clearTimeout(_audiobook._colorSaveTimer);
|
||
_audiobook._colorSaveTimer = setTimeout(async () => {
|
||
try { if (typeof clPut === 'function') await clPut(rec); } catch (_) {}
|
||
}, 400);
|
||
});
|
||
detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => {
|
||
if (typeof _openVoicePicker === 'function')
|
||
_openVoicePicker(detail.querySelector('.lcd-voice-top'), rec, async () => {
|
||
const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id);
|
||
if (up) { _abCloseProfile(); _abShowProfile(up.name); }
|
||
});
|
||
});
|
||
detail.querySelector('.ab-cd-auto')?.addEventListener('click', async () => {
|
||
if (typeof _autoAssignVoice === 'function') {
|
||
await _autoAssignVoice(rec);
|
||
const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id);
|
||
if (up) { _abCloseProfile(); _abShowProfile(up.name); }
|
||
}
|
||
});
|
||
detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); });
|
||
|
||
// Pencil edit toggle
|
||
let _saveTimer = null;
|
||
const editBtn = detail.querySelector('.ab-cd-edit-btn');
|
||
editBtn?.addEventListener('click', function () {
|
||
const editing = detail.classList.toggle('ab-cd-editing');
|
||
this.innerHTML = editing ? '<span class="mdi mdi-check"></span>' : '<span class="mdi mdi-pencil-outline"></span>';
|
||
this.title = editing ? 'Fertig' : 'Charakterblatt bearbeiten';
|
||
detail.querySelectorAll('.ab-editable').forEach(el => {
|
||
el.contentEditable = editing ? 'true' : 'false';
|
||
if (editing) {
|
||
el.addEventListener('input', function onInput() {
|
||
clearTimeout(_saveTimer);
|
||
_saveTimer = setTimeout(async () => {
|
||
if (!rec.sheet) rec.sheet = {};
|
||
rec.sheet[el.dataset.sk] = el.textContent.trim();
|
||
if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(el.dataset.sk)) registerCharacterRecord(rec);
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
}, 900);
|
||
});
|
||
}
|
||
});
|
||
if (editing) detail.querySelector('.ab-editable')?.focus();
|
||
});
|
||
|
||
// Source page clicks → jump to reader
|
||
detail.querySelectorAll('.ab-cd-src').forEach(el => {
|
||
el.addEventListener('click', () => {
|
||
const pg = parseInt(el.dataset.page, 10);
|
||
if (!pg) return;
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
setTimeout(() => {
|
||
const pages = window.readerState?.pages;
|
||
if (pages && pages.length >= pg && pages[pg-1]?.pageDiv) {
|
||
pages[pg-1].pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
} else if (typeof toast === 'function') {
|
||
toast('Buch im „Vorlesen"-Bereich öffnen, dann nochmal klicken', 'info');
|
||
}
|
||
}, 300);
|
||
});
|
||
});
|
||
};
|
||
|
||
// Select a character: show bar + highlight rows, keep feed visible
|
||
const _abSelectChar = (name) => {
|
||
// Toggle off if same character clicked again
|
||
if (!_abBar.hidden && _abBar.dataset.charName === name) { _abCloseBar(); return; }
|
||
_abCloseBar();
|
||
chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.toggle('is-active', el.dataset.name === name));
|
||
|
||
_abCharRows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r =>
|
||
r.__seg?.speaker && r.__seg.speaker.toLowerCase() === name.toLowerCase()
|
||
);
|
||
_abNavIdx = 0;
|
||
|
||
const color = colorFor(name);
|
||
_abBar.dataset.charName = name;
|
||
_abBar.hidden = false;
|
||
_abSyncTopbar();
|
||
_abBar.innerHTML =
|
||
`<span class="ab-char-bar-dot" style="background:${color}">${(name||'?')[0].toUpperCase()}</span>
|
||
<div class="ab-char-bar-info">
|
||
<span class="ab-char-bar-name">${escHtml(name)}</span>
|
||
<span class="ab-char-bar-cnt">${_abCharRows.length} Zeilen</span>
|
||
</div>
|
||
<div class="ab-char-bar-nav">
|
||
<button class="ab-char-bar-prev" title="Vorherige Zeile"><span class="mdi mdi-chevron-up"></span></button>
|
||
<span class="ab-char-bar-pos">1 / ${_abCharRows.length}</span>
|
||
<button class="ab-char-bar-next" title="Nächste Zeile"><span class="mdi mdi-chevron-down"></span></button>
|
||
</div>
|
||
<input class="ab-char-bar-search" type="search" placeholder="In Zeilen suchen…" autocomplete="off">
|
||
<button class="ab-char-bar-profile" title="Charakterblatt öffnen"><span class="mdi mdi-account-details-outline"></span> Profil</button>
|
||
<button class="ab-char-bar-close" title="Auswahl aufheben"><span class="mdi mdi-close"></span></button>`;
|
||
|
||
// highlight all + jump to first
|
||
_abCharRows.forEach(r => r.classList.add('ab-char-hl'));
|
||
if (_abCharRows.length) {
|
||
_abCharRows[0].classList.add('ab-char-focus');
|
||
_abCharRows[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}
|
||
|
||
_abBar.querySelector('.ab-char-bar-prev').addEventListener('click', () => _abNav(-1));
|
||
_abBar.querySelector('.ab-char-bar-next').addEventListener('click', () => _abNav(1));
|
||
_abBar.querySelector('.ab-char-bar-profile').addEventListener('click', () => {
|
||
if (_abDetailEl) _abCloseProfile(); else _abShowProfile(name);
|
||
});
|
||
_abBar.querySelector('.ab-char-bar-close').addEventListener('click', _abCloseBar);
|
||
|
||
// Search within this character's rows
|
||
const inp = _abBar.querySelector('.ab-char-bar-search');
|
||
let _st = null;
|
||
const _doSearch = (jump) => {
|
||
const q = inp.value.trim().toLowerCase();
|
||
const pool = q ? _abCharRows.filter(r =>
|
||
(r.__seg?.text || r.querySelector('.ab-cv-txt')?.textContent || '').toLowerCase().includes(q)
|
||
) : _abCharRows;
|
||
feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus'));
|
||
if (pool.length) {
|
||
if (jump) _abNavIdx = (_abNavIdx + 1) % pool.length;
|
||
else _abNavIdx = 0;
|
||
pool[_abNavIdx].classList.add('ab-char-focus');
|
||
pool[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
_abBar.querySelector('.ab-char-bar-pos').textContent = (_abNavIdx + 1) + ' / ' + pool.length;
|
||
} else {
|
||
_abBar.querySelector('.ab-char-bar-pos').textContent = '0 Treffer';
|
||
}
|
||
};
|
||
inp.addEventListener('input', () => { clearTimeout(_st); _st = setTimeout(() => _doSearch(false), 200); });
|
||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } });
|
||
};
|
||
|
||
const renderRoster = () => {
|
||
const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count);
|
||
if (!items.length) { chars.innerHTML = '<span class="ab-cv-empty">reading…</span>'; return; }
|
||
const prev = _abBar.hidden ? null : _abBar.dataset.charName;
|
||
chars.innerHTML = items.map(([n, info]) => {
|
||
const color = info.color || colorFor(n);
|
||
return `<div class="ab-char-item${prev===n?' is-active':''}" data-name="${escHtml(n)}">
|
||
<span class="ab-char-dot" style="background:${color}">${escHtml((n||'?')[0].toUpperCase())}</span>
|
||
<span class="ab-char-name">${escHtml(n)}</span>
|
||
<b class="ab-char-count">${roster.get(n)?.count||0}</b>
|
||
<button class="ab-char-alias-btn" data-name="${escHtml(n)}" title="Add alias / also known as"><span class="mdi mdi-tag-plus-outline"></span></button>
|
||
</div>`;
|
||
}).join('');
|
||
chars.querySelectorAll('.ab-char-item').forEach(item => {
|
||
item.addEventListener('click', () => _abSelectChar(item.dataset.name));
|
||
});
|
||
chars.querySelectorAll('.ab-char-alias-btn').forEach(btn => {
|
||
btn.addEventListener('click', e => { e.stopPropagation(); _abOpenAliasPopup(btn.dataset.name, btn); });
|
||
});
|
||
};
|
||
|
||
// Quick "also known as" shortcut right in the sidebar, so adding an alias
|
||
// (e.g. "Garthai" for "Sharraz Garthai", or "Ork" for a role-only speaker)
|
||
// doesn't require leaving the casting screen for the full Character Library
|
||
// editor. Writes through the same clUpsert used everywhere else, so the
|
||
// alias is immediately shared with Rehearser/Character sheets too.
|
||
let _abAliasPopup = null;
|
||
function _abCloseAliasPopup() { if (_abAliasPopup) { _abAliasPopup.remove(); _abAliasPopup = null; } }
|
||
function _abOpenAliasPopup(name, anchorEl) {
|
||
_abCloseAliasPopup();
|
||
const el = document.createElement('div');
|
||
el.className = 'ab-alias-popup';
|
||
el.innerHTML = `
|
||
<div class="ab-alias-popup-title">Also known as — <b>${escHtml(name)}</b></div>
|
||
<input type="text" class="ab-alias-popup-inp" placeholder="e.g. Garthai, der Fremde" autocomplete="off">
|
||
<div class="ab-alias-popup-actions">
|
||
<button type="button" class="btn-secondary btn-sm ab-alias-cancel">Cancel</button>
|
||
<button type="button" class="btn-primary btn-sm ab-alias-save">Add</button>
|
||
</div>`;
|
||
document.body.appendChild(el);
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
el.style.left = Math.min(rect.left, window.innerWidth - 260) + 'px';
|
||
el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 120) + 'px';
|
||
const inp = el.querySelector('.ab-alias-popup-inp');
|
||
setTimeout(() => inp.focus(), 30);
|
||
const save = async () => {
|
||
const alias = inp.value.trim();
|
||
if (!alias) { _abCloseAliasPopup(); return; }
|
||
try {
|
||
const book = window.readerState?.title || '';
|
||
const rec = await clUpsert(book, { name, aliases: alias });
|
||
if (rec) { registerCharacterRecord(rec); renderRoster(); if (_hlCache) _hlCache.ver = -1; }
|
||
toast(`"${alias}" added as an alias for ${name}`, 'success');
|
||
} catch (err) {
|
||
toast('Could not save alias: ' + (err.message || err), 'error');
|
||
}
|
||
_abCloseAliasPopup();
|
||
};
|
||
el.querySelector('.ab-alias-save').addEventListener('click', save);
|
||
el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup());
|
||
inp.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); save(); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); _abCloseAliasPopup(); }
|
||
});
|
||
setTimeout(() => {
|
||
document.addEventListener('click', function onDoc(e) {
|
||
if (!el.contains(e.target) && e.target !== anchorEl) { _abCloseAliasPopup(); document.removeEventListener('click', onDoc); }
|
||
});
|
||
}, 0);
|
||
_abAliasPopup = el;
|
||
}
|
||
|
||
(async () => {
|
||
const title = window.readerState?.title || '';
|
||
try {
|
||
const records = typeof clGetAllByTagOrBook === 'function'
|
||
? await clGetAllByTagOrBook(title)
|
||
: (typeof clGetAll === 'function' ? await clGetAll() : []);
|
||
for (const rec of records || []) {
|
||
if (!rec?.name) continue;
|
||
registerCharacterRecord(rec);
|
||
for (const alias of identityNames(rec)) {
|
||
const rosterName = [...roster.keys()].find(n => n.toLowerCase() === alias.toLowerCase());
|
||
if (rosterName) setCharacterColor(rosterName, _abRecordColor(rec, rec.name));
|
||
}
|
||
}
|
||
_abRefreshCharacterColors();
|
||
} catch (_) {}
|
||
})();
|
||
|
||
const MAXROWS = Number.POSITIVE_INFINITY; // keep the full completed cast available for review/page navigation
|
||
// Whether the user has scrolled up to review/edit earlier content.
|
||
// While true: auto-scroll and trim are suppressed so their position is preserved.
|
||
let _userScrolled = false;
|
||
// jumpBtn already declared above (in the character-detail block)
|
||
feed.addEventListener('scroll', () => {
|
||
const atBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 80;
|
||
_userScrolled = !atBottom;
|
||
if (jumpBtn) jumpBtn.hidden = atBottom;
|
||
const labels = Array.from(feed.querySelectorAll('.ab-cv-page-label[data-page]'));
|
||
let current = _abCurrentPageNum;
|
||
for (const label of labels) {
|
||
const r = label.closest('.ab-cv-page')?.getBoundingClientRect();
|
||
const fr = feed.getBoundingClientRect();
|
||
if (r && r.top <= fr.top + 80) current = Number(label.dataset.page) || current;
|
||
else break;
|
||
}
|
||
if (current) _abSetCurrentPage(current);
|
||
}, { passive: true });
|
||
const _scrollToBottom = () => { feed.scrollTop = feed.scrollHeight; };
|
||
if (jumpBtn) {
|
||
jumpBtn.addEventListener('click', () => {
|
||
_userScrolled = false;
|
||
jumpBtn.hidden = true;
|
||
_scrollToBottom();
|
||
});
|
||
}
|
||
const trim = () => {
|
||
if (_userScrolled) return; // don't touch the feed while user is reading/editing
|
||
let rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider');
|
||
while (rows.length > MAXROWS) {
|
||
const first = rows[0];
|
||
const page = first.closest('.ab-cv-page');
|
||
first.remove();
|
||
if (page && !page.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) page.remove();
|
||
rows = feed.querySelectorAll('.ab-cv-row, .ab-cv-note, .ab-cv-divider');
|
||
}
|
||
_scrollToBottom();
|
||
};
|
||
|
||
// Group casting feed content into per-page cards (white "paper" on the feed's
|
||
// canvas background) so long books read visually like a paginated document
|
||
// instead of one unbroken scroll. pagemark() starts a new card; everything
|
||
// else appends into whichever card is currently open.
|
||
let _abCurPage = null;
|
||
const _abJumpToReaderPage = async (pageNum) => {
|
||
const pg = parseInt(pageNum, 10);
|
||
if (!pg) return;
|
||
if (typeof window.readerJumpToPage === 'function') {
|
||
await window.readerJumpToPage(pg);
|
||
return;
|
||
}
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
if (typeof window.navReaderView === 'function') window.navReaderView('main');
|
||
else if (typeof window.showReaderView === 'function') window.showReaderView('main');
|
||
setTimeout(async () => {
|
||
const pageDiv = window.readerState?.pages?.[pg - 1]?.pageDiv;
|
||
if (pageDiv) {
|
||
if (typeof window.readerRenderPage === 'function') await window.readerRenderPage(pg - 1);
|
||
pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'nearest' });
|
||
}
|
||
else if (typeof toast === 'function') toast('Source page is not loaded in Reader yet', 'info');
|
||
}, 250);
|
||
};
|
||
const _abNewPage = (label, pageNum) => {
|
||
// Drop the previous card if a page boundary produced no content at all
|
||
// (e.g. a blank page), so we don't leave an empty white box in the feed.
|
||
if (_abCurPage && !_abCurPage.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider')) _abCurPage.remove();
|
||
const p = document.createElement('div');
|
||
p.className = 'ab-cv-page';
|
||
if (label) {
|
||
const pageAttr = pageNum ? ` data-page="${escHtml(String(pageNum))}"` : '';
|
||
p.innerHTML = `<button class="ab-cv-page-label"${pageAttr} type="button" title="Back to source page in Reader"><span class="mdi mdi-book-open-page-variant"></span> ${escHtml(label)} <span class="mdi mdi-arrow-u-left-top ab-cv-page-back"></span></button>`;
|
||
}
|
||
feed.appendChild(p);
|
||
_abCurPage = p;
|
||
if (pageNum) {
|
||
_abSetCurrentPage(pageNum);
|
||
_abUpdatePageNav();
|
||
}
|
||
return p;
|
||
};
|
||
const _abPage = () => _abCurPage || _abNewPage();
|
||
const _abPageNumbers = () => {
|
||
const set = new Set();
|
||
const readerPages = window.readerState?.mode === 'pdf' ? (window.readerState?.pages?.length || 0) : 0;
|
||
if (readerPages > 0) {
|
||
for (let i = 1; i <= readerPages; i++) set.add(i);
|
||
} else {
|
||
(_audiobook.pageMarks || []).forEach(m => set.add((m.page || 0) + 1));
|
||
}
|
||
feed.querySelectorAll('.ab-cv-page-label[data-page]').forEach(el => {
|
||
const n = parseInt(el.dataset.page, 10);
|
||
if (n) set.add(n);
|
||
});
|
||
return [...set].sort((a, b) => a - b);
|
||
};
|
||
const _abSetCurrentPage = (pageNum) => {
|
||
const pg = parseInt(pageNum, 10);
|
||
if (!pg) return;
|
||
_abCurrentPageNum = pg;
|
||
const sel = _abPageNav.querySelector('.ab-page-select');
|
||
if (sel && [...sel.options].some(o => Number(o.value) === pg)) sel.value = String(pg);
|
||
};
|
||
const _abUpdatePageNav = () => {
|
||
const pages = _abPageNumbers();
|
||
const sel = _abPageNav.querySelector('.ab-page-select');
|
||
if (!sel || !pages.length) { _abPageNav.hidden = true; if (typeof _abSyncTopbar === 'function') _abSyncTopbar(); return; }
|
||
const old = Number(sel.value) || _abCurrentPageNum || pages[0];
|
||
sel.innerHTML = pages.map(n => `<option value="${n}">Page ${n}</option>`).join('');
|
||
_abPageNav.hidden = pages.length <= 1;
|
||
_abSetCurrentPage(pages.includes(old) ? old : pages[0]);
|
||
if (typeof _abSyncTopbar === 'function') _abSyncTopbar();
|
||
};
|
||
const _abScrollToCastPage = (pageNum) => {
|
||
const pg = parseInt(pageNum, 10);
|
||
if (!pg) return;
|
||
let label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`);
|
||
if (!label) {
|
||
const active = _abActiveSegments();
|
||
if (active.arr.length) _abRedrawSegments(active.arr);
|
||
label = feed.querySelector(`.ab-cv-page-label[data-page="${CSS.escape(String(pg))}"]`);
|
||
}
|
||
if (pg === 1) {
|
||
_userScrolled = true;
|
||
if (jumpBtn) jumpBtn.hidden = false;
|
||
const firstPage = label?.closest('.ab-cv-page') || feed.firstElementChild;
|
||
if (firstPage) firstPage.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
else feed.scrollTo({ top: 0, behavior: 'smooth' });
|
||
_abSetCurrentPage(pg);
|
||
return;
|
||
}
|
||
if (!label) {
|
||
toast('This cast page is not available yet. Use the book button to jump to the source page.', 'info');
|
||
_abSetCurrentPage(pg);
|
||
return;
|
||
}
|
||
_userScrolled = true;
|
||
if (jumpBtn) jumpBtn.hidden = false;
|
||
label.closest('.ab-cv-page')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
_abSetCurrentPage(pg);
|
||
};
|
||
const _abStepPage = (dir) => {
|
||
const pages = _abPageNumbers();
|
||
if (!pages.length) return;
|
||
const current = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum || pages[0];
|
||
const idx = Math.max(0, pages.indexOf(current));
|
||
const next = pages[Math.max(0, Math.min(pages.length - 1, idx + dir))];
|
||
_abScrollToCastPage(next);
|
||
};
|
||
_abPageNav.querySelector('.ab-page-select')?.addEventListener('change', e => _abScrollToCastPage(e.target.value));
|
||
_abPageNav.querySelector('.ab-page-prev')?.addEventListener('click', () => _abStepPage(-1));
|
||
_abPageNav.querySelector('.ab-page-next')?.addEventListener('click', () => _abStepPage(1));
|
||
_abPageNav.querySelector('.ab-page-source')?.addEventListener('click', () => {
|
||
const pg = Number(_abPageNav.querySelector('.ab-page-select')?.value) || _abCurrentPageNum;
|
||
_abJumpToReaderPage(pg);
|
||
});
|
||
|
||
const _abUndoStack = [];
|
||
const _abRedoStack = [];
|
||
const _abHistoryLimit = 40;
|
||
const _abSyncTopbar = () => {
|
||
const hasSelection = !_abBar.hidden;
|
||
const hasHistory = _abUndoStack.length > 0 || _abRedoStack.length > 0;
|
||
_abEditToolbar.hidden = !hasHistory;
|
||
// Always visible — it hosts the font-size controls now, not just the
|
||
// page nav / selection bar / undo history.
|
||
_abTopbar.hidden = false;
|
||
};
|
||
const _abActiveSegments = () => {
|
||
if (_audiobook.running && Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments };
|
||
if (Array.isArray(_audiobook.segments)) return { key: 'segments', arr: _audiobook.segments };
|
||
if (Array.isArray(_audiobook.liveSegments)) return { key: 'liveSegments', arr: _audiobook.liveSegments };
|
||
return { key: 'segments', arr: [] };
|
||
};
|
||
const _abCloneSegments = (segs) => (segs || []).map(s => ({ ...s }));
|
||
const _abSyncRosterList = (segs) => {
|
||
const names = [];
|
||
for (const s of segs || []) {
|
||
const sp = s?.type === 'dialogue' && s.speaker ? s.speaker : '';
|
||
if (!sp || /^Narrator$/i.test(sp) || /^Unknown|Unbekannt/i.test(sp)) continue;
|
||
if (!names.some(n => n.toLowerCase() === sp.toLowerCase())) names.push(sp);
|
||
}
|
||
_audiobook.roster = names;
|
||
};
|
||
const _abPersistManualEdit = () => {
|
||
const active = _abActiveSegments();
|
||
if (_audiobook.lastText) {
|
||
const done = _audiobook.completedChunks || active.arr.length;
|
||
const total = _audiobook.completedTotal || done;
|
||
setTimeout(() => _abSaveDraft(active.arr || [], _audiobook.roster || [], _audiobook.lastText, done, total), 50);
|
||
}
|
||
_audiobookDebouncedSave();
|
||
};
|
||
const _abUpdateEditButtons = () => {
|
||
const undo = _abEditToolbar.querySelector('.ab-edit-undo');
|
||
const redo = _abEditToolbar.querySelector('.ab-edit-redo');
|
||
if (undo) undo.disabled = !_abUndoStack.length;
|
||
if (redo) redo.disabled = !_abRedoStack.length;
|
||
_abSyncTopbar();
|
||
};
|
||
const _abPushEditState = (keyOverride, arrOverride) => {
|
||
const active = arrOverride ? { key: keyOverride || 'segments', arr: arrOverride } : _abActiveSegments();
|
||
if (!active.arr.length) return false;
|
||
_abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) });
|
||
while (_abUndoStack.length > _abHistoryLimit) _abUndoStack.shift();
|
||
_abRedoStack.length = 0;
|
||
_abUpdateEditButtons();
|
||
return true;
|
||
};
|
||
const _abRowSpeaker = (s) => {
|
||
const isNarrator = s.type !== 'dialogue' || !s.speaker || String(s.speaker).toLowerCase() === 'narrator';
|
||
return { isNarrator, speakerName: isNarrator ? 'Narrator' : s.speaker };
|
||
};
|
||
// A narration segment that reads like a heading: short, standalone (a
|
||
// paragraph of its own from OCR/paragraph detection), either a chapter-word
|
||
// pattern or a few words with no sentence punctuation. Rendered bold,
|
||
// larger, and centered so recovered chapter titles look like titles.
|
||
const _abIsHeadingSeg = (s) => {
|
||
if (s?.type === 'dialogue') return false;
|
||
const t = String(s?.text || '').trim();
|
||
if (!t || t.length > 60) return false;
|
||
if (/^(prolog(ue)?|epilog(ue)?|kapitel|chapter|teil|buch|part|book|akt|szene|scene)\b/i.test(t)) return true;
|
||
if (/^\d+[\.\)]?\s*(kapitel|chapter)?$/i.test(t)) return true;
|
||
return t.split(/\s+/).length <= 7 && !/[.!?;:,«»"]/.test(t);
|
||
};
|
||
const _abRowFromSegment = (s, extraClass = '') => {
|
||
const { isNarrator, speakerName } = _abRowSpeaker(s);
|
||
const c = colorFor(speakerName);
|
||
const row = document.createElement('div');
|
||
row.className = `ab-cv-row${extraClass ? ' ' + extraClass : ''}${isNarrator ? ' is-narr' : ''}${_abIsHeadingSeg(s) ? ' is-heading' : ''}`;
|
||
row.__seg = s;
|
||
row.innerHTML = `<span class="ab-cv-row-tools">
|
||
<button class="ab-cv-row-tool ab-cv-edit-text" type="button" title="Edit text"><span class="mdi mdi-pencil-outline"></span></button>
|
||
<button class="ab-cv-row-tool ab-cv-merge-prev" type="button" title="Merge with previous segment"><span class="mdi mdi-arrow-collapse-up"></span></button>
|
||
<button class="ab-cv-row-tool ab-cv-merge-next" type="button" title="Merge with next segment"><span class="mdi mdi-arrow-collapse-down"></span></button>
|
||
</span>
|
||
<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span>
|
||
<span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||
// No per-row listeners here on purpose — with 1000+ segments in a big book,
|
||
// attaching 2 extra listeners per row on every redraw (merge/split rebuilds
|
||
// the whole feed) was measurably slower. Editing is wired once via event
|
||
// delegation on `feed` instead (see _abStartRowEdit and its callers below).
|
||
return row;
|
||
};
|
||
// Swap a row's text for a textarea (edit button click or double-click),
|
||
// delegated from a single feed-level listener instead of per-row ones.
|
||
const _abStartRowEdit = (row) => {
|
||
if (!row || !row.__seg || row.querySelector('.ab-cv-edit-ta')) return;
|
||
const s = row.__seg;
|
||
const txtEl = row.querySelector('.ab-cv-txt');
|
||
if (!txtEl) return;
|
||
const ta = document.createElement('textarea');
|
||
ta.className = 'ab-cv-edit-ta';
|
||
ta.value = s.text || '';
|
||
txtEl.replaceWith(ta);
|
||
ta.focus();
|
||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||
const commit = (save) => {
|
||
if (save) {
|
||
const val = ta.value;
|
||
if (val !== s.text) {
|
||
_abPushEditState();
|
||
s.text = val;
|
||
_abPersistManualEdit();
|
||
}
|
||
}
|
||
ta.replaceWith(txtEl);
|
||
txtEl.innerHTML = highlightText(s.text || '');
|
||
};
|
||
ta.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); commit(true); }
|
||
else if (e.key === 'Escape') { e.preventDefault(); commit(false); }
|
||
});
|
||
ta.addEventListener('blur', () => commit(true));
|
||
};
|
||
const _abRecountRoster = (segs) => {
|
||
for (const info of roster.values()) info.count = 0;
|
||
for (const s of segs || []) {
|
||
const { speakerName } = _abRowSpeaker(s || {});
|
||
const c = colorFor(speakerName);
|
||
if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c });
|
||
roster.get(speakerName).count++;
|
||
}
|
||
_abSyncRosterList(segs || []);
|
||
renderRoster();
|
||
};
|
||
const _abRedrawSegments = (segs) => {
|
||
const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : '';
|
||
feed.innerHTML = '';
|
||
_abCurPage = null;
|
||
// Segments carry their own page number — stamped at cast time for new
|
||
// casts, and back-filled once at draft load by _abStampSegmentPages for
|
||
// drafts saved before the .page field existed. Rendering directly from it
|
||
// (instead of re-guessing offsets from text on every redraw) is what fixed
|
||
// pages silently merging after navigating away from the panel and back.
|
||
let lastPage = null;
|
||
for (const s of segs || []) {
|
||
if (s.page != null && s.page !== lastPage) {
|
||
_abNewPage('Page ' + s.page, s.page);
|
||
lastPage = s.page;
|
||
}
|
||
_abPage().appendChild(_abRowFromSegment(s));
|
||
}
|
||
_abRecountRoster(segs || []);
|
||
_abClearHL();
|
||
_abUpdatePageNav();
|
||
if (selectedChar) {
|
||
_abBar.hidden = true;
|
||
_abSelectChar(selectedChar);
|
||
}
|
||
};
|
||
const _abRestoreEditState = (snap) => {
|
||
if (!snap) return;
|
||
const restored = _abCloneSegments(snap.segments);
|
||
if (snap.key === 'liveSegments') _audiobook.liveSegments = restored;
|
||
else _audiobook.segments = restored;
|
||
_abRedrawSegments(restored);
|
||
_abPersistManualEdit();
|
||
};
|
||
const _abUndoEdit = () => {
|
||
const active = _abActiveSegments();
|
||
if (!_abUndoStack.length || !active.arr.length) return;
|
||
_abRedoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) });
|
||
_abRestoreEditState(_abUndoStack.pop());
|
||
_abUpdateEditButtons();
|
||
};
|
||
const _abRedoEdit = () => {
|
||
const active = _abActiveSegments();
|
||
if (!_abRedoStack.length || !active.arr.length) return;
|
||
_abUndoStack.push({ key: active.key, segments: _abCloneSegments(active.arr) });
|
||
_abRestoreEditState(_abRedoStack.pop());
|
||
_abUpdateEditButtons();
|
||
};
|
||
_abEditToolbar.querySelector('.ab-edit-undo')?.addEventListener('click', _abUndoEdit);
|
||
_abEditToolbar.querySelector('.ab-edit-redo')?.addEventListener('click', _abRedoEdit);
|
||
_abSyncTopbar();
|
||
|
||
// Manual character assignment logic
|
||
let assignModeSeg = null;
|
||
let assignModeRow = null;
|
||
|
||
let assignPopup = document.getElementById('ab-cv-assign-popup');
|
||
if (!assignPopup) {
|
||
assignPopup = document.createElement('div');
|
||
assignPopup.id = 'ab-cv-assign-popup';
|
||
assignPopup.style.cssText = 'position:fixed; z-index:2001; display:none; background:var(--surface); border:1px solid var(--border); border-radius:8px; box-shadow:0 10px 25px rgba(0,0,0,0.4); padding:10px; width:220px; max-height:320px; flex-direction:column; gap:8px;';
|
||
|
||
const header = document.createElement('div');
|
||
header.style.cssText = 'display:flex; justify-content:space-between; align-items:center; margin-bottom:-4px; margin-top:-4px;';
|
||
header.innerHTML = '<span style="font-size:11px; font-weight:600; color:var(--subtext); text-transform:uppercase;">Assign to</span><span class="mdi mdi-close" style="cursor:pointer; font-size:16px; color:var(--subtext); padding:4px;" title="Close (Esc)"></span>';
|
||
assignPopup.appendChild(header);
|
||
|
||
const inp = document.createElement('input');
|
||
inp.type = 'text'; inp.placeholder = 'Search or add character...';
|
||
inp.style.cssText = 'width:100%; padding:6px; font-size:13px; border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text);';
|
||
assignPopup.appendChild(inp);
|
||
const list = document.createElement('div');
|
||
list.className = 'ab-cv-popup-list';
|
||
list.style.cssText = 'display:flex; flex-direction:column; gap:2px; overflow-y:auto; max-height:220px; margin-right:-4px; padding-right:4px;';
|
||
assignPopup.appendChild(list);
|
||
document.body.appendChild(assignPopup);
|
||
|
||
// assignPopup is a page-lifetime DOM singleton, but audiobookCastView()
|
||
// (and its assignName/closeAssignPopup/roster closures) gets re-created
|
||
// on every fresh cast/recast run. Listeners attached here would otherwise
|
||
// permanently close over whichever run's functions existed the first
|
||
// time this "if (!assignPopup)" block ran — so typing a new name and
|
||
// hitting Enter would silently call a stale, disconnected assignName
|
||
// from an earlier session. _abOpenAssignPopup() refreshes
|
||
// assignPopup._assignName/_close on every open, so route through those
|
||
// instead of the closed-over references.
|
||
header.querySelector('.mdi-close').addEventListener('click', () => assignPopup._close?.());
|
||
|
||
document.addEventListener('mousedown', e => {
|
||
if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target) && !e.target.closest('.ab-cv-spk')) {
|
||
assignPopup._close?.();
|
||
}
|
||
});
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && assignPopup.style.display !== 'none') {
|
||
assignPopup._close?.();
|
||
}
|
||
});
|
||
inp.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') {
|
||
const addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn');
|
||
const visibleBtns = Array.from(assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]')).filter(b => b.style.display !== 'none');
|
||
if (addBtn && addBtn.style.display !== 'none') {
|
||
addBtn.click();
|
||
} else if (visibleBtns.length === 1) {
|
||
visibleBtns[0].click();
|
||
} else {
|
||
const val = inp.value.trim();
|
||
if (val) assignPopup._assignName?.(val);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
assignPopup._close?.();
|
||
}
|
||
});
|
||
inp.addEventListener('input', () => {
|
||
const val = inp.value.trim().toLowerCase();
|
||
const listItems = assignPopup.querySelectorAll('.ab-cv-popup-list > div[data-name]');
|
||
let exactMatch = false;
|
||
listItems.forEach(item => {
|
||
const name = item.dataset.name.toLowerCase();
|
||
if (name === val) exactMatch = true;
|
||
if (name.includes(val)) item.style.display = 'flex';
|
||
else item.style.display = 'none';
|
||
});
|
||
let addBtn = assignPopup.querySelector('.ab-cv-popup-add-btn');
|
||
if (val && !exactMatch && val !== 'narrator') {
|
||
if (!addBtn) {
|
||
addBtn = document.createElement('div');
|
||
addBtn.className = 'ab-cv-popup-add-btn';
|
||
addBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--primary); border-top:1px solid var(--border); margin-top:4px; padding-top:8px;';
|
||
addBtn.onmouseover = () => addBtn.style.background = 'var(--panel)';
|
||
addBtn.onmouseout = () => addBtn.style.background = 'transparent';
|
||
}
|
||
addBtn.innerHTML = `<span style="font-size:14px; font-weight:bold;">+</span> Add "${escHtml(inp.value.trim())}"`;
|
||
addBtn.onclick = () => assignPopup._assignName?.(inp.value.trim());
|
||
addBtn.style.display = 'flex';
|
||
assignPopup.querySelector('.ab-cv-popup-list').appendChild(addBtn); // keep at bottom
|
||
} else if (addBtn) {
|
||
addBtn.style.display = 'none';
|
||
}
|
||
});
|
||
}
|
||
|
||
function closeAssignPopup() {
|
||
assignPopup.style.display = 'none';
|
||
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
|
||
assignModeSeg = null;
|
||
assignModeRow = null;
|
||
}
|
||
|
||
function assignName(name) {
|
||
if (!assignModeSeg) return;
|
||
const active = _abActiveSegments();
|
||
_abPushEditState(active.key, active.arr);
|
||
const isNarrator = name.toLowerCase() === 'narrator';
|
||
// Decrement old speaker's count before reassigning
|
||
const _oldSpk = assignModeSeg.speaker;
|
||
if (_oldSpk && roster.has(_oldSpk)) {
|
||
const _old = roster.get(_oldSpk);
|
||
if (_old.count > 0) _old.count--;
|
||
}
|
||
assignModeSeg.speaker = isNarrator ? 'Narrator' : name;
|
||
assignModeSeg.type = isNarrator ? 'narration' : 'dialogue';
|
||
if (isNarrator) assignModeSeg.emotion = '';
|
||
assignModeRow.classList.remove('is-assigning');
|
||
|
||
const speakerName = isNarrator ? 'Narrator' : name;
|
||
const c = colorFor(speakerName);
|
||
if (!roster.has(speakerName)) roster.set(speakerName, { count: 0, color: c });
|
||
roster.get(speakerName).count++;
|
||
|
||
assignModeRow.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||
assignModeRow.querySelector('.ab-cv-spk').innerHTML = `${escHtml(speakerName)}${assignModeSeg.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(assignModeSeg.emotion) + ')</span>' : ''}`;
|
||
assignModeRow.querySelector('.ab-cv-spk').style.color = c;
|
||
assignModeRow.querySelector('.ab-cv-spk').title = 'Click to assign character';
|
||
_abRecountRoster(_abActiveSegments().arr);
|
||
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
|
||
closeAssignPopup();
|
||
// Persist manual corrections to both localStorage (fast) and IndexedDB (durable).
|
||
// Reuse the real done/total the cast last reached — an interrupted cast must
|
||
// stay reported (and resumable) as interrupted, not flip to "100% done" just
|
||
// because a speaker got manually reassigned.
|
||
_abPersistManualEdit();
|
||
}
|
||
|
||
// Shared by clicking the speaker label AND clicking/dragging a name inside
|
||
// the narration text itself — same popup, optionally pre-filled with the
|
||
// word(s) you clicked so you don't have to retype an exact match.
|
||
function _abOpenAssignPopup(row, anchorEl, prefill) {
|
||
if (!row || !row.__seg || row.classList.contains('is-processing')) return;
|
||
if (assignModeRow) assignModeRow.classList.remove('is-assigning');
|
||
assignModeSeg = row.__seg;
|
||
assignModeRow = row;
|
||
row.classList.add('is-assigning');
|
||
// Re-point the popup's shared listeners at THIS view's assignName/close —
|
||
// audiobookCastView() is re-invoked per cast/recast run, so these must be
|
||
// refreshed on every open rather than captured once (see the singleton
|
||
// setup above for why).
|
||
assignPopup._assignName = assignName;
|
||
assignPopup._close = closeAssignPopup;
|
||
window.getSelection().removeAllRanges();
|
||
|
||
assignPopup.style.display = 'flex';
|
||
const rect = anchorEl.getBoundingClientRect();
|
||
const top = Math.min(rect.bottom + 4, window.innerHeight - 300);
|
||
assignPopup.style.top = top + 'px';
|
||
assignPopup.style.left = rect.left + 'px';
|
||
|
||
const list = assignPopup.querySelector('.ab-cv-popup-list');
|
||
list.innerHTML = '';
|
||
|
||
const narrBtn = document.createElement('div');
|
||
narrBtn.dataset.name = 'Narrator';
|
||
narrBtn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--subtext); border-bottom:1px solid var(--border); margin-bottom:4px; padding-bottom:8px;';
|
||
narrBtn.innerHTML = `<span style="font-size:13px;">📖</span> Narrator`;
|
||
narrBtn.onmouseover = () => narrBtn.style.background = 'var(--panel)';
|
||
narrBtn.onmouseout = () => narrBtn.style.background = 'transparent';
|
||
narrBtn.onclick = () => assignName('Narrator');
|
||
list.appendChild(narrBtn);
|
||
|
||
const items = [...roster.entries()].sort((a, b) => b[1].count - a[1].count);
|
||
for (const [n, info] of items) {
|
||
const btn = document.createElement('div');
|
||
btn.dataset.name = n;
|
||
btn.style.cssText = 'padding:6px 8px; font-size:12px; cursor:pointer; border-radius:4px; display:flex; align-items:center; gap:6px; transition:background 0.1s; font-weight:600; color:var(--text);';
|
||
btn.innerHTML = `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};"></span> ${escHtml(n)}`;
|
||
btn.onmouseover = () => btn.style.background = 'var(--panel)';
|
||
btn.onmouseout = () => btn.style.background = 'transparent';
|
||
btn.onclick = () => assignName(n);
|
||
list.appendChild(btn);
|
||
}
|
||
|
||
const inp = assignPopup.querySelector('input');
|
||
inp.value = prefill || '';
|
||
setTimeout(() => { inp.focus(); if (prefill) inp.dispatchEvent(new Event('input')); }, 50);
|
||
}
|
||
|
||
const _abIsUnknownSeg = (s) => !s?.speaker || /^Unknown|Unbekannt/i.test(s.speaker);
|
||
const _abIsNarrSeg = (s) => s?.type !== 'dialogue' || !s.speaker || /^Narrator$/i.test(s.speaker);
|
||
const _abMergedSegment = (a, b) => {
|
||
let base = a;
|
||
if (_abIsUnknownSeg(a) && !_abIsUnknownSeg(b)) base = b;
|
||
else if (_abIsNarrSeg(a) && !_abIsNarrSeg(b)) base = b;
|
||
const type = _abIsNarrSeg(base) ? 'narration' : 'dialogue';
|
||
const speaker = type === 'narration' ? 'Narrator' : (base.speaker || 'Unknown');
|
||
return {
|
||
speaker,
|
||
type,
|
||
emotion: type === 'dialogue' ? (base.emotion || a.emotion || b.emotion || '') : '',
|
||
text: audiobookJoinSegmentText(a.text || '', b.text || ''),
|
||
// Anchor the merged row at its earlier segment's page — merging never
|
||
// built this at all before, so a merge would silently drop the row's
|
||
// page number and it would render as if it belonged to whatever page
|
||
// card came before it.
|
||
page: a.page ?? b.page,
|
||
};
|
||
};
|
||
const _abMergeByRow = (row, dir) => {
|
||
const active = _abActiveSegments();
|
||
const idx = active.arr.indexOf(row?.__seg);
|
||
if (idx < 0) return;
|
||
const leftIdx = dir < 0 ? idx - 1 : idx;
|
||
const rightIdx = dir < 0 ? idx : idx + 1;
|
||
if (leftIdx < 0 || rightIdx >= active.arr.length) {
|
||
toast('No adjacent segment to merge', 'info');
|
||
return;
|
||
}
|
||
_abPushEditState(active.key, active.arr);
|
||
const merged = _abMergedSegment(active.arr[leftIdx], active.arr[rightIdx]);
|
||
active.arr.splice(leftIdx, 2, merged);
|
||
if (active.key === 'segments') _audiobook.segments = active.arr;
|
||
else _audiobook.liveSegments = active.arr;
|
||
_abRedrawSegments(active.arr);
|
||
_abPersistManualEdit();
|
||
toast('Segments merged', 'success');
|
||
};
|
||
|
||
// Editing is pencil-icon only (see the click handler's .ab-cv-edit-text
|
||
// branch) — double-click was removed so selecting/dragging text to assign
|
||
// a character (see the .ab-cv-txt mousedown/mouseup handling below) can't
|
||
// accidentally drop you into edit mode instead.
|
||
|
||
feed.addEventListener('click', e => {
|
||
const pageLabel = e.target.closest('.ab-cv-page-label[data-page]');
|
||
if (pageLabel) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_abJumpToReaderPage(pageLabel.dataset.page);
|
||
return;
|
||
}
|
||
const editBtn = e.target.closest('.ab-cv-edit-text');
|
||
if (editBtn) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_abStartRowEdit(editBtn.closest('.ab-cv-row'));
|
||
return;
|
||
}
|
||
const mergeBtn = e.target.closest('.ab-cv-row-tool');
|
||
if (mergeBtn) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
_abMergeByRow(mergeBtn.closest('.ab-cv-row'), mergeBtn.classList.contains('ab-cv-merge-prev') ? -1 : 1);
|
||
return;
|
||
}
|
||
const spk = e.target.closest('.ab-cv-spk');
|
||
if (spk) {
|
||
_abOpenAssignPopup(spk.closest('.ab-cv-row'), spk, '');
|
||
return;
|
||
}
|
||
// Click a name/word inside the narration text itself — same popup,
|
||
// pre-filled with the clicked word so you don't have to retype it.
|
||
// Dragging across several words is handled by the native-selection
|
||
// listener below instead of a custom span-drag tracker, so it can't
|
||
// conflict with the existing "select text to split this segment" flow,
|
||
// which also reacts to window.getSelection() over the same text. That
|
||
// listener clears the selection once it acts, so this guard (rather than
|
||
// just isCollapsed) stops the click that follows a drag's mouseup from
|
||
// re-opening the popup with just the single word under the pointer.
|
||
if (_abJustHandledSelection) { _abJustHandledSelection = false; return; }
|
||
const txtEl = e.target.closest('.ab-cv-txt');
|
||
if (txtEl && window.getSelection().isCollapsed) {
|
||
const row = txtEl.closest('.ab-cv-row');
|
||
const nameHit = e.target.closest('.ab-name-hit');
|
||
if (nameHit) {
|
||
_abOpenAssignPopup(row, nameHit, nameHit.dataset.name || nameHit.textContent.trim());
|
||
} else {
|
||
const hit = _abWordRangeAtPoint(e.clientX, e.clientY);
|
||
if (hit) {
|
||
const rect = hit.range.getBoundingClientRect();
|
||
_abOpenAssignPopup(row, { getBoundingClientRect: () => rect }, hit.word);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
let _abJustHandledSelection = false;
|
||
|
||
// Hover highlight for the word under the cursor — one reused overlay div
|
||
// positioned from the caret-range rect, instead of per-word spans (see
|
||
// _abWordRangeAtPoint for why spans froze the page at book scale).
|
||
let _abHoverHL = document.getElementById('ab-word-hover');
|
||
if (!_abHoverHL) {
|
||
_abHoverHL = document.createElement('div');
|
||
_abHoverHL.id = 'ab-word-hover';
|
||
_abHoverHL.className = 'ab-word-hover';
|
||
_abHoverHL.hidden = true;
|
||
document.body.appendChild(_abHoverHL);
|
||
}
|
||
let _abHoverRaf = 0;
|
||
let _abHoverPt = null;
|
||
const _abHideHoverHL = () => { _abHoverHL.hidden = true; };
|
||
feed.addEventListener('mousemove', e => {
|
||
_abHoverPt = { x: e.clientX, y: e.clientY, target: e.target };
|
||
if (_abHoverRaf) return;
|
||
_abHoverRaf = requestAnimationFrame(() => {
|
||
_abHoverRaf = 0;
|
||
const pt = _abHoverPt;
|
||
if (!pt || !pt.target?.closest) return;
|
||
const txtEl = pt.target.closest('.ab-cv-txt');
|
||
if (!txtEl || pt.target.closest('.ab-name-hit') || feed.querySelector('.ab-cv-edit-ta')) { _abHideHoverHL(); return; }
|
||
const hit = _abWordRangeAtPoint(pt.x, pt.y);
|
||
if (!hit) { _abHideHoverHL(); return; }
|
||
const r = hit.range.getBoundingClientRect();
|
||
if (!r.width) { _abHideHoverHL(); return; }
|
||
_abHoverHL.style.left = (r.left - 2) + 'px';
|
||
_abHoverHL.style.top = (r.top - 1) + 'px';
|
||
_abHoverHL.style.width = (r.width + 4) + 'px';
|
||
_abHoverHL.style.height = (r.height + 2) + 'px';
|
||
_abHoverHL.hidden = false;
|
||
});
|
||
});
|
||
feed.addEventListener('mouseleave', _abHideHoverHL);
|
||
feed.addEventListener('scroll', _abHideHoverHL, { passive: true });
|
||
feed.addEventListener('dblclick', e => {
|
||
const hit = e.target.closest('.ab-name-hit');
|
||
if (!hit) return; // only a known name/alias can fast-assign; unknown text still needs the popup
|
||
const row = hit.closest('.ab-cv-row');
|
||
const name = hit.dataset.name;
|
||
if (row && name) {
|
||
_abOpenAssignPopup(row, hit, '');
|
||
assignName(name);
|
||
}
|
||
});
|
||
|
||
let splitBtn = document.getElementById('ab-cv-split-btn');
|
||
if (!splitBtn) {
|
||
splitBtn = document.createElement('button');
|
||
splitBtn.id = 'ab-cv-split-btn';
|
||
splitBtn.className = 'btn-secondary btn-sm';
|
||
splitBtn.innerHTML = '<span class="mdi mdi-call-split"></span> Split text to Unknown Speaker';
|
||
splitBtn.style.cssText = 'position:fixed; z-index:2000; display:none; background:var(--accent); color:#fff; border:none; box-shadow:0 4px 12px rgba(0,0,0,0.3);';
|
||
document.body.appendChild(splitBtn);
|
||
}
|
||
let currentSplitState = null;
|
||
|
||
// Character offset of a (node, offset) point within txtSpan's full text
|
||
// content — used instead of fullText.indexOf(selectedText) below, which
|
||
// silently split at the WRONG spot whenever the selected phrase (or a
|
||
// trimmed/whitespace variant of it) occurred more than once earlier in the
|
||
// same paragraph. Range-counting always finds the exact spot you dragged,
|
||
// no matter how common the selected text is elsewhere in the passage.
|
||
const _abOffsetInEl = (el, node, offset) => {
|
||
const r = document.createRange();
|
||
r.selectNodeContents(el);
|
||
r.setEnd(node, offset);
|
||
return r.toString().length;
|
||
};
|
||
|
||
document.addEventListener('selectionchange', () => {
|
||
if (!feed) return; // Panel closed
|
||
const sel = window.getSelection();
|
||
if (sel.isCollapsed || !feed.contains(sel.anchorNode)) {
|
||
if (splitBtn) splitBtn.style.display = 'none';
|
||
currentSplitState = null;
|
||
return;
|
||
}
|
||
const txtSpan = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode.closest('.ab-cv-txt') : sel.anchorNode.closest('.ab-cv-txt');
|
||
if (!txtSpan) { splitBtn.style.display = 'none'; return; }
|
||
|
||
const row = txtSpan.closest('.ab-cv-row');
|
||
if (!row || !row.__seg) return;
|
||
|
||
const rawText = sel.toString();
|
||
const text = rawText.trim();
|
||
if (text.length > 0) {
|
||
// If we are in assign mode and they selected a short text, it's for assigning a name. Don't show split.
|
||
if (assignModeSeg && text.length < 40 && !text.includes('\n')) {
|
||
splitBtn.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
const range = sel.getRangeAt(0);
|
||
const rect = range.getBoundingClientRect();
|
||
splitBtn.style.display = 'block';
|
||
splitBtn.style.top = (rect.bottom + 8) + 'px';
|
||
splitBtn.style.left = Math.max(10, rect.left + (rect.width / 2) - 100) + 'px';
|
||
// Forward selections have anchor==start; a backward drag (dragging
|
||
// right-to-left) has anchor==end, so use range.start/end (always
|
||
// document-order) rather than sel.anchor/focus for offset math.
|
||
const startOffset = _abOffsetInEl(txtSpan, range.startContainer, range.startOffset);
|
||
const endOffset = _abOffsetInEl(txtSpan, range.endContainer, range.endOffset);
|
||
currentSplitState = { row, text, rawText, seg: row.__seg, startOffset, endOffset };
|
||
} else {
|
||
splitBtn.style.display = 'none';
|
||
currentSplitState = null;
|
||
}
|
||
});
|
||
|
||
splitBtn.addEventListener('click', () => {
|
||
if (!currentSplitState) return;
|
||
const { row, seg, startOffset, endOffset } = currentSplitState;
|
||
const fullText = seg.text || '';
|
||
const before = fullText.slice(0, startOffset);
|
||
const selectedText = fullText.slice(startOffset, endOffset);
|
||
const after = fullText.slice(endOffset);
|
||
if (!selectedText) return;
|
||
|
||
// Try committed segments first, then live array (during active casting)
|
||
let arr = _audiobook.segments || [];
|
||
let globalIdx = arr.indexOf(seg);
|
||
if (globalIdx === -1 && Array.isArray(_audiobook.liveSegments)) {
|
||
arr = _audiobook.liveSegments;
|
||
globalIdx = arr.indexOf(seg);
|
||
}
|
||
if (globalIdx !== -1) _abPushEditState(arr === _audiobook.liveSegments ? 'liveSegments' : 'segments', arr);
|
||
const newSegs = [];
|
||
if (before.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: before, page: seg.page });
|
||
newSegs.push({ speaker: 'Unknown', type: 'dialogue', emotion: '', text: selectedText, page: seg.page });
|
||
if (after.length) newSegs.push({ speaker: seg.speaker, type: seg.type, emotion: seg.emotion, text: after, page: seg.page });
|
||
|
||
if (globalIdx !== -1) {
|
||
arr.splice(globalIdx, 1, ...newSegs);
|
||
_abRedrawSegments(arr);
|
||
_abPersistManualEdit();
|
||
} else {
|
||
// DOM-only split — will be reconciled once casting finishes
|
||
console.warn('[audiobook] split during casting: DOM-only, segment not yet in array');
|
||
const frag = document.createDocumentFragment();
|
||
for (const s of newSegs) frag.appendChild(_abRowFromSegment(s));
|
||
row.parentNode.insertBefore(frag, row);
|
||
row.remove();
|
||
}
|
||
|
||
splitBtn.style.display = 'none';
|
||
window.getSelection().removeAllRanges();
|
||
toast('Segment split! You can now assign a character to the Unknown block.', 'success');
|
||
});
|
||
|
||
feed.addEventListener('mouseup', () => {
|
||
const sel = window.getSelection();
|
||
if (sel.isCollapsed || !feed.contains(sel.anchorNode)) return;
|
||
const text = sel.toString().trim();
|
||
if (!text || text.length >= 40 || text.includes('\n')) return;
|
||
_abJustHandledSelection = true; // stop the click that follows this mouseup from also firing
|
||
if (assignModeSeg) {
|
||
// Popup already open (e.g. from clicking the speaker label) — a short
|
||
// selection refines/confirms the assignment directly, as before.
|
||
assignName(text);
|
||
sel.removeAllRanges();
|
||
return;
|
||
}
|
||
// No popup yet: dragging across a name inside the narration text (e.g. a
|
||
// multi-word name like "Sharraz Garthai" the roster doesn't have) opens
|
||
// the popup pre-filled with the dragged text instead of doing nothing.
|
||
const anchorEl = sel.anchorNode.nodeType === 3 ? sel.anchorNode.parentNode : sel.anchorNode;
|
||
const txtSpan = anchorEl.closest('.ab-cv-txt');
|
||
if (!txtSpan) { _abJustHandledSelection = false; return; }
|
||
const row = txtSpan.closest('.ab-cv-row');
|
||
if (!row) { _abJustHandledSelection = false; return; }
|
||
_abOpenAssignPopup(row, anchorEl, text);
|
||
sel.removeAllRanges();
|
||
});
|
||
|
||
chars.addEventListener('click', e => {
|
||
const chip = e.target.closest('.ab-chip');
|
||
if (chip) {
|
||
let name = '';
|
||
for (const n of chip.childNodes) if (n.nodeType === 3) name += n.nodeValue;
|
||
name = name.trim();
|
||
|
||
if (assignModeSeg) {
|
||
if (name) assignName(name);
|
||
} else if (name) {
|
||
// Scroll to the next occurrence of this character in the feed
|
||
const rows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r => r.__seg && r.__seg.speaker === name);
|
||
if (!rows.length) return;
|
||
|
||
const currentY = feed.scrollTop;
|
||
let target = rows.find(r => (r.offsetTop - feed.offsetTop) > currentY + 10);
|
||
if (!target) target = rows[0]; // loop around to the top
|
||
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
|
||
// Highlight briefly
|
||
target.style.transition = 'background 0.3s';
|
||
target.style.background = 'var(--accent-hover, rgba(100, 150, 255, 0.2))';
|
||
setTimeout(() => {
|
||
if (target.parentNode) target.style.background = '';
|
||
}, 1000);
|
||
}
|
||
}
|
||
});
|
||
|
||
return {
|
||
// Refresh the sidebar roster from the FULL cast — recast/quality runs feed
|
||
// the view only the lines they check, which made the sidebar collapse to
|
||
// "Unknown <n>" as if all named characters had been lost.
|
||
recountRoster(allSegs) { _abRecountRoster(allSegs || []); },
|
||
rebuild(segs) {
|
||
this.clearProcessing();
|
||
feed.querySelector('.ab-skel-feed')?.remove();
|
||
chars.querySelector('.ab-skel-chars')?.remove();
|
||
_abRedrawSegments(segs || []);
|
||
},
|
||
update(done) {
|
||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||
if (castBar) castBar.hidden = false;
|
||
const pct = Math.round((done / total) * 100);
|
||
if (fill) fill.style.width = pct + '%';
|
||
if (count) { count.hidden = false; count.textContent = `passage ${done} / ${total}`; }
|
||
const fillText = panel.querySelector('#ab-cv-fill-text');
|
||
if (fillText) fillText.textContent = `${pct}% (Passage ${done} of ${total})`;
|
||
},
|
||
processing(text) {
|
||
if (this._procRow) this._procRow.remove();
|
||
this._thinkRaw = '';
|
||
this._thinkDone = false;
|
||
this._procRow = document.createElement('div');
|
||
this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row';
|
||
const isLong = text.length > 160;
|
||
const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : '');
|
||
// Split view while the LLM works: left = its live thinking stream (fed
|
||
// by view.thinking via the SSE endpoint), right = the passage it reads.
|
||
this._procRow.innerHTML = `
|
||
<div class="ab-cv-llm-head">
|
||
<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span>
|
||
${isLong ? '<button class="ab-cv-expand-btn" title="Show full passage"><span class="mdi mdi-chevron-down"></span></button>' : ''}
|
||
</div>
|
||
<div class="ab-cv-llm-preview">${preview}</div>
|
||
<div class="ab-cv-llm-split" hidden>
|
||
<div class="ab-cv-llm-col">
|
||
<div class="ab-cv-llm-col-label ab-cv-think-label"><span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…</div>
|
||
<pre class="ab-cv-think"></pre>
|
||
</div>
|
||
<div class="ab-cv-llm-col">
|
||
<div class="ab-cv-llm-col-label"><span class="mdi mdi-text-long"></span> Passage</div>
|
||
<pre class="ab-cv-llm-pre">${escHtml(text)}</pre>
|
||
</div>
|
||
</div>
|
||
${isLong ? `<div class="ab-cv-llm-full" hidden><pre class="ab-cv-llm-pre">${escHtml(text)}</pre></div>` : ''}
|
||
`;
|
||
if (isLong) {
|
||
this._procRow.querySelector('.ab-cv-expand-btn').addEventListener('click', function () {
|
||
const full = this.closest('.ab-cv-llm-row').querySelector('.ab-cv-llm-full');
|
||
full.hidden = !full.hidden;
|
||
const icon = this.querySelector('span');
|
||
if (icon) icon.className = full.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
|
||
});
|
||
}
|
||
_abPage().appendChild(this._procRow);
|
||
trim();
|
||
},
|
||
// Live LLM output for the passage currently processing. First delta swaps
|
||
// the plain preview for the split thinking/passage view; subsequent deltas
|
||
// append and keep the thinking pane scrolled to the newest text.
|
||
thinking(delta) {
|
||
if (!this._procRow || !delta || this._thinkDone) return;
|
||
this._thinkRaw = (this._thinkRaw || '') + delta;
|
||
const split = this._procRow.querySelector('.ab-cv-llm-split');
|
||
const think = this._procRow.querySelector('.ab-cv-think');
|
||
const label = this._procRow.querySelector('.ab-cv-think-label');
|
||
if (!split || !think) return;
|
||
if (split.hidden) {
|
||
split.hidden = false;
|
||
const prev = this._procRow.querySelector('.ab-cv-llm-preview');
|
||
if (prev) prev.hidden = true;
|
||
}
|
||
// Some models wrap real reasoning in <think>...</think>; others ignore
|
||
// that instruction and stream straight into the JSON answer. Show
|
||
// whichever is actually arriving — live progress beats nothing — but
|
||
// label it honestly instead of calling raw JSON "thinking".
|
||
const openAt = this._thinkRaw.indexOf('<think>');
|
||
let shown, isReal = false;
|
||
if (openAt >= 0) {
|
||
const afterOpen = this._thinkRaw.slice(openAt + '<think>'.length);
|
||
const closeAt = afterOpen.indexOf('</think>');
|
||
shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen;
|
||
isReal = true;
|
||
if (closeAt >= 0) this._thinkDone = true;
|
||
} else {
|
||
shown = this._thinkRaw;
|
||
}
|
||
if (label) label.innerHTML = isReal
|
||
? '<span class="mdi mdi-head-dots-horizontal-outline"></span> LLM Thinking…'
|
||
: '<span class="mdi mdi-code-json"></span> Live Output (raw — no reasoning exposed)';
|
||
think.textContent = shown.trim().slice(-20000);
|
||
think.scrollTop = think.scrollHeight;
|
||
if (closeAt >= 0) this._thinkDone = true;
|
||
},
|
||
clearProcessing() {
|
||
if (this._procRow) { this._procRow.remove(); this._procRow = null; }
|
||
},
|
||
addSegments(segs) {
|
||
this.clearProcessing();
|
||
feed.querySelector('.ab-skel-feed')?.remove();
|
||
chars.querySelector('.ab-skel-chars')?.remove();
|
||
|
||
const makeRow = (s) => {
|
||
const { speakerName } = _abRowSpeaker(s);
|
||
colorFor(speakerName);
|
||
roster.get(speakerName).count++;
|
||
return _abRowFromSegment(s);
|
||
};
|
||
|
||
const CHUNK = 80;
|
||
// Render first chunk immediately so the screen fills fast
|
||
const first = segs.slice(0, CHUNK);
|
||
const frag0 = document.createDocumentFragment();
|
||
first.forEach(s => frag0.appendChild(makeRow(s)));
|
||
_abPage().appendChild(frag0);
|
||
renderRoster();
|
||
|
||
// Stream remaining chunks without blocking the main thread
|
||
if (segs.length > CHUNK) {
|
||
let i = CHUNK;
|
||
const next = () => {
|
||
if (i >= segs.length) { trim(); renderRoster(); return; }
|
||
const batch = document.createDocumentFragment();
|
||
const end = Math.min(i + CHUNK, segs.length);
|
||
for (; i < end; i++) batch.appendChild(makeRow(segs[i]));
|
||
_abPage().appendChild(batch);
|
||
renderRoster();
|
||
setTimeout(next, 0);
|
||
};
|
||
setTimeout(next, 0);
|
||
} else {
|
||
trim();
|
||
}
|
||
},
|
||
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; _abPage().appendChild(r); trim(); },
|
||
// Visual gap marking that the passages before/after are not contiguous.
|
||
// prevIdx / nextIdx are indices into _audiobook.segments for the gap boundaries.
|
||
// Click expands the divider to show all segments in the gap inline.
|
||
divider(prevIdx, nextIdx) {
|
||
this.clearProcessing();
|
||
const r = document.createElement('div');
|
||
r.className = 'ab-cv-divider ab-cv-divider-expand';
|
||
const gapFrom = Math.max(0, (prevIdx >= 0 ? prevIdx + 1 : 0));
|
||
const segsAll = _audiobook.segments || [];
|
||
const gapTo = Math.min(segsAll.length - 1, (nextIdx >= 0 ? nextIdx - 1 : segsAll.length - 1));
|
||
const gapCount = Math.max(0, gapTo - gapFrom + 1);
|
||
r.innerHTML = `⋯ <span class="ab-cv-divider-hint">${gapCount > 0 ? gapCount + ' line' + (gapCount !== 1 ? 's' : '') + ' hidden — ' : ''}click to expand</span>`;
|
||
r.title = 'Click to reveal the text between these passages';
|
||
r.addEventListener('click', () => {
|
||
const gap = segsAll.slice(gapFrom, gapTo + 1);
|
||
if (!gap.length) { r.remove(); return; }
|
||
const PEEK = 20;
|
||
const show = gap.slice(0, PEEK);
|
||
const rest = gap.slice(PEEK);
|
||
const frag = document.createDocumentFragment();
|
||
for (const s of show) {
|
||
const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||
const spk = isNarr ? 'Narrator' : s.speaker;
|
||
const c = colorFor(spk);
|
||
const row = document.createElement('div');
|
||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||
row.__seg = s;
|
||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||
frag.appendChild(row);
|
||
}
|
||
if (rest.length) {
|
||
// Insert a new collapsed divider for the remaining lines
|
||
const next = document.createElement('div');
|
||
next.className = 'ab-cv-divider ab-cv-divider-expand';
|
||
next.innerHTML = `⋯ <span class="ab-cv-divider-hint">${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand</span>`;
|
||
next.title = 'Click to reveal more';
|
||
const restSegs = rest; // closure
|
||
next.addEventListener('click', function onClick() {
|
||
const f2 = document.createDocumentFragment();
|
||
for (const s of restSegs) {
|
||
const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||
const spk = isNarr ? 'Narrator' : s.speaker;
|
||
const c = colorFor(spk);
|
||
const row = document.createElement('div');
|
||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||
row.__seg = s;
|
||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||
f2.appendChild(row);
|
||
}
|
||
next.replaceWith(f2);
|
||
});
|
||
frag.appendChild(next);
|
||
}
|
||
r.replaceWith(frag);
|
||
});
|
||
_abPage().appendChild(r); trim();
|
||
},
|
||
// Page-break marker in the casting feed (from source PDF page boundaries).
|
||
// Starts a new "paper" card so each source page reads as its own block.
|
||
pagemark(pageNum) {
|
||
this.clearProcessing();
|
||
_abNewPage('Page ' + pageNum, pageNum);
|
||
trim();
|
||
},
|
||
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||
// the preview — so it waits for you if you wandered off to do something else.
|
||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||
if (count) count.hidden = true;
|
||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||
if (castBar) castBar.hidden = true;
|
||
const completedSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length
|
||
? _audiobook.segments
|
||
: (typeof allSegments !== 'undefined' && allSegments.length ? allSegments : null);
|
||
if (completedSegments) _abRedrawSegments(completedSegments);
|
||
const cancelBtn = panel.querySelector('#ab-cv-cancel');
|
||
if (cancelBtn) cancelBtn.hidden = true;
|
||
const foot = panel.querySelector('#ab-cv-foot');
|
||
foot.hidden = false;
|
||
// The "N characters · M segments" summary lives under the character
|
||
// sidebar (not the action-button footer) so it reads next to the list
|
||
// it's summarising.
|
||
const sideEl = panel.querySelector('.ab-cv-side');
|
||
if (sideEl) {
|
||
let doneEl = sideEl.querySelector('.ab-cv-side-done');
|
||
if (!doneEl) {
|
||
doneEl = document.createElement('div');
|
||
doneEl.className = 'ab-cv-side-done';
|
||
sideEl.appendChild(doneEl);
|
||
}
|
||
doneEl.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span>`;
|
||
}
|
||
// An interrupted cast (crash/reload) leaves completedChunks < completedTotal —
|
||
// offer to pick up from there instead of only full/partial recasts.
|
||
const resumable = _audiobook.completedChunks > 0 && _audiobook.completedTotal > 0
|
||
&& _audiobook.completedChunks < _audiobook.completedTotal;
|
||
const continueBtnHtml = resumable
|
||
? `<button class="btn-primary btn-sm" id="ab-cv-continue" style="margin-right:8px;" title="Continue casting from passage ${_audiobook.completedChunks + 1} of ${_audiobook.completedTotal}, keeping already-cast passages"><span class="mdi mdi-play-circle-outline"></span> Continue casting</button>`
|
||
: '';
|
||
foot.innerHTML = `<span style="flex:1"></span>${continueBtnHtml}<button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality run — resolves Unknown speakers with stricter context checks while preserving the existing cast"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Generate character sheets and match voices to each found character"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-secondary btn-sm" id="ab-cv-export-md" style="margin-right:8px;" title="Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character"><span class="mdi mdi-folder-zip-outline"></span> Export cast .zip</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Assign voices to each character and synthesise in Script Rehearser"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>`;
|
||
|
||
const runVerificationPass = () => {
|
||
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt. Deine Aufgabe ist es, unbekannte Sprecher zu lösen und falsche Unknown/Narrator-Zuweisungen zu korrigieren, ohne bereits klare Sprecher unnötig zu verändern.
|
||
|
||
AUFGABE (2. Qualitätslauf):
|
||
1. LÖSE 'Unknown'-Segmente auf: Nutze umgebenden Kontext, Inquit-Formeln wie "sagte X", "fragte sie", "rief er", Handlungsbeschreibungen, Reihenfolge der Sprecher, Ping-Pong-Wechsel in Dialogen und bekannte Figuren.
|
||
2. KORRIGIERE ein Segment zu 'Narrator' nur dann, wenn es eindeutig Erzählertext, Handlung, Beschreibung oder ein Sprecher-Tag ist.
|
||
3. BEHALTE vorhandene klare Sprecher-Zuweisungen im Kontext bei. Nutze sie als Anker für die Unknown-Zeilen.
|
||
4. 'Unknown' ist NUR erlaubt, wenn der Sprecher trotz Kontext absolut nicht bestimmbar ist.
|
||
|
||
DEDUKTIONS-WERKZEUGE (wende sie in dieser Reihenfolge an):
|
||
- Doppelpunkt-Regel: Endet der Erzählersatz vor dem Zitat mit ":", spricht dessen Subjekt das Zitat ("Dann richtete er sich auf und rief in die Runde:" → der zuvor genannte Charakter; "Ein anderer fragte verschlafen:" → dieser andere).
|
||
- Nachgestellte Zuordnung: Der Erzählersatz NACH dem Zitat verrät den Sprecher — auch bei unpersönlicher Formel ("»Was machst du denn da?« ertönte es über ihm. Karyla hatte ihren Hammer weggelegt und war herübergekommen." → Karyla sprach).
|
||
- Pronomen-Auflösung: er/sie/es in Inquit-Formeln und Action Beats meint die zuletzt genannte Person passenden Geschlechts ("Mit einem Stoß schob sie Uriens zur Seite" → sie = die zuletzt genannte Frau, und die umliegenden Zitate sind ihre).
|
||
- Adressaten-Regel: "X wandte sich an Y" → X spricht das nächste Zitat, Y ist der wahrscheinlichste Antwortende.
|
||
- Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab — auch über viele Zitate ohne Tags hinweg. Verfolge die Kette zur letzten eindeutigen Nennung zurück und führe sie fort. In einer Zwei-Personen-Szene ist 'Unknown' fast immer falsch.
|
||
- Rollenbezeichnungen sind gültige Sprecher — nutze sie statt 'Unknown' (z.B. 'Ork', 'Der Fremde', 'Nachbar', 'Wächter', 'Junge').
|
||
|
||
DIALOG-ERKENNUNG BEI PDF/OCR-TEXTEN:
|
||
Viele PDF-Extraktionen verlieren Anführungszeichen oder Guillemets. Ein kurzer Satz kann also trotzdem Dialog sein, auch wenn »...« oder „..." im übergebenen Segment fehlen. Entscheide nach Satzform, Antwortstruktur, Sprecherwechsel, Inquit-Formeln und Szene. Markiere eine Zeile NICHT allein deshalb als Narration, weil sichtbare Anführungszeichen fehlen.
|
||
|
||
ABSATZ- UND KAPITELSTRUKTUR:
|
||
Eine Leerzeile markiert einen Absatzwechsel oder Kapitel-/Szenenanfang. Eine sehr kurze, alleinstehende Zeile vor einer Leerzeile ist eine Kapitelüberschrift — 'narration'/'Narrator', niemals Dialog.
|
||
|
||
GRAMMATIK-CHECK FÜR NARRATION:
|
||
- Inquit-Formeln / Sprecher-Tags sind narration, niemals dialogue: finite Sprechverben wie sagte, fragte, rief, entgegnete, murmelte, flüsterte, schrie, antwortete + Subjekt/Pronomen/Name.
|
||
- Beispiele: "murmelte er mit erstickter Stimme.", ", entgegnete Marcian kalt.", "fragte Uriens leise." sind Narrator/narration.
|
||
- Action Beats sind narration: blickte, ging, schwieg, lachte, hob die Hand, wandte sich ab, usw.
|
||
- Nur die tatsächlich gesprochenen Wörter innerhalb der Anführungszeichen bleiben dialogue; alle grammatischen Rahmen- und Berichtssätze sind narration.
|
||
|
||
FÜR JEDES SEGMENT AUSGABE:
|
||
- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters.
|
||
- type: 'narration' oder 'dialogue'
|
||
- text: EXAKT der WORTWÖRTLICHE Originaltext — KEINE Änderungen, KEINE Auslassungen, KEINE Ergänzungen.
|
||
- emotion: Bei Dialogen 1-2 deutsche Wörter für den Tonfall. Bei Narration leer ('').
|
||
|
||
ABSOLUTE REGELN:
|
||
- Alle Segmente zusammen MÜSSEN den Originaltext exakt, lückenlos und wortgetreu rekonstruieren.
|
||
- PDF-/OCR-SCHUTZ: Ein fehlendes » oder « darf niemals bewirken, dass die restliche Passage als Dialog markiert wird. Bei einem offenen »-Zitat vor einer Inquit-Formel oder Erzählerhandlung endet der Dialog am ersten plausiblen Satzende (? ! .). Bei einem einzelnen schließenden « nach einem kurzen Satz ist dieser Satz davor der Dialog.
|
||
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
|
||
- Mische NIEMALS Narration und Dialog in einem Segment.`;
|
||
const choice = audiobookCurrentCastLlm(panel);
|
||
const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
|
||
closePanel();
|
||
audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt });
|
||
};
|
||
|
||
foot.querySelector('#ab-cv-verify').addEventListener('click', runVerificationPass);
|
||
foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => {
|
||
closePanel();
|
||
await audiobookOpenCurrentInRehearser();
|
||
});
|
||
foot.querySelector('#ab-cv-export-md')?.addEventListener('click', audiobookExportCastMd);
|
||
|
||
const applyPromptAndRun = (callback) => {
|
||
const newPrompt = panel.querySelector('#ab-cv-prompt-text').value;
|
||
const choice = audiobookCurrentCastLlm(panel);
|
||
const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
|
||
|
||
if (typeof _appSettings !== 'undefined') _appSettings.audiobook_prompt = newPrompt;
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ audiobook_prompt: newPrompt }) })
|
||
.finally(() => {
|
||
closePanel();
|
||
if (callback) callback(savedChoice.url, savedChoice.model);
|
||
});
|
||
};
|
||
|
||
foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast));
|
||
foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
|
||
foot.querySelector('#ab-cv-continue')?.addEventListener('click', () => applyPromptAndRun((u, m) => audiobookCast(u, m, {
|
||
startIndex: _audiobook.completedChunks,
|
||
segments: _audiobook.segments,
|
||
roster: _audiobook.roster,
|
||
narrationOnly: _audiobook.narratedPassages,
|
||
degraded: _audiobook.degraded
|
||
})));
|
||
foot.querySelector('#ab-cv-cast-chars').addEventListener('click', () => {
|
||
if (typeof window.csForReader === 'function') window.csForReader();
|
||
else if (typeof csForReader === 'function') csForReader();
|
||
else toast('Character sheets not loaded yet', 'error');
|
||
});
|
||
panel.classList.add('ab-castpanel-done');
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||
},
|
||
done(options = {}) {
|
||
const stopped = options.stopped || _audiobook.cancel;
|
||
if (!stopped) { closePanel(); return; }
|
||
|
||
this.clearProcessing();
|
||
if (count) count.hidden = true;
|
||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||
if (castBar) castBar.hidden = true;
|
||
feed.querySelector('.ab-skel-feed')?.remove();
|
||
chars.querySelector('.ab-skel-chars')?.remove();
|
||
|
||
const message = options.message || 'Casting stopped before any passage completed.';
|
||
if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-page')) {
|
||
feed.innerHTML = ''; _abCurPage = null;
|
||
const r = document.createElement('div');
|
||
r.className = 'ab-cv-note';
|
||
r.textContent = message;
|
||
feed.appendChild(r);
|
||
} else if (options.message) {
|
||
const r = document.createElement('div');
|
||
r.className = 'ab-cv-note';
|
||
r.textContent = message;
|
||
feed.appendChild(r);
|
||
}
|
||
if (!chars.querySelector('.ab-char-item')) {
|
||
chars.innerHTML = '<span class="ab-cv-empty">No completed characters yet.</span>';
|
||
}
|
||
|
||
const foot = panel.querySelector('#ab-cv-foot');
|
||
const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0;
|
||
foot.hidden = false;
|
||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-stop-circle-outline"></span> ${escHtml(message)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-stopped-back" style="margin-right:8px;"><span class="mdi mdi-arrow-left"></span> Back to reader</button>${hasSegments ? '<button class="btn-secondary btn-sm" id="ab-cv-stopped-review" style="margin-right:8px;"><span class="mdi mdi-drama-masks"></span> Edit Characters</button>' : ''}<button class="btn-primary btn-sm" id="ab-cv-stopped-recast"><span class="mdi mdi-refresh"></span> Cast again</button>`;
|
||
|
||
foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel);
|
||
foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', async () => {
|
||
closePanel();
|
||
await audiobookOpenCurrentInRehearser();
|
||
});
|
||
foot.querySelector('#ab-cv-stopped-recast')?.addEventListener('click', () => {
|
||
const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value;
|
||
const currentChoice = audiobookCurrentCastLlm(panel);
|
||
const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model);
|
||
if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt;
|
||
fetch('/api/settings', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ audiobook_prompt: newPrompt || '' })
|
||
}).finally(() => {
|
||
closePanel();
|
||
audiobookCast(choice.url, choice.model);
|
||
});
|
||
});
|
||
|
||
panel.classList.add('ab-castpanel-done');
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||
},
|
||
// Show fresh "Ready to cast" state (used when server draft fetch returns empty)
|
||
loadingRestore() {
|
||
feed.querySelector('.ab-skel-feed')?.remove();
|
||
chars.querySelector('.ab-skel-chars')?.remove();
|
||
feed.innerHTML = '';
|
||
_abCurPage = null;
|
||
const r = document.createElement('div');
|
||
r.className = 'ab-cv-note';
|
||
r.textContent = 'Loading saved cast…';
|
||
feed.appendChild(r);
|
||
chars.innerHTML = '<span class="ab-cv-empty">restoring…</span>';
|
||
const statusMsg = panel.querySelector('#ab-cv-status-msg');
|
||
if (statusMsg) {
|
||
statusMsg.style.display = 'inline-block';
|
||
statusMsg.textContent = 'Checking saved cast before starting a new one.';
|
||
}
|
||
const castBtn = panel.querySelector('#ab-cv-start-cast');
|
||
if (castBtn) castBtn.style.display = 'none';
|
||
},
|
||
setFreshState(message = 'Ready to cast.') {
|
||
feed.querySelector('.ab-skel-feed')?.remove();
|
||
chars.querySelector('.ab-skel-chars')?.remove();
|
||
if (!feed.querySelector('.ab-cv-row, .ab-cv-divider, .ab-cv-page')) {
|
||
feed.innerHTML = '';
|
||
_abCurPage = null;
|
||
const r = document.createElement('div');
|
||
r.className = 'ab-cv-note';
|
||
r.textContent = message;
|
||
feed.appendChild(r);
|
||
}
|
||
if (!chars.querySelector('.ab-char-item')) chars.innerHTML = '<span class="ab-cv-empty">No saved cast found.</span>';
|
||
const statusMsg = panel.querySelector('#ab-cv-status-msg');
|
||
if (statusMsg) {
|
||
statusMsg.style.display = 'inline-block';
|
||
statusMsg.textContent = message;
|
||
}
|
||
const castBtn = panel.querySelector('#ab-cv-start-cast');
|
||
if (castBtn) {
|
||
castBtn.style.display = 'inline-block';
|
||
castBtn.addEventListener('click', () => {
|
||
const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value;
|
||
const currentChoice = audiobookCurrentCastLlm(panel);
|
||
const choice = audiobookSaveLlmChoice(currentChoice.url, currentChoice.model);
|
||
if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt;
|
||
audiobookCast(choice.url, choice.model);
|
||
});
|
||
}
|
||
},
|
||
};
|
||
}
|
||
|
||
async function audiobookRecastUnknown(overrideUrl, overrideModel, options = {}) {
|
||
if (_audiobook.running) return;
|
||
const segs = _audiobook.segments;
|
||
if (!segs || !segs.length) return;
|
||
const originalSegments = segs.map(s => ({...s}));
|
||
const countUnknownDialogue = arr => (arr || []).filter(s => s?.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker))).length;
|
||
const beforeUnknownCount = countUnknownDialogue(segs);
|
||
|
||
// Mechanical cases first (colon rule, post-quote inquit) — resolved in code
|
||
// for free, so they never even reach the LLM queue below.
|
||
const preResolved = audiobookResolveUnknowns(segs, [], _audiobook.roster || []);
|
||
if (preResolved.length) toast(`${preResolved.length} Unknown line${preResolved.length !== 1 ? 's' : ''} resolved by grammar rules`, 'success');
|
||
|
||
const unknownIdxs = [];
|
||
for (let i = 0; i < segs.length; i++) {
|
||
if (segs[i].type === 'dialogue' && (!segs[i].speaker || /^Unknown|Unbekannt/i.test(segs[i].speaker))) {
|
||
unknownIdxs.push(i);
|
||
}
|
||
}
|
||
if (!unknownIdxs.length) {
|
||
toast('No Unknown speakers found', 'success');
|
||
audiobookShowPreview();
|
||
return;
|
||
}
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
|
||
const ac = new AbortController(); _audiobook.abort = () => ac.abort();
|
||
|
||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang();
|
||
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||
const promptOverride = typeof options.prompt === 'string' ? options.prompt : null;
|
||
|
||
const groups = audiobookRecastGroups(unknownIdxs, segs);
|
||
const view = audiobookCastView(unknownIdxs.length, llm_url, model);
|
||
view.recountRoster(segs); // sidebar shows the whole cast's roster, not just the lines being checked
|
||
|
||
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
|
||
try {
|
||
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
|
||
body: JSON.stringify({
|
||
text: 'Wake up.', known_characters: [], recent: '', language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
|
||
})
|
||
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
view.done({ stopped: true, message: 'Recast stopped before any lines were updated.' });
|
||
toast('Recast stopped. Existing cast preserved.', 'info');
|
||
return;
|
||
}
|
||
}
|
||
|
||
let done = 0;
|
||
let prevIdx = -2;
|
||
let groupsSinceSave = 0;
|
||
const pendingReplacements = new Map();
|
||
const normalizeRecastSegment = (seg, fallback) => {
|
||
const type = seg?.type === 'narration' ? 'narration' : 'dialogue';
|
||
const speaker = type === 'narration' ? 'Narrator' : (seg?.speaker || fallback?.speaker || 'Unknown');
|
||
return {
|
||
speaker,
|
||
type,
|
||
emotion: type === 'dialogue' ? (seg?.emotion || fallback?.emotion || '') : '',
|
||
text: seg?.text || fallback?.text || ''
|
||
};
|
||
};
|
||
try {
|
||
for (let group of groups) {
|
||
if (_audiobook.cancel) break;
|
||
|
||
const unresolvedGroup = [];
|
||
for (const idx of group) {
|
||
if (audiobookIsSpeechTagOnly(segs[idx]?.text)) {
|
||
segs[idx].type = 'narration';
|
||
segs[idx].speaker = 'Narrator';
|
||
segs[idx].emotion = '';
|
||
} else {
|
||
unresolvedGroup.push(idx);
|
||
}
|
||
}
|
||
if (!unresolvedGroup.length) {
|
||
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
|
||
prevIdx = group[group.length - 1];
|
||
view.update(done, `Correcting narration tags ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`);
|
||
for (const idx of group) {
|
||
view.addSegments([segs[idx]]);
|
||
done++;
|
||
}
|
||
continue;
|
||
}
|
||
group = unresolvedGroup;
|
||
|
||
// These Unknown lines come from scattered places in the document. Mark a
|
||
// gap with "⋯" whenever this passage isn't directly after the previous one.
|
||
if (group[0] !== prevIdx + 1) view.divider(prevIdx, group[0]);
|
||
prevIdx = group[group.length - 1];
|
||
|
||
const recastCtx = audiobookRecastContext(segs, group);
|
||
const passageText = recastCtx.text;
|
||
const lineLabel = group.length > 1
|
||
? `Attributing lines ${done + 1}-${done + group.length} / ${unknownIdxs.length}…`
|
||
: `Attributing line ${done + 1} / ${unknownIdxs.length}…`;
|
||
|
||
view.update(done, lineLabel);
|
||
view.processing(passageText.trim());
|
||
|
||
let data = null;
|
||
const recastBody = audiobookAttributeBody({
|
||
text: passageText.trim(),
|
||
known_characters: _audiobook.roster.slice(-40),
|
||
recent: recastCtx.recent || '',
|
||
language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_RECAST_TIMEOUT_MS)
|
||
}, promptOverride);
|
||
try {
|
||
// Streaming first (live thinking view); blocking endpoint as fallback.
|
||
try {
|
||
data = await audiobookAttributeStream(recastBody, view, ac.signal, AUDIOBOOK_RECAST_TIMEOUT_MS);
|
||
} catch (streamErr) {
|
||
if (streamErr.name === 'AbortError') throw streamErr;
|
||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
signal: ac.signal,
|
||
body: JSON.stringify(recastBody)
|
||
}, AUDIOBOOK_RECAST_TIMEOUT_MS + 5000);
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||
data = await r.json();
|
||
}
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
|
||
// statusText is empty on HTTP/2, which used to leave this note blank after the colon
|
||
view.note(`API Error while checking ${group.length > 1 ? 'a group of Unknown lines' : 'Unknown line'}: ${err.message || err}`);
|
||
for (const idx of group) {
|
||
view.addSegments([segs[idx]]);
|
||
done++;
|
||
}
|
||
continue;
|
||
}
|
||
if (data && data.segments) {
|
||
const used = new Set();
|
||
for (const idx of group) {
|
||
const targetSeg = segs[idx];
|
||
const seqMatch = audiobookFindReturnedSegmentSequence(targetSeg, data.segments, used);
|
||
if (seqMatch && seqMatch.segs.length > 1) {
|
||
const repl = seqMatch.segs.map(s => normalizeRecastSegment(s, targetSeg)).filter(s => (s.text || '').trim());
|
||
const hasResolved = repl.some(s => s.type === 'narration' || (s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker)));
|
||
const hasUnresolvedDialogue = repl.some(s => s.type === 'dialogue' && (!s.speaker || /^Unknown|Unbekannt/i.test(s.speaker)));
|
||
if (repl.length && hasResolved && !hasUnresolvedDialogue) {
|
||
seqMatch.idxs.forEach(i => used.add(i));
|
||
pendingReplacements.set(idx, repl);
|
||
repl.forEach(s => {
|
||
if (s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker) && !_audiobook.roster.includes(s.speaker)) _audiobook.roster.push(s.speaker);
|
||
});
|
||
continue;
|
||
}
|
||
}
|
||
const match = audiobookFindReturnedSegment(targetSeg, data.segments, used);
|
||
if (match && match.seg.speaker) {
|
||
const speaker = match.seg.type === 'narration' ? 'Narrator' : match.seg.speaker;
|
||
if (!speaker || /^Unknown|Unbekannt/i.test(speaker)) continue;
|
||
used.add(match.idx);
|
||
targetSeg.type = match.seg.type === 'narration' ? 'narration' : 'dialogue';
|
||
targetSeg.speaker = speaker;
|
||
targetSeg.emotion = targetSeg.type === 'dialogue' ? (match.seg.emotion || targetSeg.emotion || '') : '';
|
||
if (targetSeg.type === 'dialogue' && !_audiobook.roster.includes(speaker)) _audiobook.roster.push(speaker);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const idx of group) {
|
||
view.addSegments(pendingReplacements.get(idx) || [segs[idx]]);
|
||
done++;
|
||
}
|
||
groupsSinceSave++;
|
||
if (groupsSinceSave >= 10 && _audiobook.lastText) {
|
||
groupsSinceSave = 0;
|
||
_abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
|
||
view.recountRoster(segs); // keep the sidebar reflecting the full cast as lines resolve
|
||
}
|
||
}
|
||
view.update(_audiobook.cancel ? done : unknownIdxs.length);
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message);
|
||
} finally {
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
}
|
||
if (pendingReplacements.size) {
|
||
[...pendingReplacements.entries()]
|
||
.sort((a, b) => b[0] - a[0])
|
||
.forEach(([idx, repl]) => segs.splice(idx, 1, ...repl));
|
||
}
|
||
|
||
const afterUnknownCount = countUnknownDialogue(segs);
|
||
if (!_audiobook.cancel && afterUnknownCount > beforeUnknownCount) {
|
||
segs.splice(0, segs.length, ...originalSegments);
|
||
view.note(`Quality run rolled back: Unknown segments increased from ${beforeUnknownCount} to ${afterUnknownCount}. Existing cast preserved.`);
|
||
toast('Quality run rolled back because it increased Unknown speakers.', 'error');
|
||
}
|
||
|
||
// Recast-unknown only fixes speakers on already-cast segments — it never adds
|
||
// new passages, so the original cast's real done/total must carry through
|
||
// unchanged rather than being overwritten with a fake "100% done" value.
|
||
const _rcDone = _audiobook.completedChunks || segs.length;
|
||
const _rcTotal = _audiobook.completedTotal || _rcDone;
|
||
|
||
if (_audiobook.cancel) {
|
||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||
view.done({ stopped: true, message: 'Recast stopped. Existing cast preserved.' });
|
||
toast('Recast stopped. Existing cast preserved.', 'info');
|
||
return;
|
||
}
|
||
|
||
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _rcDone, _rcTotal);
|
||
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
}
|
||
|
||
async function audiobookOpenCastView() {
|
||
if (_audiobook.running) return;
|
||
const text = audiobookScopeText();
|
||
if (!text) { toast('Import a document first', 'error'); return; }
|
||
|
||
// Bind this casting session to the open library book so its draft is restored
|
||
// by identity on reopen. Clearing the in-memory cast when the book changes
|
||
// prevents a stale in-memory cast from masking the correct per-book draft.
|
||
const _curBook = (window.readerState && readerState.savedId) || null;
|
||
if (_curBook && _audiobook.bookId && _audiobook.bookId !== _curBook) {
|
||
_audiobook.segments = null; _audiobook.lastText = null; _audiobook.roster = null;
|
||
}
|
||
_audiobook.bookId = _curBook;
|
||
|
||
const llm_url = audiobookLlmUrl();
|
||
const model = audiobookLlmModel();
|
||
|
||
const chunks = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||
: [text];
|
||
|
||
// 1. In-memory restore — user navigated away and came back in the same session
|
||
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
view.rebuild(_audiobook.segments);
|
||
const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
return;
|
||
}
|
||
|
||
// Helper: apply a draft object into the view
|
||
const _applyDraft = (draft, view, source) => {
|
||
const draftDone = Number.isFinite(Number(draft.done)) ? Number(draft.done) : 0;
|
||
const draftTotal = Number.isFinite(Number(draft.total)) ? Number(draft.total) : 0;
|
||
_abStampSegmentPages(draft.segments, draft.pageMarks, text); // pre-.page drafts: stamp once so the renderer stays single-path
|
||
_audiobook.segments = draft.segments;
|
||
_audiobook.roster = draft.roster || [];
|
||
_audiobook.lastText = text;
|
||
_audiobook.pageMarks = draft.pageMarks || [];
|
||
_audiobook.rehId = draft.rehId || null;
|
||
// Always keep the raw progress the original cast reached, even once it's
|
||
// finished or a later edit re-saves the draft — later saves (manual speaker
|
||
// fixes, recast-unknown) must not clobber this with a "looks 100%" sentinel,
|
||
// or an interrupted cast becomes silently unresumable and misreported as done.
|
||
_audiobook.completedChunks = draftDone > 0 ? draftDone : 0;
|
||
_audiobook.completedTotal = draftTotal > 0 ? draftTotal : 0;
|
||
view.rebuild(draft.segments);
|
||
const ageMs = Date.now() - (draft.savedAt || 0);
|
||
const ageMins = Math.round(ageMs / 60000);
|
||
const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`;
|
||
const pct = draftTotal > 0 ? Math.max(0, Math.min(100, Math.round((draftDone / draftTotal) * 100))) : 100;
|
||
const wasDone = draftTotal > 0 && draftDone >= draftTotal;
|
||
const canContinue = draftDone > 0 && draftTotal > 0 && draftDone < draftTotal;
|
||
const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt';
|
||
view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${canContinue ? ' Casting wurde unterbrochen — „Continue casting" setzt an Passage ' + (draftDone + 1) + ' fort, „Recast all" für komplette Neuauswertung.' : (!wasDone ? ' Fortschritt wurde gesichert.' : '')}`);
|
||
const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
};
|
||
|
||
// 2. localStorage draft restore — fastest, no network
|
||
const _localDraft = _abLoadDraft(text);
|
||
if (_localDraft && _localDraft.segments && _localDraft.segments.length > 0) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
_applyDraft(_localDraft, view, 'local');
|
||
// Backfill server in case it's missing this draft
|
||
if (_abBookId()) {
|
||
const serverCopy = {
|
||
..._localDraft,
|
||
bookId: _abBookId(),
|
||
title: window.readerState?.title || _localDraft.title || '',
|
||
};
|
||
fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`, {
|
||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify(serverCopy)
|
||
}).catch(() => {});
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 3. Server draft restore — for other browsers/devices or cleared localStorage
|
||
const bookId = _abBookId();
|
||
if (bookId) {
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
view.loadingRestore();
|
||
try {
|
||
const serverDraft = await _abLoadDraftServer(bookId);
|
||
if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) {
|
||
// Cache locally so next open is instant
|
||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {}
|
||
_applyDraft(serverDraft, view, 'server');
|
||
} else {
|
||
view.setFreshState('No saved cast was found for this saved book. Starting a new cast will create a new draft.');
|
||
}
|
||
} catch (_) {
|
||
view.setFreshState('Saved cast could not be checked. Browser autosave was already searched; starting a new cast will create a new draft.');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 4. Fresh start (no book ID, no draft)
|
||
audiobookCastView(chunks.length, llm_url, model, true);
|
||
}
|
||
|
||
async function audiobookCast(overrideUrl, overrideModel, resume) {
|
||
if (_audiobook.running) return;
|
||
const text = audiobookScopeText();
|
||
if (!text) { toast('Import a document first', 'error'); return; }
|
||
if (typeof parseScript !== 'function') { toast('Rehearser not loaded yet — try again in a moment', 'error'); return; }
|
||
|
||
_audiobook.running = true;
|
||
const hadServerAutosaveTarget = !!_abBookId();
|
||
if (!hadServerAutosaveTarget) {
|
||
const ready = await _abEnsureLibraryBook();
|
||
if (!ready && typeof toast === 'function') {
|
||
toast('Autosave will use this browser only until the book is saved to the library.', 'info');
|
||
}
|
||
}
|
||
|
||
const chunks = (typeof splitTextIntoChunks === 'function')
|
||
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
|
||
: [text];
|
||
|
||
// Resuming an interrupted cast: pick up at the passage the autosave stopped
|
||
// on instead of reprocessing everything from passage 1. Chunking is a pure
|
||
// function of the text, so the same text always re-splits into the same
|
||
// chunk boundaries, making the saved passage index safe to resume at.
|
||
const startIndex = (resume && resume.startIndex > 0 && resume.startIndex < chunks.length) ? resume.startIndex : 0;
|
||
const isResume = startIndex > 0;
|
||
|
||
_audiobook.cancel = false;
|
||
if (!isResume) {
|
||
_audiobook.rehId = null; // fresh cast creates a new rehearsal record
|
||
_abClearDraft();
|
||
}
|
||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
|
||
const ac = new AbortController();
|
||
_audiobook.abort = () => ac.abort();
|
||
|
||
if (overrideUrl && typeof overrideUrl !== 'string') overrideUrl = null;
|
||
const llm_url = overrideUrl || audiobookLlmUrl(), language = audiobookLang(text);
|
||
let model = audiobookSafeLlmModel(overrideModel || audiobookLlmModel());
|
||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||
if (isResume && resume.segments && resume.segments.length) view.rebuild(resume.segments);
|
||
|
||
view.processing('Waking up LLM model (this may take a few minutes if cold-booting)…');
|
||
try {
|
||
await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: ac.signal,
|
||
body: JSON.stringify({
|
||
text: 'Wake up.', known_characters: [], recent: '', language,
|
||
llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url,
|
||
model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model),
|
||
timeout_seconds: audiobookTimeoutSeconds(AUDIOBOOK_WARMUP_TIMEOUT_MS)
|
||
})
|
||
}, AUDIOBOOK_WARMUP_TIMEOUT_MS + 5000);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
|
||
toast('Casting stopped before any passages were saved.', 'info');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const allSegments = isResume ? resume.segments.slice() : [];
|
||
_audiobook.liveSegments = allSegments; // expose so split works during casting
|
||
const roster = isResume ? (resume.roster || []).slice() : [];
|
||
let narrationOnly = isResume ? (resume.narrationOnly || 0) : 0; // passages with no quotes at all — legitimately all narration
|
||
let degraded = isResume ? (resume.degraded || 0) : 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
|
||
let completedChunks = startIndex;
|
||
const saveCheckpoint = () => {
|
||
if (allSegments.length) _abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
};
|
||
_abStartDraftAutosave(saveCheckpoint);
|
||
// Page-mark tracking for visual page-break rows in the casting feed. Segments
|
||
// are stamped with the resolved page number as they're created (see _curPageNum
|
||
// below) so the feed can be redrawn later purely from that stored field instead
|
||
// of re-guessing offsets from text — re-guessing is what used to silently merge
|
||
// pages after navigating away from the casting panel and back.
|
||
const _pgMarks = (_audiobook.pageMarks || []).slice();
|
||
if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0
|
||
let _pgMarkIdx = 0, _pgCharPos = 0, _curPageNum = 1;
|
||
if (isResume) {
|
||
// Fast-forward the page-mark cursor past the passages already cast so
|
||
// page-break rows aren't re-emitted into the feed on resume.
|
||
for (let k = 0; k < startIndex; k++) _pgCharPos += chunks[k].length + 1;
|
||
while (_pgMarkIdx < _pgMarks.length && _pgMarks[_pgMarkIdx].offset <= _pgCharPos) _pgMarkIdx++;
|
||
if (_pgMarkIdx > 0) _curPageNum = _pgMarks[_pgMarkIdx - 1].page + 1;
|
||
}
|
||
try {
|
||
for (let i = startIndex; i < chunks.length; i++) {
|
||
if (_audiobook.cancel) break;
|
||
view.update(i);
|
||
// Insert page-break markers in the feed when the source PDF page changes.
|
||
while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) {
|
||
_curPageNum = _pgMarks[_pgMarkIdx].page + 1;
|
||
view.pagemark(_curPageNum);
|
||
_pgMarkIdx++;
|
||
}
|
||
_pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks
|
||
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
|
||
if (!audiobookHasDialogue(chunks[i])) {
|
||
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '', page: _curPageNum };
|
||
allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
|
||
completedChunks = i + 1;
|
||
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
continue;
|
||
}
|
||
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary
|
||
const recent = allSegments.filter(s => s.type === 'dialogue' && s.speaker && !/^Unknown|Unbekannt/i.test(s.speaker))
|
||
.slice(-6).map(s => `${s.speaker}: ${(s.text || '').slice(0, 80)}`).join('\n');
|
||
|
||
view.processing(chunks[i]);
|
||
// ── Helper: run one attribution call and return parsed segments (or null on error) ──
|
||
const attributeChunk = async (chunkText, recentCtx, timeoutMs = AUDIOBOOK_ATTRIBUTION_TIMEOUT_MS) => {
|
||
const body = { text: chunkText, known_characters: roster.slice(-40), recent: recentCtx, language, llm_url: document.getElementById('ab-cv-llm-url')?.value.trim() || llm_url, model: audiobookSafeLlmModel(document.getElementById('ab-cv-llm-select')?.value || model), timeout_seconds: audiobookTimeoutSeconds(timeoutMs) };
|
||
// Streaming first — shows the LLM's live thinking in the processing
|
||
// card. Any stream failure falls through to the blocking endpoint.
|
||
try {
|
||
const d = await audiobookAttributeStream(body, view, ac.signal, timeoutMs);
|
||
return Array.isArray(d.segments) ? d.segments : null;
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') throw err; // propagate cancel
|
||
}
|
||
try {
|
||
const r = await audiobookFetchWithTimeout('/api/attribute-dialogue', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
signal: ac.signal,
|
||
body: JSON.stringify(body),
|
||
}, timeoutMs + 5000);
|
||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText || ('HTTP ' + r.status)); }
|
||
const d = await r.json();
|
||
return Array.isArray(d.segments) ? d.segments : null;
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') throw err; // propagate cancel
|
||
return { error: err.message };
|
||
}
|
||
};
|
||
|
||
// The whole per-chunk attribution + cleanup pipeline is guarded so that any
|
||
// unexpected error for ONE passage (bad response shape, network hiccup mid
|
||
// retry, etc.) degrades gracefully to quote-splitting instead of throwing an
|
||
// uncaught error that kills the entire cast loop and strands the panel in a
|
||
// broken, un-navigable state. Abort (Stop Casting) still propagates normally.
|
||
let segs;
|
||
try {
|
||
// ── First attempt ──
|
||
let result = await attributeChunk(chunks[i], recent);
|
||
let data = null;
|
||
|
||
if (result && !result.error) {
|
||
data = result;
|
||
} else if (result && result.error) {
|
||
// ── Retry: split the failing chunk in half and run both halves ──
|
||
view.note(`⚠️ Passage ${i + 1} timed out — retrying in two halves…`);
|
||
const half = Math.floor(chunks[i].length / 2);
|
||
const splitAt = chunks[i].lastIndexOf(' ', half) || half;
|
||
const chunkA = chunks[i].slice(0, splitAt).trim();
|
||
const chunkB = chunks[i].slice(splitAt).trim();
|
||
const resA = await attributeChunk(chunkA, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
|
||
const resB = await attributeChunk(chunkB, recent, AUDIOBOOK_ATTRIBUTION_RETRY_TIMEOUT_MS);
|
||
|
||
const segsA = Array.isArray(resA) ? resA : null;
|
||
const segsB = Array.isArray(resB) ? resB : null;
|
||
|
||
if (segsA || segsB) {
|
||
const arrA = segsA || audiobookSplitByQuotes(chunkA);
|
||
const arrB = segsB || audiobookSplitByQuotes(chunkB);
|
||
data = [...arrA, ...arrB];
|
||
if (!segsA) { degraded++; view.note(`⚠️ Passage ${i + 1} (first half) — auto-detected (retry also failed)`); }
|
||
if (!segsB) { degraded++; view.note(`⚠️ Passage ${i + 1} (second half) — auto-detected (retry also failed)`); }
|
||
} else {
|
||
// Both halves also failed
|
||
view.note(`❌ Passage ${i + 1} — LLM error: ${result.error}`);
|
||
data = null;
|
||
}
|
||
}
|
||
|
||
segs = Array.isArray(data) ? data : (data && Array.isArray(data.segments) ? data.segments : []);
|
||
const hasDialogue = segs.some(s => s.type === 'dialogue');
|
||
if (!segs.length || (!hasDialogue && audiobookHasDialogue(chunks[i]))) {
|
||
// LLM completely unavailable — extract dialogue from quotes without speaker attribution
|
||
segs = audiobookSplitByQuotes(chunks[i]);
|
||
degraded++;
|
||
const named = segs.filter(s => s.type === 'dialogue' && !/^Unknown|Unbekannt/i.test(s.speaker)).length;
|
||
view.note(`Passage ${i + 1} — auto-detected dialogue${named ? ` (${named} speaker${named !== 1 ? 's' : ''} from tags)` : ' (set speakers in review)'}`);
|
||
} else {
|
||
// Clean up LLM hallucinations where it outputs the same text twice or leaves quotes in narration
|
||
let cleanedSegs = [];
|
||
for (let s of segs) {
|
||
s.text = s.text || '';
|
||
|
||
if (s.type === 'dialogue' && audiobookIsSpeechTagOnly(s.text)) {
|
||
s.type = 'narration';
|
||
s.speaker = 'Narrator';
|
||
s.emotion = '';
|
||
}
|
||
|
||
// Strict validation: if LLM claims it's dialogue, but the original text has NO quotes around it, it's a hallucination.
|
||
if (s.type === 'dialogue' && s.text.trim().length > 10) {
|
||
const tText = s.text.trim();
|
||
// Try to find the text in the original chunk to check its surroundings
|
||
const idx = chunks[i].indexOf(tText);
|
||
if (idx !== -1) {
|
||
const surround = chunks[i].slice(Math.max(0, idx - 8), idx) + chunks[i].slice(idx + tText.length, idx + tText.length + 8);
|
||
if (!/[«»„“”"‟‚‘’›‹『「—–]/.test(surround)) {
|
||
s.type = 'narration';
|
||
s.speaker = 'Narrator';
|
||
s.emotion = '';
|
||
}
|
||
}
|
||
}
|
||
|
||
if (cleanedSegs.length > 0) {
|
||
let last = cleanedSegs[cleanedSegs.length - 1];
|
||
if (s.text.trim() === last.text.trim()) {
|
||
if (s.type === 'dialogue' && last.type !== 'dialogue') {
|
||
cleanedSegs[cleanedSegs.length - 1] = s; continue;
|
||
} else if (s.type !== 'dialogue' && last.type === 'dialogue') {
|
||
continue;
|
||
} else { continue; }
|
||
}
|
||
if (s.type === 'dialogue' && last.type === 'narration') {
|
||
const tS = s.text.trim(), tL = last.text.trim();
|
||
if (tL.endsWith(tS)) {
|
||
last.text = tL.slice(0, -tS.length).trim();
|
||
if (!last.text) cleanedSegs.pop();
|
||
}
|
||
}
|
||
}
|
||
if (s.text.trim()) cleanedSegs.push(s);
|
||
}
|
||
|
||
// Merge consecutive narration segments to preserve paragraph flow
|
||
let mergedSegs = [];
|
||
for (let s of cleanedSegs) {
|
||
if (mergedSegs.length > 0) {
|
||
let last = mergedSegs[mergedSegs.length - 1];
|
||
if (s.type === 'narration' && last.type === 'narration' && (s.speaker || 'Narrator').toLowerCase() === 'narrator' && (last.speaker || 'Narrator').toLowerCase() === 'narrator') {
|
||
// Only add a newline if they don't already flow perfectly (e.g. LLM split mid-sentence)
|
||
// But usually we just join with double newline to preserve paragraphs, or single space if it's mid-sentence.
|
||
// A safe heuristic: if it ends with punctuation, use double newline (paragraph break).
|
||
if (/[.!?]$/.test(last.text.trim())) {
|
||
last.text = last.text.trimEnd() + '\n\n' + s.text.trimStart();
|
||
} else {
|
||
last.text = last.text.trimEnd() + ' ' + s.text.trimStart();
|
||
}
|
||
continue;
|
||
}
|
||
}
|
||
mergedSegs.push(s);
|
||
}
|
||
segs = mergedSegs;
|
||
}
|
||
} catch (chunkErr) {
|
||
if (chunkErr.name === 'AbortError') throw chunkErr; // propagate Stop Casting
|
||
degraded++;
|
||
segs = audiobookSplitByQuotes(chunks[i]);
|
||
view.note(`❌ Passage ${i + 1} — unexpected error (${chunkErr.message}) — auto-detected dialogue instead`);
|
||
}
|
||
// Deterministic pass over what the LLM left Unknown — colon rule and
|
||
// post-quote inquit are mechanical enough to apply in code (with the
|
||
// previous chunk's tail as lookback context across the boundary).
|
||
audiobookResolveUnknowns(segs, allSegments.slice(-2), roster);
|
||
// harvest speaker names (from LLM or tag heuristic) into the running roster
|
||
segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); });
|
||
segs.forEach(s => { s.page = _curPageNum; allSegments.push(s); });
|
||
view.addSegments(segs);
|
||
completedChunks = i + 1;
|
||
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
|
||
}
|
||
view.update(_audiobook.cancel ? completedChunks : chunks.length);
|
||
} catch (err) {
|
||
if (err.name === 'AbortError') {
|
||
_audiobook.cancel = true;
|
||
view.note('Casting stopped. Completed passages were preserved.');
|
||
} else {
|
||
// Surface unexpected errors instead of throwing an uncaught rejection —
|
||
// that used to strand the panel mid-cast with no way to recover short of
|
||
// reloading the whole app. Treat it like a stop: keep whatever completed
|
||
// and let the normal "stopped early" / recast flow take over below.
|
||
_audiobook.cancel = true;
|
||
view.note(`❌ Casting stopped — unexpected error: ${err.message}`);
|
||
}
|
||
} finally {
|
||
_abStopDraftAutosave();
|
||
_audiobook.running = false;
|
||
_audiobook.abort = null;
|
||
}
|
||
|
||
if (!allSegments.length) {
|
||
if (_audiobook.cancel) {
|
||
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
|
||
toast('Casting stopped before any passages were saved.', 'info');
|
||
return;
|
||
}
|
||
view.done();
|
||
toast('No segments produced', 'error');
|
||
return;
|
||
}
|
||
|
||
_audiobook.segments = allSegments;
|
||
_audiobook.lastText = text;
|
||
_audiobook.roster = roster;
|
||
_audiobook.narratedPassages = narrationOnly;
|
||
_audiobook.degraded = degraded;
|
||
_audiobook.completedChunks = _audiobook.cancel ? completedChunks : chunks.length;
|
||
_audiobook.completedTotal = chunks.length;
|
||
_abSaveDraft(allSegments, roster, text, _audiobook.completedChunks, chunks.length);
|
||
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
|
||
|
||
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
|
||
|
||
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
|
||
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||
const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
|
||
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
|
||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||
}
|
||
|
||
// ── Editable attribution preview ─────────────────────────────────────────────
|
||
|
||
async function audiobookOpenCurrentInRehearser() {
|
||
const segs = _audiobook.segments || [];
|
||
if (!segs.length) { toast('No cast to open', 'error'); return false; }
|
||
|
||
await audiobookSaveAsRehearsal({ silent: true });
|
||
|
||
if (_audiobook.rehId && typeof window.rehDbGetById === 'function' && typeof window.rehLoadRecord === 'function') {
|
||
try {
|
||
const rec = await window.rehDbGetById(_audiobook.rehId);
|
||
if (rec) {
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||
window.rehLoadRecord(rec);
|
||
toast('Opened cast in Script Rehearser', 'success');
|
||
return true;
|
||
}
|
||
} catch (err) {
|
||
console.warn('[audiobook] failed to open saved rehearser record:', err);
|
||
}
|
||
}
|
||
|
||
const { script, emotions } = audiobookBuildScript(segs);
|
||
await audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
|
||
return true;
|
||
}
|
||
|
||
function audiobookShowPreview() {
|
||
audiobookOpenCurrentInRehearser();
|
||
return;
|
||
const segs = _audiobook.segments || [];
|
||
const roster = _audiobook.roster || [];
|
||
document.getElementById('audiobook-preview')?.remove();
|
||
const ov = document.createElement('div');
|
||
ov.id = 'audiobook-preview';
|
||
ov.className = 'audiobook-overlay';
|
||
// datalist = Narrator + LLM roster + any speakers present in the segments (incl. Unknown)
|
||
const speakerSet = [...new Set(['Narrator', ...roster, ...segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)])];
|
||
const charCount = speakerSet.filter(n => !/^Unknown|Unbekannt/i.test(n)).length;
|
||
const opts = speakerSet.map(n => `<option value="${escHtml(n)}">`).join('');
|
||
ov.innerHTML = `<div class="audiobook-box audiobook-preview-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-drama-masks"></span> Review cast & lines
|
||
<span class="audiobook-count">${segs.length} segments · ${charCount} character${charCount !== 1 ? 's' : ''}</span></div>
|
||
<div class="audiobook-msg">Fix any wrong speaker or emotion, then open in the Script Rehearser to assign voices.${_audiobook.narratedPassages ? ` <span class="audiobook-narr-note">${_audiobook.narratedPassages} passage${_audiobook.narratedPassages !== 1 ? 's' : ''} had no dialogue (narrator).</span>` : ''}${_audiobook.degraded ? ` <span class="audiobook-narr-note">${_audiobook.degraded} passage${_audiobook.degraded !== 1 ? 's' : ''} used quick detection — set the “Unknown” speakers.</span>` : ''}</div>
|
||
<datalist id="audiobook-roster">${opts}</datalist>
|
||
<div class="audiobook-seglist" id="audiobook-seglist"></div>
|
||
<div class="audiobook-actions">
|
||
<button class="btn-secondary btn-sm" id="audiobook-preview-cancel">Cancel</button>
|
||
<button class="btn-primary btn-sm" id="audiobook-preview-open"><span class="mdi mdi-account-music-outline"></span> Open in Rehearser</button>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(ov);
|
||
ov.querySelector('#audiobook-seglist').innerHTML = segs.map((s, i) => `<div class="audiobook-seg${s.type === 'dialogue' ? ' is-dialog' : ''}">
|
||
<input class="audiobook-seg-sp" data-i="${i}" list="audiobook-roster" value="${escHtml(s.speaker || 'Narrator')}" aria-label="Speaker">
|
||
<input class="audiobook-seg-emo" data-i="${i}" value="${escHtml(s.emotion || '')}" placeholder="emotion" aria-label="Emotion"${s.type === 'dialogue' ? '' : ' disabled'}>
|
||
<div class="audiobook-seg-text">${highlightText(s.text || '')}</div>
|
||
</div>`).join('');
|
||
ov.querySelector('#audiobook-preview-cancel').addEventListener('click', () => ov.remove());
|
||
ov.querySelector('#audiobook-preview-open').addEventListener('click', () => { audiobookApplyPreviewAndOpen(); ov.remove(); });
|
||
}
|
||
|
||
function audiobookApplyPreviewAndOpen() {
|
||
const segs = _audiobook.segments;
|
||
document.querySelectorAll('#audiobook-seglist .audiobook-seg-sp').forEach(inp => {
|
||
const i = +inp.dataset.i; const v = inp.value.trim() || 'Narrator';
|
||
segs[i].speaker = v;
|
||
segs[i].type = (v.toLowerCase() === 'narrator') ? 'narration' : 'dialogue';
|
||
});
|
||
document.querySelectorAll('#audiobook-seglist .audiobook-seg-emo').forEach(inp => {
|
||
const i = +inp.dataset.i; segs[i].emotion = inp.value.trim();
|
||
});
|
||
const { script, emotions } = audiobookBuildScript(segs);
|
||
audiobookOpenInRehearser(script, (readerState.title || 'Audiobook'), emotions);
|
||
}
|
||
|
||
// Build a rehearser script (CAPS speaker + line; narration as plain paragraphs)
|
||
// and a parallel list of per-dialogue-line emotions (same order as dialog lines).
|
||
// Inserts \f page-break markers at the segment boundary nearest each source PDF
|
||
// page start (so saved/opened scripts keep the book's pagination). Page positions
|
||
// are realigned per-segment against the source text to avoid cumulative drift.
|
||
function audiobookBuildScript(segments) {
|
||
let script = '';
|
||
const emotions = [];
|
||
const marks = _audiobook.pageMarks || [];
|
||
const src = _audiobook.lastText || '';
|
||
let markIdx = 0, searchPos = 0;
|
||
// The first page mark sits at the start of the document — no leading break.
|
||
if (marks.length && marks[0].offset <= 2) markIdx = 1;
|
||
|
||
for (const s of segments) {
|
||
const t = (s.text || '').trim(); if (!t) continue;
|
||
// Where does this segment sit in the source text? (verbatim narration matches
|
||
// exactly; dialogue text — quotes stripped — still occurs in the source.)
|
||
if (src && markIdx < marks.length) {
|
||
const probe = t.slice(0, 24);
|
||
const at = probe ? src.indexOf(probe, searchPos) : -1;
|
||
const pos = at >= 0 ? at : searchPos;
|
||
if (at >= 0) searchPos = at + probe.length;
|
||
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
|
||
if (script.trim()) {
|
||
script += `\n\f${marks[markIdx].page + 1}\n`; // page break — 1-indexed page number
|
||
}
|
||
markIdx++;
|
||
}
|
||
}
|
||
const isDialogue = s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() !== 'narrator';
|
||
if (isDialogue) {
|
||
script += '\n' + s.speaker.toUpperCase() + '\n' + t + '\n';
|
||
emotions.push(s.emotion || '');
|
||
} else {
|
||
script += '\n' + t + '\n'; // narration → narrator reads these
|
||
}
|
||
}
|
||
return { script: script.trim(), emotions };
|
||
}
|
||
|
||
async function audiobookOpenInRehearser(script, title, dialogueEmotions) {
|
||
if ($('reh-script-text')) $('reh-script-text').value = script;
|
||
if ($('reh-script-title')) $('reh-script-title').value = title;
|
||
if (typeof navTo === 'function') navTo('s-rehearser');
|
||
// Reuse the rehearser's own parse flow (builds lines, cast, jumps to Cast phase)
|
||
const btn = $('reh-parse-btn');
|
||
if (btn) btn.click();
|
||
else if (typeof parseScript === 'function') { rehState.lines = parseScript(script); }
|
||
// Apply per-line emotions to dialog lines in order (Phase 3: emotion-aware narration)
|
||
let speakers = 0, lines = 0;
|
||
if (window.rehState && Array.isArray(rehState.lines)) {
|
||
let k = 0;
|
||
rehState.lines.forEach(l => { if (l.type === 'dialog') { const e = dialogueEmotions[k++]; if (e) l.emotion = e; lines++; } });
|
||
speakers = Object.keys(rehState.cast || {}).filter(s => !String(s).includes('NARRATOR')).length;
|
||
}
|
||
// Persist as a reopenable rehearsal so the cast/lines aren't lost — find it under
|
||
// Script Rehearser → Bibliothek (Library) and reopen anytime to edit & synthesise.
|
||
let saved = false;
|
||
if (typeof saveToLibrary === 'function') {
|
||
try { rehState.savedId = null; await saveToLibrary(); saved = true; } catch (_) {}
|
||
}
|
||
toast(`Cast ${speakers} character${speakers !== 1 ? 's' : ''} · ${lines} lines` + (saved ? ' — saved to Rehearser → Bibliothek' : ''), 'success');
|
||
}
|
||
|
||
// ── Audiobook export (rehearser): synthesise every line → MP3 per chapter ────
|
||
|
||
function audiobookIsChapter(line) {
|
||
if (line.type === 'act' || line.type === 'scene') return true;
|
||
const t = (typeof stripMarkdown === 'function' ? stripMarkdown(line.text || '') : (line.text || '')).trim();
|
||
if (!t || t.length > 60) return false;
|
||
return /^(chapter|kapitel|chap\.?|part|book|prologue|epilogue|prolog|epilog|teil)\b/i.test(t);
|
||
}
|
||
|
||
function audiobookLineVoice(l) {
|
||
if (l.type === 'dialog') {
|
||
const c = rehState.cast[l.speaker] || {};
|
||
return { voice: c.voice, instruct: (typeof _buildInstruct === 'function' ? _buildInstruct(c.instruct, l.emotion) : '') };
|
||
}
|
||
return { voice: rehState.narratorVoice, instruct: '' };
|
||
}
|
||
|
||
async function audiobookExport() {
|
||
if (_audiobook.running) return;
|
||
if (!window.rehState || !(rehState.lines || []).length) { toast('Open a script in the rehearser first', 'error'); return; }
|
||
if (!rehState.backend) { toast('Select a TTS backend in the rehearser first', 'error'); return; }
|
||
if (typeof _ensureNarrator === 'function') _ensureNarrator();
|
||
|
||
const speakable = i => {
|
||
const l = rehState.lines[i];
|
||
if (!l || l.ignored || l.hidden) return false;
|
||
if (l.type === 'dialog') { const c = rehState.cast[l.speaker]; return !!(c && c.voice && c.voice !== 'me'); }
|
||
return !!(rehState.narratorVoice && (l.text || '').trim());
|
||
};
|
||
|
||
// Bucket speakable lines into chapters (by chapter headings / act / scene)
|
||
const buckets = [];
|
||
let cur = null;
|
||
rehState.lines.forEach((l, i) => {
|
||
if (audiobookIsChapter(l)) { cur = { title: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text).trim().slice(0, 50), idx: [] }; buckets.push(cur); }
|
||
if (speakable(i)) { if (!cur) { cur = { title: '', idx: [] }; buckets.push(cur); } cur.idx.push(i); }
|
||
});
|
||
const allIdx = buckets.flatMap(b => b.idx);
|
||
if (!allIdx.length) { toast('Nothing to synthesise — cast voices first', 'error'); return; }
|
||
|
||
_audiobook.running = true; _audiobook.cancel = false;
|
||
const prog = audiobookProgress(allIdx.length);
|
||
const mp3 = new Map();
|
||
let done = 0;
|
||
const queue = allIdx.slice();
|
||
const worker = async () => {
|
||
while (queue.length && !_audiobook.cancel) {
|
||
const i = queue.shift();
|
||
const l = rehState.lines[i];
|
||
const { voice, instruct } = audiobookLineVoice(l);
|
||
const text = (typeof _rehInlineTone === 'function')
|
||
? _rehInlineTone(stripMarkdown(l.text), l.emotion)
|
||
: (typeof stripMarkdown === 'function' ? stripMarkdown(l.text) : l.text);
|
||
try { mp3.set(i, await fetchTtsPreviewBlob(voice, text, 'mp3', instruct, rehState.backend)); } catch (_) {}
|
||
prog.update(++done, `Synthesising line ${done} / ${allIdx.length}…`);
|
||
}
|
||
};
|
||
try { await Promise.all(Array.from({ length: Math.min(2, allIdx.length) }, worker)); }
|
||
finally { prog.done(); _audiobook.running = false; }
|
||
|
||
if (_audiobook.cancel) { toast('Export cancelled', 'error'); return; }
|
||
const title = (typeof readerSafeName === 'function' ? readerSafeName($('reh-script-title')?.value || 'Audiobook') : ($('reh-script-title')?.value || 'Audiobook'));
|
||
const realChapters = buckets.filter(b => b.title).length > 0;
|
||
let files = 0;
|
||
for (let c = 0; c < buckets.length; c++) {
|
||
const blobs = buckets[c].idx.map(i => mp3.get(i)).filter(Boolean);
|
||
if (!blobs.length) continue;
|
||
const blob = new Blob(blobs, { type: 'audio/mpeg' });
|
||
const ch = buckets[c].title ? ' ' + readerSafeName(buckets[c].title) : '';
|
||
const name = (realChapters || buckets.length > 1)
|
||
? `${title} - ${String(c + 1).padStart(2, '0')}${ch}.mp3`
|
||
: `${title}.mp3`;
|
||
if (typeof readerDownload === 'function') readerDownload(blob, name);
|
||
files++;
|
||
await new Promise(r => setTimeout(r, 400));
|
||
}
|
||
toast('Exported audiobook · ' + files + (realChapters ? ' chapter MP3 file(s)' : ' MP3 file(s)'), 'success');
|
||
}
|
||
|
||
// ── Wiring ───────────────────────────────────────────────────────────────────
|
||
|
||
$('reader-audiobook-btn')?.addEventListener('click', audiobookOpenCastView);
|
||
$('reh-tb-audiobook')?.addEventListener('click', audiobookExport);
|
||
|
||
// Build a rehearsal record object from the current segments.
|
||
// If _audiobook.rehId is set, fetches the existing record so voice assignments
|
||
// made in Script Rehearser are preserved when we overwrite the script text.
|
||
async function _audiobookBuildRehRecord(segs) {
|
||
const { script, emotions } = audiobookBuildScript(segs);
|
||
const title = (typeof readerState !== 'undefined' && readerState.title) ? readerState.title : 'Audiobook';
|
||
const lines = (typeof parseScript === 'function') ? parseScript(script) : [];
|
||
|
||
// Map emotions onto lines so detectCharacters picks them up
|
||
if (emotions && emotions.length) {
|
||
let eIdx = 0;
|
||
lines.forEach(l => { if (l.type === 'dialog' && eIdx < emotions.length) { if (emotions[eIdx]) l.emotion = emotions[eIdx]; eIdx++; } });
|
||
}
|
||
|
||
// Build cast from detected speakers
|
||
const cast = {};
|
||
if (typeof detectCharacters === 'function') {
|
||
const detected = detectCharacters(lines);
|
||
Object.entries(detected).forEach(([sp, def]) => {
|
||
cast[sp] = { voice: def.voice, color: def.color, instruct: '', lang: '', gender: '', tags: '', soul: '', ignored: false, hidden: false, voiceData: null };
|
||
});
|
||
}
|
||
|
||
// Fetch the existing record and merge its cast so voice assignments survive an update
|
||
let existing = null;
|
||
if (_audiobook.rehId && typeof window.rehDbGetById === 'function') {
|
||
try { existing = await window.rehDbGetById(_audiobook.rehId); } catch (_) {}
|
||
}
|
||
if (existing && existing.cast) {
|
||
Object.entries(existing.cast).forEach(([sp, info]) => {
|
||
if (cast[sp]) cast[sp] = { ...cast[sp], ...info }; // overlay saved voice/instruct/etc.
|
||
else cast[sp] = info; // speaker was removed from segments but keep their info
|
||
});
|
||
}
|
||
|
||
const emotions_map = {};
|
||
lines.forEach((l, i) => { if (l.type === 'dialog' && l.emotion) emotions_map[i] = l.emotion; });
|
||
|
||
return {
|
||
title, script, cast,
|
||
emotions: emotions_map,
|
||
notes: existing ? (existing.notes || {}) : {},
|
||
ignored: existing ? (existing.ignored || {}) : {},
|
||
hidden: existing ? (existing.hidden || {}) : {},
|
||
backend: existing ? (existing.backend || '') : '',
|
||
narratorVoice: existing ? (existing.narratorVoice || '') : '',
|
||
lineIndex: existing ? (existing.lineIndex || 0) : 0,
|
||
clips: existing ? (existing.clips || []) : [],
|
||
created: existing ? existing.created : new Date(),
|
||
updated: new Date(),
|
||
};
|
||
}
|
||
|
||
let _abRehSaveTimer = null;
|
||
|
||
// Save segments to the Script Rehearser IndexedDB.
|
||
// { silent: true } suppresses the toast and draft-clear (used for auto-saves).
|
||
async function audiobookSaveAsRehearsal(opts) {
|
||
const silent = opts && opts.silent;
|
||
const segs = _audiobook.segments;
|
||
if (!segs || !segs.length) { if (!silent) toast('No segments to save', 'error'); return; }
|
||
if (typeof rehDbAdd !== 'function') { if (!silent) toast('Rehearser DB not available', 'error'); return; }
|
||
|
||
try {
|
||
const rec = await _audiobookBuildRehRecord(segs);
|
||
if (_audiobook.rehId) {
|
||
rec.id = _audiobook.rehId;
|
||
await rehDbPut(rec);
|
||
} else {
|
||
_audiobook.rehId = await rehDbAdd(rec);
|
||
}
|
||
if (typeof renderLibraryList === 'function') renderLibraryList();
|
||
if (!silent) {
|
||
_abClearDraft();
|
||
toast('Saved as Script Rehearsal', 'success');
|
||
}
|
||
} catch (e) {
|
||
if (!silent) toast('Failed to save rehearsal: ' + e.message, 'error');
|
||
else console.warn('[audiobook] auto-save failed:', e);
|
||
}
|
||
}
|
||
|
||
// Debounced silent save — called after each manual speaker correction
|
||
function _audiobookDebouncedSave() {
|
||
clearTimeout(_abRehSaveTimer);
|
||
_abRehSaveTimer = setTimeout(() => audiobookSaveAsRehearsal({ silent: true }), 1500);
|
||
}
|