From 6f2c0b7b3dc6c97078e4e1c1d0b548575551892a Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:53:04 +0200 Subject: [PATCH] refactor: extract audiobook cast view --- CHANGELOG.md | 2 + TODO.md | 3 +- VERSION | 2 +- docs/ARCHITECTURE.md | 2 +- static/index.html | 16 +- static/js/audiobook-cast.js | 3274 +++++++++++++++++++++++++++++++++++ static/js/audiobook.js | 3117 --------------------------------- 7 files changed, 3288 insertions(+), 3128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c190889..412e02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Audiobook live cast-view extraction.** The live cast feed, roster, settings, and prompt controls now live in + `audiobook-cast.js`; the legacy `audiobookCastView` entry point remains available for compatibility. - **Rehearser voice-matching extraction.** Local voice matching, preview-line selection, fish.audio search/import, and candidate assignment now live in `rehearser-voice-picker.js`; DOM event registration remains timing-safe. - **Audiobook/Reader stylesheet extraction.** Reader controls, Audiobook casting, page layout, character overlays, diff --git a/TODO.md b/TODO.md index 2f067b1..9bd0229 100644 --- a/TODO.md +++ b/TODO.md @@ -137,7 +137,8 @@ architecture change: the larger Audiobook/Rehearser/style split. - [x] Extract Rehearser character development and backend-selection controls into `rehearser-voice-design.js`. - [x] Extract Audiobook quote segmentation and conservative turn-taking into `audiobook-segmentation.js`. - [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`. -- [ ] Extract Audiobook parsing and casting. +- [x] Extract Audiobook parsing and casting; parsing, segmentation, cast normalization/serialization, and the live cast + view now live in focused modules, while LLM orchestration and transport remain in `audiobook.js`. - [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`. - [x] Extract Audiobook cast serialization and page-stamping helpers into `audiobook-cast.js`. - [x] Split the Performance/resource diagnostic styles into `static/css/resource-diagnostics.css`. diff --git a/VERSION b/VERSION index ffb8153..c2988cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.47 +1.24.48 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 221e5ce..d30255a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,7 +38,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed) - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`. -- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, and deterministic speaker/sentence/duplicate/alias normalization helpers; live casting remains in `audiobook.js`. +- ✅ `audiobook-cast.js` — cast Markdown export, legacy page-stamping migration, deterministic speaker/sentence/duplicate/alias normalization helpers, and the live cast-view renderer. - ✅ `static/css/resource-diagnostics.css` — isolated Performance/resource diagnostic panel styles. - ✅ `static/css/background-jobs.css` — isolated background-job history and retry-panel styles. - ✅ `static/css/voice-design.css` — isolated Voice Design form, samples, and preview-match styles. diff --git a/static/index.html b/static/index.html index 9360cf7..d2bb41f 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,12 +27,12 @@ - - - - - - + + + + + + @@ -397,7 +397,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/audiobook-cast.js b/static/js/audiobook-cast.js index 1af7803..70a62f7 100644 --- a/static/js/audiobook-cast.js +++ b/static/js/audiobook-cast.js @@ -209,9 +209,3283 @@ function _audiobookConsolidateSpeakerAliases(segments) { 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 = ` +
+ + Casting audiobook + + passage 0 / ${total} + + + + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+ Characters found + +
+
+ + +
+
+
+ ${[38,62,45,28,54,35].map(w => `
`).join('')} +
+
+ +
+
+
+ + + + + + +
`; + + 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 = ' 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 = '' + + savedPrompts.map((p, i) => ``).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 = ''; + 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 => ``).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 = ``; + } + }).catch(() => { + sel.innerHTML = ``; + }); + }; + 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 = ' 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 + ? `` + : `${escHtml((name || '?')[0].toUpperCase())}`; + + }; + 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 `${match}`; + }); + } + 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 = ` + + `; + _abTopbar.appendChild(_abEditToolbar); + + const _abPageNav = document.createElement('div'); + _abPageNav.className = 'ab-page-nav'; + _abPageNav.hidden = true; + + _abPageNav.innerHTML = ` + + + + `; + _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 = ` + + + `; + _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 = ` + + review + + `; + // 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
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 = ' 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 = ' 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 = '' + + '
Kein Charakterblatt – zuerst „Cast all character roles“ ausführen.
'; + 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 + ? `
${escHtml(rec.name)}
` + : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; + // 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 `
${lbl}
${escHtml(v)}
`; + }; + + + // Sources section + const srcItems = Array.isArray(rec.sources) ? rec.sources : []; + const sourcesHtml = srcItems.length + ? `
+ +
${srcItems.map(s => `Seite ${s.page||'?'}`).join('')}
+
` : ''; + + detail.innerHTML = + ` +
+ ${avatarHtml} +
+
${escHtml(rec.name)}
+ ${_abStr(sh.title) ? `
${escHtml(_abStr(sh.title))}
` : ''} + ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''} +
${tierLabel}${lineCount ? `${escHtml(lineCount)} lines` : ''}
+
+
+ + + +
+
+
+
+ + ${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'} + + +
+ ${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''} +
+
${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')}
+
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
+
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
+
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
+
${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}
+
${field('',sh.relationships,'relationships')}
+ ${sourcesHtml} + +
+
`; + + 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 ? '' : ''; + 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 = + `${(name||'?')[0].toUpperCase()} +
+ ${escHtml(name)} + ${_abCharRows.length} Zeilen +
+
+ + 1 / ${_abCharRows.length} + +
+ + + `; + + + // 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 ? `No matches` : `
${ + [38,62,45,28,54,35].map(w => `
`).join('') + }
`; + return; + } + const prev = _abBar.hidden ? null : _abBar.dataset.charName; + chars.innerHTML = items.map(([n, info]) => { + const color = info.color || colorFor(n); + return `
+ ${_abAvatarHtml(n, color)} + ${escHtml(n)} + ${roster.get(n)?.count||0} + + +
`; + }).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 = ` +
Also known as — ${escHtml(name)}
+ + + ${otherNames.map(([n, info]) => ``).join('')} + + ${otherNames.length ? `
Or merge with an already-found character:
+
+ ${otherNames.slice(0, 8).map(([n, info]) => ``).join('')} +
` : ''} +
+ + +
`; + + 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 = ``; + } + 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 => ``).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 = ` + + + + + ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''} + ${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)} + + ${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; + 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 = 'Assign to'; + 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 = `+ 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 ? ' (' + escHtml(assignModeSeg.emotion) + ')' : ''}`; + 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 = `📖 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 + ? `` + : ``) + ` ${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 = ' 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 " 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 = ` +
+ LLM Reading… + +
+
${preview}
+ + `; + 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 ...; 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(''); + let shown, isReal = false, closeAt = -1; + if (openAt >= 0) { + const afterOpen = this._thinkRaw.slice(openAt + ''.length); + closeAt = afterOpen.indexOf(''); + shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen; + isReal = true; + } else { + shown = this._thinkRaw; + } + if (label) label.innerHTML = isReal + ? ' LLM Thinking…' + : ' 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 = `⋯ ${gapCount > 0 ? gapCount + ' line' + (gapCount !== 1 ? 's' : '') + ' hidden — ' : ''}click to expand`; + 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 = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; + 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 = `⋯ ${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand`; + + 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 = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; + 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 = ` ${escHtml(summary)}`; + } + // 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 + ? `` + : ''; + // 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 = `${continueBtnHtml}`; + + 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 = 'No completed characters yet.'; + } + + const foot = panel.querySelector('#ab-cv-foot'); + + const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; + foot.hidden = false; + foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; + + 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 = 'restoring…'; + 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 = 'No saved cast found.'; + 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, diff --git a/static/js/audiobook.js b/static/js/audiobook.js index b819b0e..5441dc6 100644 --- a/static/js/audiobook.js +++ b/static/js/audiobook.js @@ -1569,3123 +1569,6 @@ function _abScrollIntoView(el, opts) { })); } -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 = ` -
- - Casting audiobook - passage 0 / ${total} - - - - - - - - - - -
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- Characters found - -
-
- - -
-
-
- ${[38,62,45,28,54,35].map(w => `
`).join('')} -
-
-
-
-
- - - - - - -
`; - - 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 = ' 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 = '' + - savedPrompts.map((p, i) => ``).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 = ''; - 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 => ``).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 = ``; - } - }).catch(() => { - sel.innerHTML = ``; - }); - }; - 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 = ' 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 - ? `` - : `${escHtml((name || '?')[0].toUpperCase())}`; - }; - 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 `${match}`; - }); - } - 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 = ` - - `; - _abTopbar.appendChild(_abEditToolbar); - - const _abPageNav = document.createElement('div'); - _abPageNav.className = 'ab-page-nav'; - _abPageNav.hidden = true; - _abPageNav.innerHTML = ` - - - - `; - _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 = ` - - - `; - _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 = ` - - review - - `; - // 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
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 = ' 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 = ' 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 = '' - + '
Kein Charakterblatt – zuerst „Cast all character roles“ ausführen.
'; - 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 - ? `
${escHtml(rec.name)}
` - : `
${escHtml((rec.name||'?')[0].toUpperCase())}
`; - // 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 `
${lbl}
${escHtml(v)}
`; - }; - - // Sources section - const srcItems = Array.isArray(rec.sources) ? rec.sources : []; - const sourcesHtml = srcItems.length - ? `
- -
${srcItems.map(s => `Seite ${s.page||'?'}`).join('')}
-
` : ''; - - detail.innerHTML = - ` -
- ${avatarHtml} -
-
${escHtml(rec.name)}
- ${_abStr(sh.title) ? `
${escHtml(_abStr(sh.title))}
` : ''} - ${_abStr(sh.archetype) ? `
${escHtml(_abStr(sh.archetype))}
` : ''} -
${tierLabel}${lineCount ? `${escHtml(lineCount)} lines` : ''}
-
-
- - -
-
-
-
- - ${voiceId ? escHtml(voiceId) : 'Noch keine Stimme'} - - -
- ${pct != null ? `
Böse
Gut
${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)
` : ''} -
-
${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')}
-
${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}
-
${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}
-
${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}
-
${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}
-
${field('',sh.relationships,'relationships')}
- ${sourcesHtml} -
-
`; - - 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 ? '' : ''; - 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 = - `${(name||'?')[0].toUpperCase()} -
- ${escHtml(name)} - ${_abCharRows.length} Zeilen -
-
- - 1 / ${_abCharRows.length} - -
- - - `; - - // 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 ? `No matches` : `
${ - [38,62,45,28,54,35].map(w => `
`).join('') - }
`; - return; - } - const prev = _abBar.hidden ? null : _abBar.dataset.charName; - chars.innerHTML = items.map(([n, info]) => { - const color = info.color || colorFor(n); - return `
- ${_abAvatarHtml(n, color)} - ${escHtml(n)} - ${roster.get(n)?.count||0} - -
`; - }).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 = ` -
Also known as — ${escHtml(name)}
- - - ${otherNames.map(([n, info]) => ``).join('')} - - ${otherNames.length ? `
Or merge with an already-found character:
-
- ${otherNames.slice(0, 8).map(([n, info]) => ``).join('')} -
` : ''} -
- - -
`; - 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 = ``; - } - 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 => ``).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 = ` - - - - - ${escHtml(speakerName)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''} - ${_abConfBadgeHtml(s)}${_abCheckBtnHtml(s)} - ${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; - 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 = 'Assign to'; - 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 = `+ 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 ? ' (' + escHtml(assignModeSeg.emotion) + ')' : ''}`; - 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 = `📖 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 - ? `` - : ``) + ` ${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 = ' 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 " 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 = ` -
- LLM Reading… - -
-
${preview}
- - `; - 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 ...; 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(''); - let shown, isReal = false, closeAt = -1; - if (openAt >= 0) { - const afterOpen = this._thinkRaw.slice(openAt + ''.length); - closeAt = afterOpen.indexOf(''); - shown = closeAt >= 0 ? afterOpen.slice(0, closeAt) : afterOpen; - isReal = true; - } else { - shown = this._thinkRaw; - } - if (label) label.innerHTML = isReal - ? ' LLM Thinking…' - : ' 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 = `⋯ ${gapCount > 0 ? gapCount + ' line' + (gapCount !== 1 ? 's' : '') + ' hidden — ' : ''}click to expand`; - 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 = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; - 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 = `⋯ ${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand`; - 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 = `${escHtml(spk)}${s.emotion ? ' (' + escHtml(s.emotion) + ')' : ''}${_abMarkQuotes(highlightText(_abDisplayText(s)))}`; - 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 = ` ${escHtml(summary)}`; - } - // 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 - ? `` - : ''; - // 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 = `${continueBtnHtml}`; - - 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 = 'No completed characters yet.'; - } - - const foot = panel.querySelector('#ab-cv-foot'); - const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0; - foot.hidden = false; - foot.innerHTML = ` ${escHtml(message)}${hasSegments ? '' : ''}`; - - 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 = 'restoring…'; - 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 = 'No saved cast found.'; - 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); - }); - } - }, - }; -} // Every long-running audiobook action opens with a "already running?" guard. // They all used to `return` in silence, so clicking Recast / Verify / Export