tts-voice-creator-clone-and.../static/js/audiobook-cast.js
mARTin-B78 e81fa64448 fix: magazine state persistence, misfiled in book library, confidence badge layout, docker-socket-proxy crash loop
Magazine "forgets" the open document (real bug, confirmed live):
- A full page reload or the browser's own Back button tears down all
  in-memory JS state; magInit() always defaulted to the empty library
  view with no way back to what was open. Persists the last-opened
  magazine id in localStorage and auto-reopens it on load, falling back
  to the library only if that document no longer exists.
- The actual root cause of the reported duplicate: readerRenderLibrary()
  (Studio/Reader "Source") and libraryRenderBooks() (Library > Books)
  both listed EVERY document from the shared reader_library store with
  no kind filter, so a magazine (kind: "magazine") was already showing
  up there disguised as a plain book. The magazine never actually
  vanished -- it was misfiled in a different list, which is what led to
  the user re-uploading it and getting a real duplicate under a
  different kind. Both listings now exclude kind: "magazine".

Confidence badge layout (Studio Characters review):
- The badge (radiobox is already positioned after the speaker name in
  DOM order) rendered on its own line far below the name because both
  .ab-cv-spk and .ab-cv-txt were `display: block`, forcing a line break
  regardless of markup order. Wrapped the name + badge + check button in
  a shared flex row (.ab-cv-spk-row) so the badge now sits directly
  beside the name/after "NARRATOR" as requested, without needing to
  touch the two existing incremental-patch code paths (they insert
  relative to the same elements, now just nested one level deeper).

docker-socket-proxy crash loop (found while restarting for the magazine
Python changes to take effect -- unrelated to this session's earlier
work, landed in an earlier commit during the interruption):
- read_only: true left the entrypoint with nowhere to write its own
  haproxy.cfg ("Read-only file system"), crash-looping the container and
  leaving the app without Docker access. Removed read_only for this one
  service -- cap_drop: ALL, no-new-privileges, and the :ro socket mount
  already provide the real hardening.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 09:39:20 +02:00

3529 lines
204 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Audiobook cast serialization ────────────────────────────────────────────
// Live casting remains in audiobook.js; these deterministic export/migration
// helpers have no casting-state side effects and can be loaded independently.
(function (root) {
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');
}
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;
}
}
// Merge adjacent segments assigned to the same speaker without crossing a
// page boundary. Kept separate from the live casting orchestration because it
// is a deterministic post-cast normalization pass.
function _audiobookMergeAdjacentSameSpeaker(segments) {
const out = [];
for (const s of segments) {
const last = out[out.length - 1];
if (last && last.type === s.type && last.page === s.page) {
const lastSpeaker = (last.speaker || 'Narrator').trim().toLowerCase();
const curSpeaker = (s.speaker || 'Narrator').trim().toLowerCase();
// Dialogue keeps its emotion tag distinct so one mood is not silently
// discarded when two adjacent rows are combined.
const sameEmotion = s.type !== 'dialogue' || (last.emotion || '').toLowerCase() === (s.emotion || '').toLowerCase();
const unknownSpeaker = !lastSpeaker || lastSpeaker === 'unknown' || lastSpeaker === 'unbekannt';
if (lastSpeaker === curSpeaker && sameEmotion && !(s.type === 'dialogue' && unknownSpeaker)) {
const lastText = last.text.trimEnd(), curText = s.text.trimStart();
last.text = /[.!?…»«”"’']$/.test(lastText) ? lastText + '\n\n' + curText : lastText + ' ' + curText;
continue;
}
}
out.push(Object.assign({}, s));
}
return out;
}
// Initial casting must retain dialogue turn boundaries. Run 3 showed that
// same-speaker joins obscure upstream segmentation errors. Narration can
// still be compacted for paragraph flow; recast/verify uses the narrower
// dialogue-only merge below when that behavior is wanted.
function _audiobookMergeAdjacentNarration(segments) {
const out = [];
for (const s of segments) {
const last = out[out.length - 1];
const lastSpeaker = String(last?.speaker || 'Narrator').trim().toLowerCase();
const curSpeaker = String(s?.speaker || 'Narrator').trim().toLowerCase();
if (last && last.type === 'narration' && s?.type === 'narration'
&& last.page === s.page && lastSpeaker === 'narrator' && curSpeaker === 'narrator') {
const a = String(last.text || '').trimEnd(), b = String(s.text || '').trimStart();
last.text = /[.!?…»«”"’']$/.test(a) ? a + '\n\n' + b : a + ' ' + b;
continue;
}
out.push(Object.assign({}, s));
}
return out;
}
// Merge consecutive segments spoken by the same named character. This is a
// dialogue-only normalization pass; narration is intentionally left alone.
function _audiobookMergeAdjacentDialogue(segments) {
const out = [];
let merged = 0;
for (const s of segments) {
if (out.length) {
const last = out[out.length - 1];
const spk = String(s.speaker || '').trim().toLowerCase();
const known = spk && !/^(unknown|unbekannt|narrator)$/i.test(spk);
if (last.type === 'dialogue' && s.type === 'dialogue' && known
&& String(last.speaker || '').trim().toLowerCase() === spk) {
const a = String(last.text || '').trimEnd(), b = String(s.text || '').trimStart();
last.text = a + (/[.!?…»«"']$/.test(a) ? '\n\n' : ' ') + b;
if (!String(last.emotion || '').trim() && String(s.emotion || '').trim()) last.emotion = s.emotion;
merged++;
continue;
}
}
out.push(s);
}
return { segments: out, merged };
}
function _audiobookMergeSplitSentences(segments) {
const out = [];
let merged = 0;
for (const s of segments) {
const prev = out[out.length - 1];
const cur = String(s?.text || '').trim();
if (prev && prev.type === 'narration' && s?.type === 'narration' && cur) {
const prevText = String(prev.text || '').trimEnd();
const endsOpen = prevText && !/[.!?:;…»"'\)\]]$/.test(prevText);
const startsLower = /^[a-zäöüß]/.test(cur);
const prevIsInquit = new RegExp(_AB_INQUIT_VERBS + "[^.!?]{0,40},\\s*$", 'i').test(prevText);
if (endsOpen && startsLower && !prevIsInquit) {
prev.text = prevText + ' ' + cur;
merged++;
continue;
}
}
out.push(s);
}
return { segments: out, merged };
}
function _audiobookDedupNearbyDuplicates(segments) {
const QUOTE_CHARS = '»«„"‘’›‹';
const stripRe = new RegExp(`(^[${QUOTE_CHARS}\\s]+)|([${QUOTE_CHARS}\\s]+$)`, 'g');
const norm = (t) => String(t || '').replace(stripRe, '').toLowerCase().replace(/\s+/g, ' ').trim();
const WINDOW = 30;
const MIN_LEN = 20;
const out = [];
let removed = 0;
for (const s of segments) {
const nt = norm(s?.text);
if (nt.length >= MIN_LEN) {
let isDup = false;
for (let k = out.length - 1; k >= 0 && out.length - k <= WINDOW; k--) {
if (out[k].type === s.type && norm(out[k].text) === nt) { isDup = true; break; }
}
if (isDup) { removed++; continue; }
}
out.push(s);
}
return { segments: out, removed };
}
const _AB_TITLE_PREFIX = /^(?:der|die|das|den|dem|ein|eine|einer|herr|frau|oberst|obrist|graf|gräfin|ritter|hauptmann|kommandant|meister|bruder|schwester|prinz|prinzessin|könig|königin|alter|alte|junger|junge|blonder|blonde)\s+/i;
function _abIdentityTokens(name) {
let n = String(name || '').trim().toLowerCase(), prev = null;
while (prev !== n) { prev = n; n = n.replace(_AB_TITLE_PREFIX, ''); }
return n.split(/[\s\-]+/).filter(Boolean);
}
const _AB_NOT_A_CHARACTER = new Set([
'hinter','vor','neben','über','unter','zwischen','gegenüber','keiner','keine','niemand','jemand','alle','beide',
'einige','viele','andere','anderen','jeder','jede','man','von','zu','bei','nach','aus','mit','ihm','ihr','ihnen',
'dort','hier','draußen','drinnen','oben','unten','plötzlich','endlich','schließlich',
'inneren','innere','herz','herzen','hand','hände','haenden','kopf','stimme','stimmen','wind','feuer','flamme','flammen',
'dunkelheit','stille','schatten','licht','nacht','angst','schmerz','gedanke','gedanken','traum','träume','geliebten',
'geliebter','geliebte','blondschopf','antwort','echo','ferne','nähe','naehe','tiefe','wasser','regen','donner','erde',
'himmel','sonne','mond','tod','leben','seele','geist der','wahrheit','zweifel','hoffnung','erinnerung','vergangenheit'
]);
function _abIsImplausibleSpeaker(name) {
const n = String(name || '').trim().toLowerCase().replace(/^(?:der|die|das|den|dem|ein|eine|einer|einem)\s+/, '');
return Boolean(n) && _AB_NOT_A_CHARACTER.has(n);
}
function _audiobookDropPhantomSpeakers(segments) {
let dropped = 0;
const names = new Set();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
if (_abIsImplausibleSpeaker(s.speaker)) { names.add(s.speaker); s.speaker = 'Unknown'; dropped++; }
}
return { dropped, names: [...names] };
}
function _audiobookConsolidateSpeakerAliases(segments) {
const count = new Map();
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const sp = String(s.speaker || '').trim();
if (!sp || /^(unknown|unbekannt|narrator)/i.test(sp)) continue;
count.set(sp, (count.get(sp) || 0) + 1);
}
const names = [...count.keys()];
const tokens = new Map(names.map(n => [n, new Set(_abIdentityTokens(n))]));
const mapping = new Map();
for (const a of names) {
const ta = tokens.get(a);
if (!ta.size) continue;
let best = null;
for (const b of names) {
if (a === b) continue;
const tb = tokens.get(b);
if (!tb.size || tb.size <= ta.size) continue;
let subset = true;
for (const t of ta) if (!tb.has(t)) { subset = false; break; }
if (!subset) continue;
if (!best || count.get(b) > count.get(best) || (count.get(b) === count.get(best) && b.length > best.length)) best = b;
}
if (best) mapping.set(a, best);
}
let moved = 0;
for (const s of segments) {
if (s?.type !== 'dialogue') continue;
const to = mapping.get(String(s.speaker || '').trim());
if (to) { s.speaker = to; moved++; }
}
return { merged: mapping.size, moved, mapping };
}
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-right"></span></button>
</div>
<div class="ab-cv-side-tools">
<input type="text" class="ab-cv-side-search" id="ab-cv-side-search" placeholder="Search characters…" autocomplete="off">
<select class="ab-cv-side-sort" id="ab-cv-side-sort" title="Sort">
<option value="count">Lines</option>
<option value="alpha">A–Z</option>
</select>
</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; flex-wrap:wrap; gap:8px; 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 1 220px; min-width:220px; display:none;">Ready to identify characters.</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-scan only the unknown segments"><span class="mdi mdi-account-question-outline"></span> Identify unknown characters</button>
<button class="btn-secondary btn-sm" id="ab-cv-start-recast" style="display:none; margin-right:8px;" title="Scan the text for characters and build the cast list"><span class="mdi mdi-refresh"></span> Identify all characters</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 AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (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: Hat eine sprechende Person im bisherigen Text KEINEN Eigennamen, ist eine Rollenbezeichnung ein gültiger Sprecher (z.B. 'Ork', 'Nachbar', 'Wächter'). Ist die Person dagegen namentlich bekannt, benutze IMMER den Eigennamen und NIE die Rollenbezeichnung — eine Rollenbezeichnung ist kein Ersatz für einen Namen und auch kein Mittel, 'Unknown' zu vermeiden.
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 unmöglich ist. Ist der EINZIGE Anhaltspunkt für einen Namen, dass die Figur in der Nähe steht oder in der Szene wichtig ist, gib 'Unknown' aus statt zu raten. Ist die Person im Text namentlich bekannt, benutze den Eigennamen statt einer Rollenbezeichnung.
11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.
12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht 'Unknown'.
13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS 'Unknown'.
16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).
17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals 'Unknown'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.
18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals 'Unknown', damit die Figur durchgehend dieselbe Stimme behaelt.
19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker 'Narrator' und type 'narration', das Zitat ein eigenes Segment mit type 'dialogue' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes 'Unknown' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als 'Unknown' zu lassen.
14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals 'Unknown' bleiben.
15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist 'Unknown' zulässig.
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' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig.
- 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 hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden.
- 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'.
- Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, ", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment.
- 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, EINSCHLIESSLICH der 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!"
);
// Policy flip: quote marks used to be stripped out of dialogue text
// entirely, which was landing as stray orphaned »/« characters on their
// own narrator-only segments when the model didn't fully comply. Keeping
// them attached to the dialogue text (as they already are in the source)
// sidesteps that failure mode instead of just asking more firmly.
p = p.replace(
"- 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).",
"- text: Der EXAKTE, wortwörtliche Text aus dem Auszug. Bei 'dialogue' BEHÄLTST du die umschließenden Anführungszeichen als Teil des Texts (z.B. »Hallo!« bleibt »Hallo!«) — entferne sie NICHT und lasse niemals nur eines der beiden übrig."
);
p = p.replace(
"- 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.",
"- Wenn nur ein schließendes « nach einem kurzen Satz steht (z.B. \"Der Tod trägt rot. «\"), behandle den Satz davor als Dialog und hänge das schließende « an dessen Ende an, statt es als eigenes Segment stehen zu lassen — ein Anführungszeichen darf NIE ein eigenes Segment für sich bilden."
);
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 — abgesehen von entfernten äußeren Dialog-Anführungszeichen!",
"- 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, EINSCHLIESSLICH der 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 AKTIV HANDELT (tritt vor, dreht sich, hebt die Hand), spricht meist — ABER NUR bei Handlungsverben. Verben der WAHRNEHMUNG oder REAKTION (spürte, hörte, fühlte, sah, blickte, lauschte, bemerkte, nickte) kennzeichnen den ZUHÖRER, NICHT den Sprecher (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: Hat eine sprechende Person im bisherigen Text KEINEN Eigennamen, ist eine Rollenbezeichnung ein gültiger Sprecher (z.B. 'Ork', 'Nachbar', 'Wächter'). Ist die Person dagegen namentlich bekannt, benutze IMMER den Eigennamen und NIE die Rollenbezeichnung — eine Rollenbezeichnung ist kein Ersatz für einen Namen und auch kein Mittel, 'Unknown' zu vermeiden.
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 unmöglich ist. Ist der EINZIGE Anhaltspunkt für einen Namen, dass die Figur in der Nähe steht oder in der Szene wichtig ist, gib 'Unknown' aus statt zu raten. Ist die Person im Text namentlich bekannt, benutze den Eigennamen statt einer Rollenbezeichnung.`
);
}
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.'
);
}
// Add the mid-quote-dash and voice-announcement rules to prompts saved
// before these existed — a passage like '»...« - und weiter...' was
// getting cut into a new (wrong) speaker at the dash, and narration like
// "Marcians Stimme wirkte nicht mehr so fest" right before an unattributed
// line wasn't being used to resolve it away from 'Unknown'.
if (!/Gedankenstrich-Pause-Regel/.test(p) || !/ZUHÖRER-AUSSCHLUSS/.test(p) || !/FORTSETZUNGS-REGEL/.test(p) || !/EPITHETA/.test(p)) {
const newRules = '11. Gedankenstrich-Pause-Regel: Ein " - " MITTEN in einem Zitat ist eine Sprechpause DESSELBEN Sprechers, kein Zitatende — die Rede geht danach unverändert weiter, bis das tatsächliche schließende Anführungszeichen erscheint.\n12. Stimm-Ankündigung: Erwähnt ein Erzählersatz kurz vor einer noch nicht zugeordneten Zeile explizit die Stimme oder das beginnende Sprechen einer bestimmten Person (z.B. "Marcians Stimme wirkte nicht mehr so fest", "X setzte zum Sprechen an"), gehört diese Zeile dieser Person — nicht \'Unknown\'.\n13. ZUHÖRER-AUSSCHLUSS (negative Evidenz — sehr wichtig): Ein Name im Erzählsatz neben einem Zitat ist HÄUFIG der Zuhörer, nicht der Sprecher. Schließe eine Person als Sprecher AUS, wenn sie (a) im Nachbarsatz wahrnimmt oder reagiert (Marcian spürte ihre Tränen auf seiner Brust; Marcian hörte darüber hinweg → Marcian HÖRT ZU, jemand anderes spricht), oder (b) im Zitat selbst ANGEREDET wird (Vokativ nach Komma: Mögen die Götter dich schützen, Marcian. → Marcian ist der ANGESPROCHENE). Wer angeredet wird, spricht nicht. Wenn durch diesen Ausschluss kein Kandidat mehr übrig bleibt, suche WEITER ZURÜCK (letzte eindeutige Nennung, Ping-Pong-Kette, wer in der Szene anwesend ist) — der Ausschluss grenzt die Kandidaten nur ein und rechtfertigt für sich allein NIEMALS \'Unknown\'.\n16. FORTSETZUNGS-REGEL (wichtiger als Ping-Pong): Beschreibt der Erzaehlsatz ZWISCHEN zwei Zitaten dieselbe Person, die gerade gesprochen hat (per Pronomen oder Name, z.B. "Leise schluchzte sie.", "Er atmete tief durch."), dann spricht diese Person WEITER — das folgende Zitat gehoert IHR, nicht dem Gespraechspartner. Das Ping-Pong-Prinzip (Regel 6) gilt NUR, wenn der Erzaehlsatz auf die ANDERE Person zeigt oder gar keinen Hinweis enthaelt. Beispiel: Cindira spricht, dann "Leise schluchzte sie." → das naechste Zitat ist wieder CINDIRA; ein folgendes "Marcian spuerte ihre Traenen" bestaetigt nur, dass Marcian zuhoert (Regel 13).\n17. NEU AUFTRETENDE, UNBENANNTE HANDELNDE: Fuehrt der Erzaehlsatz unmittelbar vor einem Zitat eine unbenannte Person handelnd ein ("Eine Frau durchbrach die Kette der Krieger und warf sich...", "Ein Soldat trat vor"), dann spricht GENAU DIESE Person. Verwende die Rollenbezeichnung aus dem Text als Sprechernamen ("Eine Frau", "Ein Soldat") — niemals \'Unknown\'. Spricht dieselbe Person spaeter erneut (erkennbar am Pronomen im Erzaehlsatz), nutze exakt DENSELBEN Rollennamen weiter, damit sie eine durchgehende Stimme behaelt.\n18. EPITHETA / BESTIMMTE BESCHREIBUNGEN als Sprecher: Bezeichnungen wie "die Elfe", "der Zauberer", "der Zwerg", "der Hauptmann" meinen fast immer eine BEREITS BEKANNTE Figur. Loese sie auf den kanonischen Namen auf, sobald der Text die Verbindung herstellt (Nyrilla ist die Elfe → "Laut hallte die Stimme der Elfe durch den Gang." bedeutet: NYRILLA hat gesprochen). Nutze immer den kanonischen Namen als Sprecher, niemals das Epitheton und niemals \'Unknown\', damit die Figur durchgehend dieselbe Stimme behaelt.\n19. GEMISCHTE SEGMENTE TRENNEN: Enthaelt ein Segment sowohl Erzaehltext als auch ein Zitat ("Nyrilla richtete sich auf. Alle anderen wichen zurueck. »Ich werde jetzt ...«"), dann zerlege es: der Erzaehlteil wird ein eigenes Segment mit speaker \'Narrator\' und type \'narration\', das Zitat ein eigenes Segment mit type \'dialogue\' und der handelnden Person als Sprecher (hier NYRILLA, denn sie richtet sich unmittelbar davor auf — siehe Regel 4). Ein gemischtes Segment darf niemals als Ganzes \'Unknown\' bleiben. ZERLEGE EBENSO Segmente, die REDEBEITRAEGE MEHRERER SPRECHER enthalten: Ein Befehl, der jemanden beim Namen anspricht, und die darauffolgende Antwort sind ZWEI Sprecher, nicht einer (z.B. "Bringt Fackeln ... Lysandra, du nimmst dir einige Krieger ..." = MARCIAN befiehlt; "Mit Vergnügen, ich hab doch gleich gesagt ..." = LYSANDRA antwortet). Merkmal: Wer im ersten Teil per Vokativ ANGESPROCHEN wird (Regel 13b), ist typischerweise der Sprecher des NAECHSTEN Redebeitrags. Lege fuer jeden Redebeitrag ein eigenes Segment mit dem jeweiligen Sprecher an, statt den ganzen Block als \'Unknown\' zu lassen.\n14. GETEILTE ZITATE / Pronomen-Inquit: Beginnt ein Segment mit Komma oder Kleinbuchstaben plus Sprechverb und Pronomen (, antwortete er knapp. / , murmelte sie leise.), ist es die abgetrennte Inquit-Formel des DAVORSTEHENDEN Zitats. Ordne jenes Zitat der Person zu, auf die das Pronomen zeigt: die zuletzt genannte Person passenden Geschlechts VOR dem Zitat (er → letzter Mann, sie → letzte Frau). Solche Zeilen dürfen niemals \'Unknown\' bleiben.\n15. MEHRERE KANDIDATEN in der Nähe: Stehen mehrere bekannte Namen im Umfeld, entscheide in dieser Reihenfolge: (a) streiche alle nach Regel 13 ausgeschlossenen (Zuhörer/Angeredete); (b) bevorzuge, wer grammatisches SUBJEKT eines Sprech- oder Handlungsverbs ist, gegenüber jemandem in Objekt- oder Präpositionalstellung; (c) prüfe, wessen Wissensstand, Rolle und Sprechweise zum Inhalt passt; (d) nutze das Ping-Pong-Prinzip aus Regel 6. Erst wenn danach zwei gleichwertige Kandidaten bleiben, ist \'Unknown\' zulässig.';
const unknownRuleRe = /(\n\d+\. 'Unknown' NUR,[^\n]*'Unknown'\.)/;
if (unknownRuleRe.test(p)) {
p = p.replace(unknownRuleRe, `$1\n${newRules}`);
} else {
p += `\n${newRules}`;
}
}
// Quote marks belong to the dialogue segment, never the adjacent
// narration segment — fixes a stray »/« ending up as its own orphaned
// "NARRATOR" row right before/after a real dialogue turn.
if (!/gehören IMMER zum Dialog-Segment/.test(p)) {
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, EINSCHLIESSLICH der Anführungszeichen!",
"- Anführungszeichen gehören IMMER zum Dialog-Segment, NIEMALS zum Narration-Segment davor oder danach: Das schließende « am Ende einer Figurenrede gehört ans ENDE des dialogue-Segments, nicht an den Anfang des folgenden narration-Segments. Das öffnende » am Anfang einer Figurenrede gehört an den ANFANG des dialogue-Segments, nicht ans Ende des vorherigen narration-Segments. Ein narration-Segment darf NIE mit einem einzelnen », „, ‚ oder › beginnen oder enden, und NIE mit einem einzelnen «, \", ' oder ‹ enden oder beginnen — verschiebe das Zeichen ins richtige Nachbar-Segment.\n- 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, EINSCHLIESSLICH der Anführungszeichen!"
);
}
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-left' : 'mdi-chevron-right');
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', async () => {
const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true });
if (!ok) return;
applyPromptAndRun(audiobookCast);
});
panel.querySelector('#ab-cv-status-msg').textContent = 'Ready to identify characters.';
} 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;
};
// Small round avatar for character pickers/lists — the character's saved
// portrait (same source as the profile detail panel) when one exists,
// otherwise the colour-keyed initial-letter dot used everywhere else.
const _abAvatarHtml = (name, color) => {
const img = recordForName(name)?.image;
const c = color || colorFor(name);
return img
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${c}"><img src="${escHtml(img)}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${c}">${escHtml((name || '?')[0].toUpperCase())}</span>`;
};
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-quotes')?.addEventListener('click', () => audiobookToggleQuotes());
{ const _qb = panel.querySelector('#ab-cv-quotes'); if (_qb) { _qb.classList.toggle('is-on', _abShowQuotes); _qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } }
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>
<button class="ab-page-nav-btn ab-cv-quote-toggle" id="ab-cv-quotes" type="button" title="Show quotation marks"><span class="mdi mdi-format-quote-close"></span></button>`;
_abTopbar.appendChild(_abFontCtl);
// Review navigation sits in the toolbar next to the other feed controls
// rather than floating over the text: stepping through uncertain lines is
// ordinary navigation, same as paging, and a floating overlay covered the
// last line of the page it sent you to.
const _abReviewCtl = document.createElement('div');
_abReviewCtl.className = 'ab-review-ctl';
_abReviewCtl.id = 'ab-cv-review';
_abReviewCtl.hidden = true;
_abReviewCtl.innerHTML = `
<button class="ab-page-nav-btn" id="ab-cv-review-prev" type="button" title="Previous line needing review (Shift+F2)"><span class="mdi mdi-chevron-left"></span></button>
<span class="ab-review-jump" title="How many lines still need a look at the current threshold"><span class="mdi mdi-alert-circle-outline"></span> <span id="ab-cv-review-count">review</span></span>
<button class="ab-page-nav-btn" id="ab-cv-review-next" type="button" title="Next line needing review (F2)"><span class="mdi mdi-chevron-right"></span></button>
<select id="ab-cv-review-level" title="Which lines count as needing review" aria-label="Review level">
<option value="96">Not Confident</option>
<option value="69">Doubtful + Guess</option>
<option value="wrong">Marked wrong</option>
</select>`;
// First in the toolbar, not last: on a book with 1400 flagged lines the
// review controls are the most-used thing here, and the left edge is where
// the eye lands. Everything after it is incidental by comparison.
_abTopbar.insertBefore(_abReviewCtl, _abTopbar.firstChild);
// ── Review navigation: hop between the lines worth checking ────────────────
// The point of the confidence score is that most lines don't need reading at
// all. This walks only the rows below the chosen threshold, so a reviewer
// never scrolls past a line the system is already confident about.
{
const revBox = panel.querySelector('#ab-cv-review');
const revCount = panel.querySelector('#ab-cv-review-count');
const revLevel = panel.querySelector('#ab-cv-review-level');
const uncertainRows = () => {
const limit = parseInt(revLevel?.value || '96', 10) || 96;
return [...panel.querySelectorAll('.ab-cv-row')].filter(r => {
// A human verdict beats the score, either way: confirmed lines and
// flagged-wrong lines both leave the "still to check" queue, so the
// triage pass always shrinks and never re-serves the same line.
if (revLevel?.value === 'wrong') return r.dataset.mark === 'wrong';
if (r.dataset.mark) return false;
const c = parseInt(r.dataset.conf ?? '', 10);
return !isNaN(c) && c < limit;
});
};
const refreshReview = () => {
if (!revBox) return;
const rows = uncertainRows();
const total = panel.querySelectorAll('.ab-cv-row[data-conf]').length;
revBox.hidden = total === 0;
const ok = panel.querySelectorAll('.ab-cv-row[data-mark="ok"]').length;
const bad = panel.querySelectorAll('.ab-cv-row[data-mark="wrong"]').length;
const tail = (ok || bad) ? ` · ${ok}✓ ${bad}✗` : ` of ${total}`;
if (revCount) revCount.textContent = rows.length
? `${rows.length} to check${tail}`
: ((ok || bad) ? `all clear${tail}` : `all ${total} look confident`);
};
let cursor = -1;
const goTo = (dir) => {
const rows = uncertainRows();
if (!rows.length) { toast('No lines below the review threshold', 'success'); return; }
// Resume from whatever is on screen rather than from wherever the last
// jump left off, so scrolling by hand doesn't desync the cursor.
const mid = panel.querySelector('.ab-cv-feed')?.getBoundingClientRect();
if (mid) {
const firstBelow = rows.findIndex(r => r.getBoundingClientRect().top > mid.top + 4);
if (dir > 0) cursor = firstBelow === -1 ? rows.length - 1 : firstBelow - 1;
else cursor = firstBelow === -1 ? rows.length : firstBelow;
}
cursor = dir > 0 ? Math.min(cursor + 1, rows.length - 1) : Math.max(cursor - 1, 0);
const row = rows[cursor];
if (!row) return;
_abScrollIntoView(row, { block: 'center' });
panel.querySelectorAll('.ab-cv-row.ab-review-focus').forEach(r => r.classList.remove('ab-review-focus'));
row.classList.add('ab-review-focus');
if (revCount) revCount.textContent = `${cursor + 1} / ${rows.length} to check`;
};
panel.querySelector('#ab-cv-review-next')?.addEventListener('click', () => goTo(1));
panel.querySelector('#ab-cv-review-prev')?.addEventListener('click', () => goTo(-1));
revLevel?.addEventListener('change', () => { cursor = -1; refreshReview(); });
// Attached to the document, not the panel: a <div> never receives keydown
// unless it happens to hold focus, so panel-scoped shortcuts silently did
// nothing. Guarded on the panel being on screen instead.
const _abTopSpeakers = () => {
const c = new Map();
for (const sg of (_audiobook.segments || [])) {
if (sg?.type !== 'dialogue') continue;
const n = String(sg.speaker || '').trim();
if (!n || /^(unknown|unbekannt|narrator)$/i.test(n)) continue;
c.set(n, (c.get(n) || 0) + 1);
}
return [...c.entries()].sort((a, b) => b[1] - a[1]).map(x => x[0]);
};
const markChecked = (row, mark = 'ok') => {
const sg = row && row.__seg;
if (!sg) return;
sg.confMark = mark || null;
sg.confChecked = mark === 'ok'; // kept so older drafts stay readable
if (mark) row.dataset.mark = mark; else delete row.dataset.mark;
const b = row.querySelector('.ab-cv-conf');
if (b) b.outerHTML = _abConfBadgeHtml(sg);
const cb = row.querySelector('.ab-cv-check');
if (cb) cb.outerHTML = _abCheckBtnHtml(sg);
};
const assignSpeaker = (row, name) => {
const sg = row && row.__seg;
if (!sg) return;
sg.speaker = name;
sg.type = /^narrator$/i.test(name) ? 'narration' : 'dialogue';
if (sg.type === 'narration') sg.emotion = '';
const idx = (_audiobook.segments || []).indexOf(sg);
if (idx >= 0) {
const { score, why } = _abSegmentConfidence(_audiobook.segments, idx);
const band = _abCalibrateConfidence(score);
sg.conf = band.pct; sg.confLabel = band.label; sg.confRaw = score; sg.confWhy = why.join('; ');
row.dataset.conf = String(sg.conf);
}
const spkEl = row.querySelector('.ab-cv-spk');
if (spkEl) {
const { speakerName } = _abRowSpeaker(sg);
spkEl.textContent = speakerName;
spkEl.style.color = colorFor(speakerName);
}
row.classList.toggle('is-narr', sg.type === 'narration');
};
const persist = () => {
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
refreshReview();
};
document.addEventListener('keydown', (e) => {
if (!panel.isConnected || panel.offsetParent === null) return; // panel not on screen
const t = e.target;
if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const focused = panel.querySelector('.ab-cv-row.ab-review-focus');
const isArrow = e.key.startsWith('Arrow');
// Plain arrows are hijacked ONLY once a review is under way, so ordinary
// scrolling still works until the reviewer actually starts stepping
// through lines. ArrowRight always starts one.
if (isArrow && !focused && e.key !== 'ArrowRight') return;
if (e.key === 'ArrowRight' || e.key === 'F2') { e.preventDefault(); goTo(e.shiftKey ? -1 : 1); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); goTo(-1); return; }
if (!focused) return;
// ↑ = the attribution is right ↓ = it is wrong (queued for a fixing pass)
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
markChecked(focused, e.key === 'ArrowUp' ? 'ok' : 'wrong');
persist();
goTo(1);
return;
}
// n = Narrator, 1-9 = the Nth most-spoken character. Assigning IS the
// fix, so it also settles the line and advances.
if (/^[0-9n]$/i.test(e.key)) {
e.preventDefault();
const name = /n/i.test(e.key) || e.key === '0' ? 'Narrator' : _abTopSpeakers()[parseInt(e.key, 10) - 1];
if (!name) { toast(`No character #${e.key} in the cast`, 'error'); return; }
assignSpeaker(focused, name);
markChecked(focused, 'ok');
persist();
toast(`\u2192 ${name}`, 'success');
goTo(1);
return;
}
if (/^u$/i.test(e.key)) { e.preventDefault(); markChecked(focused, null); persist(); } // undo the verdict
});
panel._abRefreshReview = refreshReview;
setTimeout(refreshReview, 0);
}
{ const _qb = _abFontCtl.querySelector('#ab-cv-quotes');
if (_qb) { _qb.addEventListener('click', () => audiobookToggleQuotes());
_qb.classList.toggle('is-on', _abShowQuotes);
_qb.title = _abShowQuotes ? 'Hide quotation marks' : 'Show quotation marks'; } }
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');
_abScrollIntoView(rows[_abNavIdx], { 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 = _abMarkQuotes(highlightText(_abDisplayText(seg)));
});
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 all character roles“ 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 lineCount = _abStr(sh.line_count);
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.title) ? `<div class="lcd-aliases">${escHtml(_abStr(sh.title))}</div>` : ''}
${_abStr(sh.archetype) ? `<div class="lcd-archetype">${escHtml(_abStr(sh.archetype))}</div>` : ''}
<div class="lcd-tier-gender"><span class="lcd-tier">${tierLabel}</span>${lineCount ? `<span class="lcd-linecount">${escHtml(lineCount)} lines</span>` : ''}</div>
</div>
<div class="ab-cd-header-actions">
<button class="ab-cd-refine-btn" title="Refine this character only"><span class="mdi mdi-refresh"></span> Auto refine</button>
<button class="ab-cd-edit-btn" title="Charakterblatt bearbeiten"><span class="mdi mdi-pencil-outline"></span></button>
</div>
</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('Vorname',sh.first_name,'first_name')}${field('Nachname',sh.last_name,'last_name')}${field('Geschlecht',sh.gender,'gender')}${field('Titel',sh.title,'title')}${field('Beruf / Rolle',sh.profession,'profession')}${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); });
detail.querySelector('.ab-cd-refine-btn')?.addEventListener('click', async () => {
const name = String(rec.name || '').trim();
if (!name) return;
if (typeof window.audiobookRecastSelectedCharacters === 'function') return window.audiobookRecastSelectedCharacters([name]);
if (typeof window.csForReaderSelective === 'function') return window.csForReaderSelective([name]);
toast('Character refinement is unavailable right now', 'error');
});
// Pencil edit toggle
// One timer PER FIELD, not a single shared one — a shared timer meant
// editing field A then switching to field B within 900ms cancelled A's
// still-pending save with nothing to replace it, silently dropping that
// edit (same bug already fixed once this session in the Library's own
// detail page — this is the casting view's separate copy of it).
const _saveTimers = new Map();
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() {
const sk = el.dataset.sk;
clearTimeout(_saveTimers.get(sk));
_saveTimers.set(sk, setTimeout(async () => {
if (!rec.sheet) rec.sheet = {};
rec.sheet[sk] = el.textContent.trim();
if (['aliases', 'first_name', 'last_name', 'full_name', 'title'].includes(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) {
_abScrollIntoView(pages[pg-1].pageDiv, { 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');
_abScrollIntoView(_abCharRows[0], { 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');
_abScrollIntoView(pool[_abNavIdx], { 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); } });
};
let _abRosterFilter = '';
let _abRosterSort = 'count';
const renderRoster = () => {
const q = _abRosterFilter.trim().toLowerCase();
let items = [...roster.entries()].filter(([n, info]) => info.count > 0 && (!q || n.toLowerCase().includes(q)));
items.sort(_abRosterSort === 'alpha' ? (a, b) => a[0].localeCompare(b[0]) : (a, b) => b[1].count - a[1].count);
if (!items.length) {
// A bare "reading…" label reads as static text, not as "still working" —
// reuse the same skeleton rows shown on initial panel load so a live cast
// with no characters found yet visibly looks like it's loading.
chars.innerHTML = q ? `<span class="ab-cv-empty">No matches</span>` : `<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>`;
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)}">
${_abAvatarHtml(n, color)}
<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); });
});
};
(() => {
const searchInp = panel.querySelector('#ab-cv-side-search');
const sortSel = panel.querySelector('#ab-cv-side-sort');
try { _abRosterSort = localStorage.getItem('ttsvc_ab_side_sort') || 'count'; } catch (_) {}
if (sortSel) sortSel.value = _abRosterSort;
let _t = null;
searchInp?.addEventListener('input', () => {
clearTimeout(_t);
_t = setTimeout(() => { _abRosterFilter = searchInp.value; renderRoster(); }, 120);
});
sortSel?.addEventListener('change', () => {
_abRosterSort = sortSel.value;
try { localStorage.setItem('ttsvc_ab_side_sort', _abRosterSort); } catch (_) {}
renderRoster();
});
})();
// 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();
// Other already-recognized roster names, offered as pick-to-merge targets
// (e.g. "Darag" also known as "Schmied" — the LLM split one character
// into two roster entries) instead of only accepting a free-text alias.
const otherNames = [...roster.entries()]
.filter(([n, info]) => info.count > 0 && n.toLowerCase() !== name.toLowerCase() && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n))
.sort((a, b) => b[1].count - a[1].count);
const listId = 'ab-alias-roster-list';
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 — or pick a recognized character below" autocomplete="off" list="${listId}">
<datalist id="${listId}">
${otherNames.map(([n, info]) => `<option value="${escHtml(n)}">${escHtml(n)} (${info.count} lines)</option>`).join('')}
</datalist>
${otherNames.length ? `<div class="ab-alias-popup-hint">Or merge with an already-found character:</div>
<div class="ab-alias-popup-list">
${otherNames.slice(0, 8).map(([n, info]) => `<button type="button" class="ab-alias-merge-opt" data-name="${escHtml(n)}"><span class="ab-alias-merge-name">${_abAvatarHtml(n, info.color)}${escHtml(n)}</span><span class="ab-alias-merge-count">${info.count}</span></button>`).join('')}
</div>` : ''}
<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 - 280) + 'px';
el.style.top = Math.min(rect.bottom + 4, window.innerHeight - 340) + 'px';
const inp = el.querySelector('.ab-alias-popup-inp');
setTimeout(() => inp.focus(), 30);
const otherByLower = new Map(otherNames.map(([n]) => [n.toLowerCase(), n]));
const doSave = async (mergeName) => {
const alias = (mergeName || inp.value.trim());
if (!alias) { _abCloseAliasPopup(); return; }
const mergeTarget = otherByLower.get(alias.toLowerCase());
_abCloseAliasPopup();
if (mergeTarget) {
// Merging rewrites every matching segment (fast, plain array loop) —
// only THOSE rows need to be redrawn (a name change/recolor), not the
// whole feed. Rebuilding all thousands of rows for a 2-line merge is
// what used to make even a tiny merge take as long as opening a
// freshly cast book, and freeze the tab along the way. The affected
// rows are scattered across the feed rather than contiguous, so
// they're patched in place (_abPatchScatteredRows) instead of a full
// redraw, still chunked across animation frames for the rare case of
// a merge touching hundreds of lines.
const busy = _abShowBusyOverlay(`Merging "${mergeTarget}" into ${name}…`, true);
await new Promise(r => requestAnimationFrame(r));
try {
const book = window.readerState?.title || '';
const rec = await clUpsert(book, { name, aliases: alias });
if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; }
const { changed: n, segs } = _abMergeCharacters(mergeTarget, name);
if (n) {
const patched = await _abPatchScatteredRows(segs, (done, total) => busy.setProgress(done, total));
if (!patched) await _abRedrawSegmentsChunked(_abActiveSegments().arr, (done, total) => busy.setProgress(done, total));
_abPersistManualEdit();
}
toast(n ? `Merged "${mergeTarget}" into ${name} (${n} line${n !== 1 ? 's' : ''})` : `"${alias}" added as an alias for ${name}`, 'success');
} catch (err) {
toast('Could not merge: ' + (err.message || err), 'error');
} finally {
busy.remove();
}
} else {
try {
const book = window.readerState?.title || '';
const rec = await clUpsert(book, { name, aliases: alias });
if (rec) { registerCharacterRecord(rec); if (_hlCache) _hlCache.ver = -1; }
renderRoster();
toast(`"${alias}" added as an alias for ${name}`, 'success');
} catch (err) {
toast('Could not save alias: ' + (err.message || err), 'error');
}
}
};
el.querySelector('.ab-alias-save').addEventListener('click', () => doSave());
el.querySelector('.ab-alias-cancel').addEventListener('click', () => _abCloseAliasPopup());
el.querySelectorAll('.ab-alias-merge-opt').forEach(btn => {
btn.addEventListener('click', () => doSave(btn.dataset.name));
});
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); doSave(); }
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;
}
// Reassign every segment currently attributed to fromName over to intoName
// — used when the LLM split one character into two roster entries (e.g.
// "Darag" / "Schmied" for the same person) and the user picks the other
// entry from the alias popup instead of typing a plain-text alias.
// Only relabels segments (fast, plain array loop) — the caller is
// responsible for redrawing the feed afterward, since on a big book that
// part is the slow one and needs to run through the chunked path with
// visible progress instead of blocking the main thread outright.
function _abMergeCharacters(fromName, intoName) {
const active = _abActiveSegments();
if (!active.arr.length) return { changed: 0, arr: active.arr, segs: [] };
_abPushEditState(active.key, active.arr);
let changed = 0;
const segs = [];
for (const s of active.arr) {
if (s.type === 'dialogue' && s.speaker && s.speaker.toLowerCase() === fromName.toLowerCase()) {
s.speaker = intoName;
changed++;
segs.push(s);
}
}
return { changed, arr: active.arr, segs };
}
(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
// MAXROWS is permanently Infinity (see above — the full cast is kept for
// review), so the removal loop below can never actually run. It still
// used to query the ENTIRE feed (`querySelectorAll` over every row/note/
// divider in the whole book so far) on every single call regardless —
// confirmed live as a real O(n) cost repeated on every ~80-segment
// batch for the whole rest of the book, compounding into the kind of
// quadratic slowdown that crashed the tab partway through a long book.
// Only do that work at all if MAXROWS could ever actually be finite.
if (Number.isFinite(MAXROWS)) {
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);
_abScrollIntoView(pageDiv, { 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) _abScrollIntoView(firstPage, { 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;
_abScrollIntoView(label.closest('.ab-cv-page'), { 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-row"><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>${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)}</span>
<span class="ab-cv-txt">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</span>`;
if (typeof s.conf === 'number') row.dataset.conf = String(s.conf);
if (s.confChecked) row.dataset.checked = '1';
// 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;
};
// Like _abPatchRowRange, but for segments scattered across the feed instead
// of one contiguous block (e.g. every line a merged character spoke, which
// can be anywhere across a thousand-segment book). _abPatchRowRange inserts
// all replacements before the FIRST old row, which would bunch scattered
// rows together in the wrong place — this replaces each row in-place
// instead, so a merge only touches the handful of rows that actually
// changed rather than redrawing the entire feed.
const _abPatchScatteredRows = (segs, onProgress) => new Promise(resolve => {
if (!segs || !segs.length) { resolve(true); return; }
const bySeg = new Set(segs);
const targets = [...feed.querySelectorAll('.ab-cv-row')].filter(r => r.__seg && bySeg.has(r.__seg));
if (!targets.length) { resolve(false); return; }
let i = 0;
const step = () => {
const end = Math.min(i + 150, targets.length);
for (; i < end; i++) targets[i].replaceWith(_abRowFromSegment(targets[i].__seg));
if (onProgress) onProgress(i, targets.length);
if (i < targets.length) {
requestAnimationFrame(step);
} else {
_abRecountRoster(_abActiveSegments().arr);
_abClearHL();
_abUpdatePageNav();
resolve(true);
}
};
requestAnimationFrame(step);
});
const _abPatchRowRange = (oldRows, newSegs, activeArr) => {
const rows = (oldRows || []).filter(Boolean);
if (!rows.length) return false;
const parent = rows[0].parentNode;
if (!parent || rows.some(r => r.parentNode !== parent)) return false;
const beforeCounts = _abRosterCountsFromSegs(rows.map(r => r.__seg).filter(Boolean));
const afterCounts = _abRosterCountsFromSegs(newSegs || []);
const frag = document.createDocumentFragment();
for (const seg of newSegs || []) frag.appendChild(_abRowFromSegment(seg));
parent.insertBefore(frag, rows[0]);
for (const row of rows) row.remove();
_abApplyRosterDelta(beforeCounts, afterCounts);
_abQueueRosterRender();
_abClearHL();
_abUpdatePageNav();
return true;
};
const _abAdjacentRow = (row, dir) => {
let cur = dir < 0 ? row?.previousElementSibling : row?.nextElementSibling;
while (cur && !(cur.classList && cur.classList.contains('ab-cv-row'))) {
cur = dir < 0 ? cur.previousElementSibling : cur.nextElementSibling;
}
return cur || null;
};
// 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 = _abMarkQuotes(highlightText(_abDisplayText(s)));
};
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 _abRosterCountsFromSegs = (segs) => {
const counts = new Map();
for (const s of segs || []) {
const { speakerName } = _abRowSpeaker(s || {});
counts.set(speakerName, (counts.get(speakerName) || 0) + 1);
}
return counts;
};
const _abApplyRosterCounts = (counts) => {
for (const info of roster.values()) info.count = 0;
for (const [name, count] of counts || []) {
const info = roster.get(name) || { count: 0, color: colorFor(name) };
info.count = count;
roster.set(name, info);
}
_audiobook.roster = [...roster.entries()]
.filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n))
.map(([n]) => n);
};
const _abApplyRosterDelta = (beforeCounts, afterCounts) => {
const names = new Set([...(beforeCounts || new Map()).keys(), ...(afterCounts || new Map()).keys()]);
for (const name of names) {
const before = beforeCounts?.get(name) || 0;
const after = afterCounts?.get(name) || 0;
if (before === after) continue;
const info = roster.get(name) || { count: 0, color: colorFor(name) };
info.count = Math.max(0, (info.count || 0) + (after - before));
roster.set(name, info);
}
_audiobook.roster = [...roster.entries()]
.filter(([n, info]) => info.count > 0 && !/^Narrator$/i.test(n) && !/^Unknown|Unbekannt/i.test(n))
.map(([n]) => n);
};
let _abRosterRenderRaf = 0;
const _abQueueRosterRender = () => {
if (_abRosterRenderRaf) return;
_abRosterRenderRaf = requestAnimationFrame(() => {
_abRosterRenderRaf = 0;
renderRoster();
});
};
const _abRecountRoster = (segs) => {
_abApplyRosterCounts(_abRosterCountsFromSegs(segs || []));
_abQueueRosterRender();
};
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);
}
};
// Same as _abRedrawSegments, but builds the feed in batches across several
// animation frames instead of one long synchronous loop — a full redraw on
// a big book (thousands of rows, each running highlightText's regex pass)
// blocks the main thread long enough to trigger the browser's own "Page
// Unresponsive" warning. Used by operations that redraw the WHOLE feed at
// once (character merge); the normal single-segment edits stay on the
// plain synchronous path since those are cheap regardless.
// Two overlapping calls used to both survive: each one's own closured
// `i`/`list` kept its rAF chain running independently of the OTHER call's
// `feed.innerHTML = ''`, so a second (perfectly legitimate, non-recursive)
// trigger — e.g. Studio re-entering the Characters phase while an earlier
// restore's rAF batches hadn't finished yet — raced the first and both
// ended up appending their own full set of rows into the same feed.
// Confirmed live: every row duplicated exactly once (same segment object,
// two DOM nodes), which is exactly what two full, uncoordinated passes
// over the same segment list would produce. A generation token lets each
// in-flight chain notice it's been superseded and stop appending instead
// of racing the newer one.
let _abRedrawGen = 0;
const _abRedrawSegmentsChunked = (segs, onProgress, batchSize = 150) => new Promise(resolve => {
const selectedChar = (!_abBar.hidden && _abBar.dataset.charName) ? _abBar.dataset.charName : '';
const myGen = ++_abRedrawGen;
feed.innerHTML = '';
_abCurPage = null;
const list = segs || [];
let i = 0, lastPage = null;
const step = () => {
if (myGen !== _abRedrawGen) { resolve(); return; } // superseded by a newer redraw — stop
const end = Math.min(i + batchSize, list.length);
for (; i < end; i++) {
const s = list[i];
if (s.page != null && s.page !== lastPage) {
_abNewPage('Page ' + s.page, s.page);
lastPage = s.page;
}
_abPage().appendChild(_abRowFromSegment(s));
}
if (onProgress) onProgress(i, list.length);
if (i < list.length) {
requestAnimationFrame(step);
} else {
_abRecountRoster(list);
_abClearHL();
_abUpdatePageNav();
if (selectedChar) { _abBar.hidden = true; _abSelectChar(selectedChar); }
resolve();
}
};
requestAnimationFrame(step);
});
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 => {
// Don't auto-close on a click/drag inside the passage text either — that's
// exactly how you feed a name into an already-open popup (see the feed's
// click and mouseup handlers below), so closing here on mousedown would
// kill the popup before that logic ever runs.
if (assignPopup.style.display !== 'none' && !assignPopup.contains(e.target)
&& !e.target.closest('.ab-cv-spk') && !e.target.closest('.ab-cv-txt')) {
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);';
const img = recordForName(n)?.image;
btn.innerHTML = (img
? `<span style="width:18px;height:18px;border-radius:50%;flex-shrink:0;overflow:hidden;"><img src="${escHtml(img)}" alt="" style="width:100%;height:100%;object-fit:cover;"></span>`
: `<span style="width:8px;height:8px;border-radius:50%;background:${info.color};flex-shrink:0;"></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;
}
const leftRow = dir < 0 ? _abAdjacentRow(row, -1) : row;
const rightRow = dir < 0 ? row : _abAdjacentRow(row, 1);
_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;
if (!leftRow || !rightRow || leftRow.parentNode !== rightRow.parentNode) {
_abRedrawSegmentsChunked(active.arr);
} else if (!_abPatchRowRange([leftRow, rightRow], [merged], active.arr)) {
_abRedrawSegmentsChunked(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 chkBtn = e.target.closest('.ab-cv-check');
if (chkBtn) {
e.preventDefault();
e.stopPropagation();
const row = chkBtn.closest('.ab-cv-row');
const seg = row && row.__seg;
if (seg) {
seg.confChecked = !seg.confChecked;
if (seg.confChecked) row.dataset.checked = '1'; else delete row.dataset.checked;
const badge = row.querySelector('.ab-cv-conf');
if (badge) badge.outerHTML = _abConfBadgeHtml(seg);
chkBtn.outerHTML = _abCheckBtnHtml(seg);
const root = document.querySelector('.ab-castpanel-inline');
if (root && typeof root._abRefreshReview === 'function') root._abRefreshReview();
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
}
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;
}
// This guard (rather than just isCollapsed) stops the click that follows
// a drag's mouseup from also firing the word-click branch below.
if (_abJustHandledSelection) { _abJustHandledSelection = false; return; }
const txtEl = e.target.closest('.ab-cv-txt');
if (txtEl && window.getSelection().isCollapsed) {
// Clicking a name/word never opens or retargets a popup by itself —
// only the speaker label (handled above) selects a paragraph. This
// requires an assign popup to already be open (via that label click,
// or a double-click fast-assign): a click on a name anywhere in the
// text then just borrows that name into the search box, targeting
// whichever paragraph was actually selected. Without this, clicking a
// name that happens to live in some OTHER paragraph's text (the
// common way to "grab a name I see") silently reassigned THAT
// paragraph instead of the one the user meant to fix.
if (!assignModeRow) return;
const row = txtEl.closest('.ab-cv-row');
const nameHit = e.target.closest('.ab-name-hit');
const clickedWord = nameHit ? (nameHit.dataset.name || nameHit.textContent.trim())
: _abWordRangeAtPoint(e.clientX, e.clientY)?.word;
if (assignModeRow !== row) {
if (clickedWord) {
const inp = assignPopup.querySelector('input');
inp.value = clickedWord;
inp.dispatchEvent(new Event('input'));
inp.focus();
}
return;
}
if (nameHit) {
_abOpenAssignPopup(row, nameHit, clickedWord);
} 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);
if (!_abPatchRowRange([row], newSegs, arr)) _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;
// A short selection is ambiguous — could be a name to assign, or a short
// quoted line ("»Henker«.") the user is dragging out to split into its
// own Unknown-speaker segment. Quote marks are a strong tell for the
// latter (names don't come wrapped in guillemets/quotes), so leave those
// alone for the "Split text to Unknown Speaker" button (wired via
// selectionchange below) instead of hijacking them into the assign popup.
if (/^[»„“"'‘]/.test(text)) return;
// Dragging to select a name only ever feeds an ALREADY-open popup (opened
// by clicking a paragraph's speaker label) — it must not open/retarget a
// popup for whichever paragraph happens to contain the dragged text,
// which used to reassign the wrong paragraph (the one you dragged a name
// out of, not the one you actually meant to fix).
if (!assignModeSeg) return;
_abJustHandledSelection = true; // stop the click that follows this mouseup from also firing
assignName(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
_abScrollIntoView(target, { 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();
// Chunked — this runs on every draft restore of a previously-cast book,
// so a large cast (thousands of rows, each running highlightText's regex
// pass) must not block the main thread synchronously (see complete()).
_abRedrawSegmentsChunked(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 (collapsed by default, toggled via the chevron): left =
// the passage being read, right = the LLM's live thinking/raw-output
// stream (fed by view.thinking via the SSE endpoint).
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>
<button class="ab-cv-expand-btn" type="button" title="Show passage &amp; thinking" aria-expanded="false"><span class="mdi mdi-chevron-right"></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"><span class="mdi mdi-text-long"></span> Passage</div>
<pre class="ab-cv-llm-pre">${escHtml(text)}</pre>
</div>
<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>
`;
this._procRow.querySelector('.ab-cv-expand-btn').addEventListener('click', function () {
const split = this.closest('.ab-cv-llm-row').querySelector('.ab-cv-llm-split');
const open = this.getAttribute('aria-expanded') === 'true';
this.setAttribute('aria-expanded', String(!open));
split.hidden = open;
const icon = this.querySelector('span');
if (icon) icon.className = open ? 'mdi mdi-chevron-right' : 'mdi mdi-chevron-down';
});
_abPage().appendChild(this._procRow);
trim();
},
// Live LLM output for the passage currently processing. Streams into the
// (collapsed-by-default) thinking column; the user reveals it via the
// chevron button set up in processing() above.
thinking(delta) {
if (!this._procRow || !delta || this._thinkDone) return;
this._thinkRaw = (this._thinkRaw || '') + delta;
const think = this._procRow.querySelector('.ab-cv-think');
const label = this._procRow.querySelector('.ab-cv-think-label');
if (!think) return;
// 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, closeAt = -1;
if (openAt >= 0) {
const afterOpen = this._thinkRaw.slice(openAt + '<think>'.length);
closeAt = afterOpen.indexOf('</think>');
shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen;
isReal = 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">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</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">${_abMarkQuotes(highlightText(_abDisplayText(s)))}</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) {
_abHideCastingControl();
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);
// Chunked (not the plain synchronous redraw) — reopening a big already-cast
// book means thousands of rows each running highlightText's regex pass,
// which on the synchronous path blocks the main thread long enough to
// trigger the browser's "Page Unresponsive" warning and reads to the user
// as if casting silently restarted on its own.
if (completedSegments) _abRedrawSegmentsChunked(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>`
: '';
// Three grouped flyout buttons instead of eight flat ones — Identify
// (speaker attribution passes), Cast Characters (sheet generation),
// Cast (view/export) — plus Continue casting when resumable and Open
// Script Rehearser stay as direct one-click actions since they're the
// most common next step, not variants of each other.
foot.innerHTML = `<span style="flex:1 1 220px; min-width:220px;"></span>${continueBtnHtml}<button type="button" class="btn-secondary btn-sm ab-foot-trigger" id="ab-cv-menu-identify" style="margin-right:8px;" title="Scan or verify character speakers"><span class="mdi mdi-text-search"></span> Identify Characters <span class="mdi mdi-chevron-down"></span></button><button type="button" class="btn-secondary btn-sm ab-foot-trigger" id="ab-cv-menu-cast" style="margin-right:8px;" title="Generate character sheet profiles"><span class="mdi mdi-account-multiple-plus-outline"></span> Cast Characters <span class="mdi mdi-chevron-down"></span></button><button type="button" class="btn-secondary btn-sm ab-foot-trigger" id="ab-cv-menu-viewcast" style="margin-right:8px;" title="View or export the cast"><span class="mdi mdi-eye-outline"></span> Cast <span class="mdi mdi-chevron-down"></span></button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Open Script Rehearser to assign voices to each character and rehearse the cast"><span class="mdi mdi-drama-masks"></span> Open Script Rehearser</button>`;
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Sprecherzuordnung eines bereits analysierten deutschen Hörbuch-Textes. Eine erste KI hat jedem Segment bereits einen Sprecher zugewiesen ('Narrator' oder einen Charakternamen, ggf. 'Unknown'). Du bekommst diese Zuweisung NICHT — du siehst nur den Text und musst selbst neu urteilen, ob die Zeile zu Narration oder zu gesprochener Rede eines Charakters gehört, und falls Dialog: zu welchem. Das ist eine Gegenprobe, keine Neuklassifizierung von Grund auf: dein Job ist nicht "wer könnte das gesagt haben", sondern "ist diese Zeile wirklich Narration, oder wurde hier gesprochene Rede in den Erzähltext hineingezogen?".
WORAUF DU PRÜFST — IN DIESER REIHENFOLGE (höchste Priorität zuerst):
1. ZUERST alle 'Unknown'-Zeilen: Löse den Sprecher über Kontext auf (Inquit-Formeln, Ping-Pong-Wechsel in Zwiegesprächen, Adressaten-Bezug, zuletzt genannte Person). 'Unknown' bleibt nur, wenn wirklich keine Ableitung möglich ist. Das ist die dringendste Kategorie — eine Zeile ohne jeden Sprecher ist schlimmer als eine falsch zugeordnete.
2. DANACH Zeilenketten mit demselben Sprecher in Folge (2 oder mehr aufeinanderfolgende Zeilen — egal ob 'Narrator' oder ein Charakter): Das ist die zweithäufigste Fehlerquelle. Prüfe jede Zeile in einer solchen Kette einzeln: Ist das wirklich durchgehend derselbe Sprecher, oder wurde eine Sprecherwechsel-Zeile (z.B. eine Antwort einer anderen Figur, oder gesprochene Rede ohne erhaltene Anführungszeichen) fälschlich in die Kette hineingezogen? Prüfe besonders bei 'Narrator'-Ketten: Ist wirklich jede Zeile neutrale Erzählerbeschreibung, oder ist tatsächlich eine Aussage dabei, die ein Charakter so gesagt haben könnte — nur ohne erhaltene Anführungszeichen (typisch bei PDF/OCR-Extraktion)? Prüfe Tonfall, Wortwahl und Perspektive: Klingt eine der Zeilen wie eine direkte Aussage/Reaktion einer Person in der Szene (Ich-Form, Anrede, Ausruf, Frage), nicht wie neutrale Erzählerbeschreibung? Dann ist SIE Dialog, nicht Narration — auch wenn keine » « vorhanden sind, und auch wenn die Zeilen davor/danach in derselben Kette echt narrativ sind.
3. ZULETZT ein allgemeiner Plausibilitäts-Check bei allen übrigen, bereits vermuteten Sprechern: Passt Tonfall/Wortwahl der Zeile zu dem, was über diese Figur im bisherigen Text bekannt ist (Sprechweise, Haltung, Beziehung zu anderen Figuren)? Wenn eine Zeile im Kontext eindeutig zu einer ANDEREN, im Text erkennbaren Person passt, korrigiere sie dorthin.
4. Echte Narration NICHT anfassen: Reine Beschreibung, Handlung, Übergänge, Kapitelanfänge bleiben 'Narrator'. Nur weil eine Figur erwähnt wird, wird der Satz nicht zu ihrem Dialog.
DEDUKTIONS-WERKZEUGE für die Sprecherzuordnung:
- Doppelpunkt-Regel: Endet der Satz davor mit ":", spricht dessen Subjekt das Folgende.
- Nachgestellte Zuordnung: Folgt einer als 'Narrator' markierten Zeile direkt eine kurze Inquit-Formel (Sprechverb + Name/Pronomen, z.B. "entgegnete Oberst von Blautann.", ", meldete sich Lysandra zu Wort.", "murmelte er."), dann war die VORHERIGE Zeile in Wahrheit das Zitat dieser Person — auch ohne Anführungszeichen. Das ist der häufigste Fehler der ersten Zuordnung: gesprochene Rede wird fälschlich als Narration markiert, weil die Anführungszeichen bei der Extraktion verloren gingen.
- Handlungs-Hinweise (Action Beats): Wer unmittelbar vor oder nach einer fraglichen Zeile AKTIV HANDELT (aufblickt, sich umdreht, den Kopf schüttelt), ist der wahrscheinlichste Sprecher dieser Zeile.
- ZUHÖRER-AUSSCHLUSS (negative Evidenz): Wahrnehmungs- und Reaktionsverben kennzeichnen den ZUHÖRER, nicht den Sprecher ("Marcian spürte ihre Tränen", "Marcian hörte darüber hinweg" → Marcian hört zu, jemand anderes spricht). Ebenso ist eine Person, die im Zitat per Vokativ ANGEREDET wird ("..., Marcian."), nicht der Sprecher. Prüfe diese Ausschlüsse ZUERST, bevor du einen Namen aus dem Nachbarsatz übernimmst — das ist die häufigste Fehlerquelle bei nicht zugeordneten Zeilen.
- Pronomen-Inquit bei geteilten Zitaten: Eine Zeile wie ", antwortete er knapp." gehört zum vorherigen Zitat; löse das Pronomen auf die zuletzt genannte Person passenden Geschlechts VOR dem Zitat auf (er → letzter Mann, sie → letzte Frau).
- Ping-Pong-Prinzip: Zwei Personen im Gespräch wechseln sich strikt ab, auch ohne Tags — verfolge die Kette zur letzten eindeutigen Nennung zurück.
- Pronomen-Auflösung: er/sie/es meint die zuletzt genannte Person passenden Geschlechts.
- Eine Rollenbezeichnung ('Ork', 'Der Fremde', 'Wächter') ist nur dann ein gültiger Sprecher, wenn die Person im Text keinen Eigennamen hat; sonst immer den Eigennamen verwenden.
FÜR JEDES SEGMENT AUSGABE:
- speaker: 'Narrator' für Narration, oder EXAKT der Name des Charakters (nutze den in der bekannten Charakterliste geführten Namen, nicht einen Spitznamen/Alias, falls der Kontext eindeutig zuordnet).
- 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.
- Erfinde NIEMALS Text. Lasse NIEMALS Wörter weg. Füge NIEMALS etwas hinzu.
- Mische NIEMALS Narration und Dialog in einem Segment.
- Sei konservativ: ändere eine Zeile nur, wenn du nach dieser Prüfung wirklich zu einem ANDEREN Ergebnis kommst als naheliegend wäre — nicht jede Zeile muss sich ändern.`;
window._abVerificationPrompt = verificationPrompt;
const runVerificationPass = () => {
// Deliberately NOT a copy of the first-pass attribution prompt. Pass 1
// classifies from scratch; this pass receives a segment that ALREADY
// has a label (a character's name, or Narrator) and has to judge
// whether that label actually holds up — a plausibility/confidence
// check rather than fresh attribution. It also runs on every Narrator
// segment (not just Unknown dialogue), specifically to catch spoken
// lines that got swallowed into narration on the first pass.
const choice = audiobookCurrentCastLlm(panel);
const savedChoice = audiobookSaveLlmChoice(choice.url, choice.model);
closePanel();
// repairNarration (suspect-scored narration only), NOT includeNarrator
// (every narration segment). On a real novel the brute-force variant
// queues ~1000 narration lines = ~190 sequential LLM calls, which in
// practice never finished — confirmed live, this pass sat at 0/1141
// for hours and was abandoned, so it repaired NOTHING. The scored
// filter targets ~265 lines (~65 calls), which does complete, and
// measured against the hand-corrected book it still catches 37 of the
// 51 real "dialogue swallowed into narration" cases. A pass that
// finishes and catches 73% beats one that never finishes at all.
audiobookRecastUnknown(savedChoice.url, savedChoice.model, { prompt: verificationPrompt, repairNarration: true });
};
// 3rd quality pass — deliberately NOT chunk-by-chunk like the two
// passes above (both re-read the source text in ~4000-char windows,
// since a whole book is far bigger than any context window). This one
// works purely over ALREADY-ATTRIBUTED lines already sitting in memory:
// for each character, gather every line credited to them from anywhere
// in the book and send that whole bundle to the LLM in one call, asking
// it to judge each line against the REST of that character's own
// established voice — catches a line that got misattributed to the
// right-sounding-in-isolation-but-wrong-in-context character, which a
// single passage window could never expose since it only ever sees
// that one line's immediate neighbours.
const runConsistencyPass = async () => {
const active = _abActiveSegments();
const segs = active.arr;
if (!segs.length) { toast('Nothing cast yet to check', 'error'); return; }
const byName = new Map();
segs.forEach((s, idx) => {
if (s.type !== 'dialogue' || !s.speaker) return;
if (/^Narrator$/i.test(s.speaker) || /^Unknown|Unbekannt/i.test(s.speaker)) return;
if (!byName.has(s.speaker)) byName.set(s.speaker, []);
byName.get(s.speaker).push(idx);
});
const MIN_LINES = 4; // can't judge a "pattern" from fewer lines than this
const MAX_LINES_PER_CALL = 60; // context-size ceiling for one character
const candidates = [...byName.entries()].filter(([, idxs]) => idxs.length >= MIN_LINES);
if (!candidates.length) { toast('No characters with enough lines yet to check for consistency', 'error'); return; }
const choice2 = audiobookCurrentCastLlm(panel);
const savedChoice2 = audiobookSaveLlmChoice(choice2.url, choice2.model);
closePanel();
_abPushEditState(active.key, segs);
const busy = _abShowBusyOverlay(`Checking voice consistency for ${candidates.length} character${candidates.length !== 1 ? 's' : ''}…`, true);
let checked = 0, flaggedTotal = 0, failedNames = [];
const touchedSegs = [];
const knownNames = [...roster.keys()];
try {
for (const [name, idxs] of candidates) {
busy.setProgress(checked, candidates.length);
// Evenly sample across the WHOLE arc (not just the first N
// appearances) when a major character has more lines than fit in
// one call, so their voice late in the book is represented too.
let sampleIdxs = idxs;
if (idxs.length > MAX_LINES_PER_CALL) {
const step = idxs.length / MAX_LINES_PER_CALL;
sampleIdxs = Array.from({ length: MAX_LINES_PER_CALL }, (_, i) => idxs[Math.floor(i * step)]);
}
const lines = sampleIdxs.map(i => ({ index: i, text: segs[i].text }));
try {
const r = await fetch('/api/audiobook-consistency-check', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
character: name, lines,
known_characters: knownNames.filter(n => n.toLowerCase() !== name.toLowerCase()),
llm_url: savedChoice2.url, model: savedChoice2.model,
}),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const data = await r.json();
const outliers = Array.isArray(data?.outliers) ? data.outliers : [];
for (const o of outliers) {
// The model is asked to echo back the real bracketed index,
// but sometimes returns its own sequential count instead
// (confirmed happening live) — verify against the quoted
// excerpt it was also asked to provide, and fall back to a
// text search across this character's OTHER lines if the
// index it gave doesn't actually land on one of them. Safer
// to skip a real correction than to misapply one to the
// wrong (innocent) segment.
let idx = o.index;
const quote = String(o.quote || '').trim().toLowerCase();
const matchesQuote = (i) => !quote || (segs[i]?.text || '').toLowerCase().includes(quote.slice(0, 40));
if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name || !matchesQuote(idx)) {
idx = quote ? sampleIdxs.find(i => segs[i].speaker === name && matchesQuote(i)) : undefined;
}
if (typeof idx !== 'number' || !segs[idx] || segs[idx].speaker !== name) continue;
const suggested = String(o.suggested_speaker || '').trim();
let newSpeaker = null;
if (!suggested || /^unknown|unbekannt$/i.test(suggested)) newSpeaker = 'Unknown';
else newSpeaker = knownNames.find(n => n.toLowerCase() === suggested.toLowerCase()) || null;
// Only ever reassign to an ALREADY-known name (or 'Unknown')
// — this pass never invents a new character.
if (!newSpeaker || newSpeaker === name) continue;
segs[idx].speaker = newSpeaker;
if (newSpeaker === 'Unknown') segs[idx].type = 'dialogue';
touchedSegs.push(segs[idx]);
flaggedTotal++;
}
} catch (err) {
failedNames.push(name);
console.error('[consistency check]', name, err);
}
checked++;
busy.setProgress(checked, candidates.length);
}
} finally {
busy.remove();
}
if (flaggedTotal) {
const patched = await _abPatchScatteredRows(touchedSegs, () => {});
if (!patched) await _abRedrawSegmentsChunked(active.arr);
_abPersistManualEdit();
}
const failSuffix = failedNames.length ? ` (${failedNames.length} character${failedNames.length !== 1 ? 's' : ''} failed to check: ${failedNames.slice(0, 5).join(', ')})` : '';
toast(
(flaggedTotal
? `Consistency check: ${flaggedTotal} line${flaggedTotal !== 1 ? 's' : ''} reassigned across ${checked} character${checked !== 1 ? 's' : ''}`
: `Consistency check: no mismatches found across ${checked} character${checked !== 1 ? 's' : ''}`) + failSuffix,
failedNames.length && !flaggedTotal ? 'error' : 'success'
);
};
foot.querySelector('#ab-cv-open-reh').addEventListener('click', async () => {
closePanel();
await audiobookOpenCurrentInRehearser();
});
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);
});
};
const runIdentifyAll = async () => {
const bookTitle = window.readerState?.title || '';
if (bookTitle && typeof window.openBookContextEditor === 'function') {
const existingContext = typeof _getBookProfile === 'function' ? await _getBookProfile(bookTitle) : {};
if (!existingContext.confirmed) {
const profile = await window.openBookContextEditor(bookTitle);
if (!profile) return;
}
}
const ok = await confirmDialog('Start from scratch? This will discard the current cast for this book and rebuild the character definitions from the text.', { title: 'Discard current cast?', okLabel: 'Start from scratch', danger: true });
if (!ok) return;
applyPromptAndRun(audiobookCast);
};
const runIdentifyUnknown = () => applyPromptAndRun(audiobookRecastUnknown);
// Spoken lines typed as narration are invisible to runIdentifyUnknown (it only
// scans type==='dialogue'), and "Verify all" would re-send all ~1000 narration
// segments. This re-checks only the ones scoring as direct speech.
const runRepairNarration = () => {
const segs = _audiobook.segments || [];
const n = (typeof _abSuspectNarration === 'function') ? _abSuspectNarration(segs).size : 0;
if (!n) { toast('No narration lines look like mislabeled dialogue', 'success'); return; }
toast(`Re-checking ${n} narration line${n !== 1 ? 's' : ''} that look like spoken dialogue…`, 'info');
audiobookRecastUnknown(null, null, { repairNarration: true, prompt: verificationPrompt });
};
const runCastAll = () => {
if (typeof window.csForReader === 'function') window.csForReader();
else if (typeof csForReader === 'function') csForReader();
else toast('Character sheets not loaded yet', 'error');
};
const runCastFresh = async () => {
const ok = await confirmDialog('Discard every generated character sheet for this book and rebuild every profile from a blank slate? This cannot be undone.', { title: 'Discard all character sheets?', okLabel: 'Discard & rebuild', danger: true });
if (!ok) return;
if (typeof window.csForReader === 'function') window.csForReader({ fresh: true });
else if (typeof csForReader === 'function') csForReader({ fresh: true });
else toast('Character sheets not loaded yet', 'error');
};
const runCastSelected = () => {
if (!existing.length) {
toast('No character sheets found yet for this book', 'error');
return;
}
_abOpenRecastSelectPopup(existing);
};
// "Continue uncasted characters" — same completeness check the live
// progress UI already uses ("N profiles with details") to find anyone
// with zero real detail fields (never cast, or only ever picked up as
// a bare name), and runs the targeted/selective pass (evidence-window
// per character, not a full book re-scan) on just those — skips
// everyone who's already got a real profile instead of re-touching
// the whole cast like "Cast all character roles" does.
const runCastContinueUncasted = async () => {
if (typeof clGetAllByTagOrBook !== 'function' || typeof csForReaderSelective !== 'function' || typeof CS_DETAIL_FIELDS === 'undefined') {
toast('Character sheets not loaded yet', 'error');
return;
}
let recs = [];
try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {}
// Indexed by canonical name ONLY used to miss every alias — a roster
// name that's really just an already-known alternate name for a
// MORE complete record (e.g. "Fremder" once a character-sheet pass
// has proven it's the same person as the already-detailed "Zerwas")
// looked like "never cast at all" and got endlessly re-queued as its
// own separate target, recreating the same split every time this ran.
const recByName = new Map();
recs.forEach(r => {
const name = String(r.name || '').trim().toLowerCase();
if (name) recByName.set(name, r);
if (typeof clSplitIdentityTokens === 'function') {
for (const a of clSplitIdentityTokens(r?.sheet?.aliases, { aliases: true })) {
const key = a.toLowerCase();
if (!recByName.has(key)) recByName.set(key, r);
}
}
});
const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim()));
// A character with only a handful of lines usually just doesn't have
// enough text for the LLM to say anything real about them — sending
// them through the same pass as everyone else burns a call that
// almost always comes back blank anyway (confirmed live: a whole-
// roster run filled in the one prominent character and left every
// <20-line character empty). Skip anyone under this line count by
// default rather than pretending they're "uncasted" forever.
const MIN_LINES_TO_TRY = 10;
const lineCounts = new Map();
(_audiobook.segments || []).forEach(s => {
if (s?.type !== 'dialogue' || !s.speaker) return;
const k = String(s.speaker).trim().toLowerCase();
lineCounts.set(k, (lineCounts.get(k) || 0) + 1);
});
const tooSparse = [];
const incomplete = roster.filter(n => {
const rec = recByName.get(String(n).trim().toLowerCase());
const hasDetail = rec && CS_DETAIL_FIELDS.some(f => String((rec.sheet || {})[f] || '').trim());
if (hasDetail) return false;
const lc = lineCounts.get(String(n).trim().toLowerCase()) || 0;
if (lc < MIN_LINES_TO_TRY) { tooSparse.push(n); return false; }
return true;
});
if (!incomplete.length) {
toast(tooSparse.length
? `Nothing left to try — ${tooSparse.length} character${tooSparse.length !== 1 ? 's have' : ' has'} fewer than ${MIN_LINES_TO_TRY} lines and ${tooSparse.length !== 1 ? 'were' : 'was'} skipped`
: 'Every character already has a profile with detail — nothing to continue', 'success');
return;
}
toast(`Continuing ${incomplete.length} uncasted character${incomplete.length !== 1 ? 's' : ''}`
+ (tooSparse.length ? ` (skipped ${tooSparse.length} with fewer than ${MIN_LINES_TO_TRY} lines)` : '') + '…', 'info');
csForReaderSelective(incomplete);
};
const runViewCast = () => {
// navTo('s-library') is silently swallowed by Studio's nav guard
// while Studio is the active section (same pattern that broke "Open
// Script Rehearser" — see studio.js's _stuInstallNavGuardOnce) —
// confirmed live: the button fired with no visible effect. Studio's
// own Voices phase already shows this exact roster (it borrows the
// same #lib-chars-list the old Library page uses), so redirect
// there instead of navigating away.
if (document.getElementById('s-caststudio')?.classList.contains('is-active') && typeof window.showStudioPhase === 'function') {
window.showStudioPhase(3);
return;
}
try { sessionStorage.setItem('ttsvc_cast_return', 'reader'); } catch (_) {}
if (typeof navTo === 'function') navTo('s-library');
if (typeof navLibraryView === 'function') navLibraryView('characters');
};
// The Character Library accumulates a record for every name any past
// casting run has ever produced for this book, with no cleanup tied to
// the CURRENT roster — confirmed live: 65+ saved records for a book
// whose current cast is 46 names, including spelling-variant
// duplicates ("Globo Brohm" / "Gombo Brohm" / "Gernot Brohm") and a
// character split three ways ("Kolon" / "Kolon der Zwerg" / "Kolon
// Tunneltreiber") that a past, less-accurate pass never got merged
// away. The current roster (this run's own attribution, not the
// Library) is the ground truth for who's actually in the cast now.
const runLibraryCleanup = async () => {
if (typeof clGetAllByTagOrBook !== 'function' || typeof clDelete !== 'function') {
toast('Character library is not available right now', 'error');
return;
}
const roster = (_audiobook.roster || []).filter(n => n && !/^unknown$|^unbekannt$/i.test(n.trim()));
if (!roster.length) { toast('No current roster to clean up against', 'error'); return; }
const rosterSet = new Set(roster.map(n => n.trim().toLowerCase()));
let recs = [];
try { recs = await clGetAllByTagOrBook(bookTitle); } catch (_) {}
const toDelete = recs.filter(r => !rosterSet.has(String(r.name || '').trim().toLowerCase()));
if (!toDelete.length) {
toast(`Library already matches the current ${roster.length}-name roster — nothing to remove`, 'success');
return;
}
const preview = toDelete.slice(0, 12).map(r => r.name).join(', ') + (toDelete.length > 12 ? `, +${toDelete.length - 12} more` : '');
const ok = await confirmDialog(
`Remove ${toDelete.length} character record${toDelete.length !== 1 ? 's' : ''} that aren't in the current ${roster.length}-name roster? This cannot be undone.\n\n${preview}`,
{ title: 'Clean up character library?', okLabel: `Remove ${toDelete.length}`, danger: true }
);
if (!ok) return;
let removed = 0;
for (const r of toDelete) {
try { await clDelete(r.id); removed++; } catch (_) {}
}
toast(`Removed ${removed} character record${removed !== 1 ? 's' : ''} not in the current roster`, 'success');
};
const bookTitle = window.readerState?.title || '';
let existing = [];
// If this book's already been cast before, "Cast selected character
// roles" can reuse the loaded character list instead of starting blind.
(async () => {
if (!bookTitle || typeof clGetAllByTagOrBook !== 'function') return;
try { existing = await clGetAllByTagOrBook(bookTitle); } catch (_) {}
})();
const identifyMenu = foot.querySelector('#ab-cv-menu-identify');
const castMenu = foot.querySelector('#ab-cv-menu-cast');
const viewCastMenu = foot.querySelector('#ab-cv-menu-viewcast');
identifyMenu?.addEventListener('click', () => _abToggleFootMenu(identifyMenu, [
{ icon: 'mdi-refresh', label: '1. Identify all characters', title: 'Scan the text and build the cast list from scratch', onClick: runIdentifyAll, danger: true },
{ icon: 'mdi-auto-fix', label: '2. Auto-repair cast (2.1–2.5)', title: 'Runs the repair sequence automatically: fix mislabeled dialogue, resolve unknown speakers, repeat while it helps, then a full verification only if still needed', onClick: () => audiobookAutoRepairCast() },
{ icon: 'mdi-comment-alert-outline', label: '2.1 Repair mislabeled dialogue', title: 'Finds spoken lines that were typed as narration (usually because the » « quote marks were lost in extraction) and re-checks only those — far cheaper than verifying every narration line', onClick: runRepairNarration },
{ icon: 'mdi-account-question-outline', label: '2.2 Identify unknown characters', title: 'Re-scan only the unknown segments with the current prompt', onClick: runIdentifyUnknown },
{ icon: 'mdi-repeat', label: '2.3 Run until < N unknown…', title: 'Repeats recast-unknown + narrator-verify passes automatically until the Unknown-speaker count drops below a target, or progress stalls', onClick: () => {
const input = window.prompt('Stop once fewer than this many Unknown speakers remain:', '10');
if (input == null) return;
const threshold = parseInt(input, 10);
if (!Number.isFinite(threshold) || threshold < 0) { toast('Enter a whole number of 0 or more', 'error'); return; }
audiobookRecastUntilThreshold(threshold);
} },
{ icon: 'mdi-shield-check-outline', label: '2.4 Verify all characters', title: 'Second-pass plausibility check that keeps the existing cast and only corrects uncertain matches', onClick: runVerificationPass },
{ icon: 'mdi-account-search-outline', label: '2.5 Check voice consistency', title: 'Third-pass check: gathers every line already credited to each character across the whole book and flags any that don’t match their established voice', onClick: runConsistencyPass },
// Scores are normally stamped at the end of casting/recasting, so a cast
// made before this feature existed carries none. This re-scores in place
// with no LLM calls, so an existing book can be reviewed immediately
// instead of having to be re-cast just to get the colours.
{ icon: 'mdi-traffic-light-outline', label: '2.6 Score attribution confidence', title: 'Colour-code every line by how certain its speaker is (green/orange/red), then use F2 to jump between the uncertain ones. Instant — no LLM calls.', onClick: () => {
try {
const segs = _audiobook.segments || [];
if (!segs.length) { toast('Nothing cast yet to score — open or cast a book first', 'error'); return; }
_abStampConfidence(segs);
const red = segs.filter(s => typeof s.conf === 'number' && s.conf < 50).length;
const amber = segs.filter(s => typeof s.conf === 'number' && s.conf >= 50 && s.conf < 96).length;
// Patch the badges straight into the existing rows rather than
// rebuilding the feed: each row already holds its segment on
// __seg, so this is instant even on a 2000-segment book and can't
// disturb scroll position, page markers or the current selection.
const root = document.querySelector('.ab-castpanel-inline') || document;
root.querySelectorAll('.ab-cv-row').forEach(row => {
const s = row.__seg;
if (!s || typeof s.conf !== 'number') return;
row.dataset.conf = String(s.conf);
const existing = row.querySelector('.ab-cv-conf');
if (existing) existing.outerHTML = _abConfBadgeHtml(s);
else {
const spk = row.querySelector('.ab-cv-spk');
if (spk) spk.insertAdjacentHTML('afterend', _abConfBadgeHtml(s));
else row.insertAdjacentHTML('afterbegin', _abConfBadgeHtml(s));
}
});
if (typeof root._abRefreshReview === 'function') root._abRefreshReview();
if (_audiobook.lastText) _abSaveDraft(segs, _audiobook.roster || [], _audiobook.lastText, _audiobook.completedChunks || 0, _audiobook.completedTotal || 0);
toast(`Scored ${segs.length} lines — ${red} guess/doubtful, ${amber} worth a look. Use the review button or F2.`, 'success');
} catch (e) {
console.error('[confidence]', e);
toast('Scoring failed: ' + (e.message || e), 'error');
}
} },
]));
window._abRunConsistencyPass = runConsistencyPass;
castMenu?.addEventListener('click', () => _abToggleFootMenu(castMenu, [
{ icon: 'mdi-account-multiple-plus-outline', label: 'Cast all character roles', title: 'Generate / refresh the character sheets for every cast character', onClick: runCastAll },
{ icon: 'mdi-account-arrow-right-outline', label: 'Continue uncasted characters', title: 'Only generate profiles for characters with no detail yet — skips anyone already fully cast', onClick: runCastContinueUncasted },
{ icon: 'mdi-account-check-outline', label: 'Select characters to cast or recast roles', title: existing.length ? 'Choose character roles to generate or refresh' : 'No character sheets found yet for this book', disabled: !existing.length, onClick: runCastSelected },
{ divider: true },
{ icon: 'mdi-refresh', label: 'New recast (discard & rebuild all)', title: 'Discard every generated character sheet and rebuild every profile from scratch', onClick: runCastFresh, danger: true },
]));
viewCastMenu?.addEventListener('click', () => _abToggleFootMenu(viewCastMenu, [
{ icon: 'mdi-eye-outline', label: 'View cast', title: 'View the cast overview in the library', onClick: runViewCast },
{ icon: 'mdi-folder-zip-outline', label: 'Export cast archive (.zip)', title: 'Download one zip: the cast as a readable Markdown script plus a Markdown sheet per character', onClick: audiobookExportCastMd },
{ divider: true },
{ icon: 'mdi-broom', label: 'Clean up library to current roster', title: 'Remove saved character records that aren\'t in the current roster — old spelling-variant duplicates and superseded names from past casting runs', onClick: runLibraryCleanup, danger: true },
]));
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
})));
panel.classList.add('ab-castpanel-done');
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
},
done(options = {}) {
_abHideCastingControl();
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> Open Script Rehearser</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 a fresh "ready" 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 identify characters.') {
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);
});
}
},
};
}
window.TTVC = window.TTVC || {};
window.TTVC.audiobook = window.TTVC.audiobook || {};
window.TTVC.audiobook.cast = {
castView: audiobookCastView,
toMarkdown: _abCastMarkdown,
stampPages: _abStampSegmentPages,
mergeAdjacentSameSpeaker: _audiobookMergeAdjacentSameSpeaker,
mergeAdjacentNarration: _audiobookMergeAdjacentNarration,
mergeAdjacentDialogue: _audiobookMergeAdjacentDialogue,
mergeSplitSentences: _audiobookMergeSplitSentences,
dedupNearbyDuplicates: _audiobookDedupNearbyDuplicates,
dropPhantomSpeakers: _audiobookDropPhantomSpeakers,
consolidateSpeakerAliases: _audiobookConsolidateSpeakerAliases,
};
Object.assign(root, {
_abCastMarkdown, _abStampSegmentPages, audiobookCastView,
_audiobookMergeAdjacentSameSpeaker, _audiobookMergeAdjacentNarration, _audiobookMergeAdjacentDialogue,
_audiobookMergeSplitSentences, _audiobookDedupNearbyDuplicates,
_audiobookDropPhantomSpeakers, _audiobookConsolidateSpeakerAliases,
});
})(window);