// ── Utility ─────────────────────────────────────────────────────────────── const $ = id => document.getElementById(id); // ── Voice avatar colors ─────────────────────────────────────────────────── const _AVATAR_COLORS = ['#E57373','#F06292','#BA68C8','#9575CD','#7986CB', '#64B5F6','#4DD0E1','#4DB6AC','#81C784','#FFB74D','#FF8A65','#A1887F']; function avatarColor(id) { let h = 0; for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) & 0xFFFFFF; return _AVATAR_COLORS[Math.abs(h) % _AVATAR_COLORS.length]; } let _toastTimer; function toast(msg, type = '', ms = 6500) { const el = $('toast'); if (!el) return; el.textContent = msg; el.setAttribute('role', type === 'error' ? 'alert' : 'status'); el.setAttribute('aria-live', type === 'error' ? 'assertive' : 'polite'); el.className = 'show ' + type; clearTimeout(_toastTimer); _toastTimer = setTimeout(() => el.className = '', ms); } // In-app replacement for window.confirm() — native browser confirm dialogs // show the page's raw URL/IP ("192.168.178.8:7890 says…") and can't be // styled, which reads as broken/untrustworthy next to the rest of the UI. // Returns a Promise so call sites just `await confirmDialog(...)` // instead of the synchronous window.confirm() return value. function confirmDialog(message, opts = {}) { return new Promise(resolve => { const ov = document.createElement('div'); ov.className = 'audiobook-overlay'; ov.innerHTML = `
${escHtml(opts.title || 'Are you sure?')}
${escHtml(message || '')}
`; document.body.appendChild(ov); const cleanup = (result) => { ov.remove(); document.removeEventListener('keydown', onKey); resolve(result); }; const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); cleanup(false); } else if (e.key === 'Enter') { e.preventDefault(); cleanup(true); } }; document.addEventListener('keydown', onKey); ov.querySelector('#cd-cancel').addEventListener('click', () => cleanup(false)); ov.querySelector('#cd-ok').addEventListener('click', () => cleanup(true)); ov.addEventListener('click', e => { if (e.target === ov) cleanup(false); }); ov.querySelector('#cd-ok').focus(); }); } window.confirmDialog = confirmDialog; function status(msg) { ensureStatusBar(); const el = $('status-message') || $('status-bar'); if (el) el.textContent = msg; } function ensureStatusBar() { const bar = $('status-bar'); if (!bar) return null; if (!$('status-message')) { const previous = bar.textContent || 'Ready'; bar.textContent = ''; const msg = document.createElement('span'); msg.id = 'status-message'; msg.textContent = previous; bar.appendChild(msg); } if (!$('status-engines')) { const engines = document.createElement('span'); engines.id = 'status-engines'; engines.setAttribute('aria-label', 'Active engine status'); bar.appendChild(engines); } return bar; } const STATUS_LLM_CACHE = { url: '', model: '', ok: false, checked: 0, pending: false }; function statusCleanUrl(url) { return String(url || '').trim().replace(/\/+$/, ''); } function statusShortUrl(url) { try { const u = new URL(url); return u.port ? `${u.hostname}:${u.port}` : u.hostname; } catch (_) { return statusCleanUrl(url) || 'not configured'; } } function statusLabelList(items, fallback) { const clean = items.map(x => String(x || '').replace(/^\d+\s+/, '').trim()).filter(Boolean); if (!clean.length) return fallback; const shown = clean.slice(0, 4).join(', '); return clean.length > 4 ? shown + ` +${clean.length - 4}` : shown; } function statusActiveTts() { let all = []; try { all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : []; } catch (_) {} const labels = all.map(b => b.label || b.id); return { kind: 'tts', label: 'TTS', ok: all.length > 0, value: statusLabelList(labels, 'No TTS backend'), title: all.length ? `${all.length} reachable TTS backend${all.length === 1 ? '' : 's'}` : 'No reachable TTS backend', }; } function statusActiveStt() { let all = []; try { all = Array.isArray(_sttBackends) ? _sttBackends : []; } catch (_) {} const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || ''; const available = all.filter(b => b.available); const chosen = (preferred && all.find(b => b.id === preferred)) || available[0] || all[0] || null; return { kind: 'stt', label: 'STT', ok: !!(chosen && chosen.available), value: chosen ? (chosen.label || chosen.model || chosen.id) : 'No STT backend', title: chosen ? `${chosen.label || chosen.id} · ${chosen.url || ''}` : 'No STT backend configured', }; } // Resolve the effective LLM endpoint+model (app settings, falling back to the // rehearser's or conversation's pickers) — shared by the status chip and the // engine health check so the resolution order can't drift between them. function statusLlmTarget() { let settings = {}; try { settings = (typeof _appSettings !== 'undefined' && _appSettings) ? _appSettings : {}; } catch (_) {} return { url: statusCleanUrl(settings.llm_url || $('reh-llm-url')?.value || $('conv-llm-url')?.value || ''), model: settings.llm_model || $('reh-llm-model')?.value || $('conv-llm-model-select')?.value || '', apiKey: settings.llm_api_key || '', }; } function statusActiveLlm() { const { url, model } = statusLlmTarget(); const same = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model; const ok = !!url && same && STATUS_LLM_CACHE.ok; return { kind: 'llm', label: 'LLM', ok, value: model || statusShortUrl(url) || 'No LLM configured', title: url ? `${model || 'default model'} · ${url}` : 'No active LLM endpoint configured', }; } function statusEngineChip(info) { const cls = info.ok ? 'ok' : 'bad'; return ``; } function updateStatusBar() { ensureStatusBar(); const engines = $('status-engines'); if (!engines) return; const items = [statusActiveLlm(), statusActiveStt(), statusActiveTts()]; engines.innerHTML = items.map(statusEngineChip).join(''); } // ── Fly-up menus on the footer status chips — quickly switch the active // LLM model / STT backend / TTS backend without hunting through Settings. ── let _statusFlyup = null; function _statusCloseFlyup() { if (_statusFlyup) { _statusFlyup.remove(); _statusFlyup = null; } } function _statusFlyupList(anchor, items, onPick, emptyLabel, kind) { _statusCloseFlyup(); const el = document.createElement('div'); el.className = 'status-flyup'; if (kind) el.dataset.forKind = kind; // A long model list (OpenRouter alone lists 50+) is unusable to scan by // eye — add a live-filter search box once there's enough items that // scrolling to find one is slower than typing its name. const showSearch = items.length > 8; const searchHtml = showSearch ? `` : ''; const listHtml = items.length ? items.map(it => ``).join('') : `
${escHtml(emptyLabel || 'Nothing available')}
`; el.innerHTML = searchHtml + `
${listHtml}
`; document.body.appendChild(el); const rect = anchor.getBoundingClientRect(); el.style.left = Math.min(rect.left, window.innerWidth - el.offsetWidth - 12) + 'px'; el.style.bottom = (window.innerHeight - rect.top + 6) + 'px'; el.querySelectorAll('.status-flyup-item').forEach(btn => { btn.addEventListener('click', e => { e.stopPropagation(); onPick(btn.dataset.val); _statusCloseFlyup(); }); }); const searchInp = el.querySelector('.status-flyup-search input'); if (searchInp) { searchInp.addEventListener('click', e => e.stopPropagation()); searchInp.addEventListener('input', () => { const q = searchInp.value.trim().toLowerCase(); el.querySelectorAll('.status-flyup-item').forEach(btn => { btn.hidden = q.length > 0 && !btn.dataset.search.includes(q); }); }); setTimeout(() => searchInp.focus(), 0); } _statusFlyup = el; } // Apply a chosen backend id to every matching , or '' when there's too little signal. function detectLang(text) { const t = String(text || '').slice(0, 4000); if (!t.trim()) return ''; if (/[가-힣]/.test(t)) return 'Korean'; if (/[぀-ヿ]/.test(t)) return 'Japanese'; if (/[一-鿿]/.test(t)) return 'Chinese'; if (/[Ѐ-ӿ]/.test(t)) return 'Russian'; const STOP = { English: ['the','and','of','to','a','in','that','is','was','he','for','it','with','as','his','on','be','at','by','she','you','not'], German: ['der','die','und','ich','das','nicht','ein','ist','sie','mit','dem','den','war','auf','für','des','eine','auch','als','er','wir','aber','noch'], French: ['le','la','les','et','de','un','une','des','est','que','pour','dans','qui','pas','plus','vous','avec','sur','son','ne','je','il'], Spanish: ['el','la','los','las','de','que','y','un','una','en','es','por','con','no','se','su','para','lo','como','más','pero','sus'], Italian: ['il','la','di','e','che','un','una','è','per','non','con','sono','del','le','si','lo','ma','come','più','anche','della','gli'], Portuguese: ['de','que','e','o','a','do','da','em','um','uma','não','os','para','com','por','como','mas','se','dos','das','ao','seu'], Dutch: ['de','het','een','en','van','ik','te','dat','die','in','is','niet','met','zijn','op','aan','voor','er','maar','om','ook','als'], }; const words = (t.toLowerCase().match(/[a-zà-ÿ]+/g) || []); if (words.length < 8) return ''; const counts = {}, sets = {}; Object.keys(STOP).forEach(l => { counts[l] = 0; sets[l] = new Set(STOP[l]); }); for (const w of words) for (const l in sets) if (sets[l].has(w)) counts[l]++; let best = '', bestN = 0; for (const l in counts) if (counts[l] > bestN) { bestN = counts[l]; best = l; } // '' on weak signal, matching this function's own documented contract // above — NOT 'English'. Every caller already does `detectLang(x) || ''` // expecting a falsy result here to mean "couldn't tell", so silently // returning 'English' instead defeated that: _resolveBookLang // (library-characters.js) treats any truthy result as a confident, // final answer and never falls through to its own book-majority-vote // fallback — confirmed live as the actual cause of sparse/minor // characters in an all-German book (e.g. "Junker", "Kroah") getting // designed as English voices, even though the book-majority fallback // that exists specifically to prevent this was right there unused. return bestN >= 3 ? best : ''; } window.detectLang = detectLang; // German umlauts/ß transliterated to their standard ASCII spelling (ä→ae, // ö→oe, ü→ue, ß→ss) before any [^A-Za-z0-9]-style sanitizer strips them — // every one of those sanitizers (voice IDs, filenames, tags) treats a // non-ASCII letter as junk to collapse into an underscore rather than a // real letter to keep, so "Torwächter" silently became "Torw_chter" and // "Mädchen" became "M_dchen" (both later displayed as just the tail after // the LAST underscore, e.g. "chter"/"dchen", since a display-name fallback // takes the last `_`-separated segment of the id). This is also the exact // transliteration the actual TTS engine's own directory-scan voice-naming // already uses (confirmed live: it registers a voice literally as // "...Hoerbuch..." for a reference file named with "ö"), so this keeps // generated IDs consistent with what the engine itself expects. function _umlautSafe(str) { return String(str || '').replace(/[äöüÄÖÜß]/g, ch => ({ 'ä':'ae', 'ö':'oe', 'ü':'ue', 'Ä':'Ae', 'Ö':'Oe', 'Ü':'Ue', 'ß':'ss', }[ch])); } window._umlautSafe = _umlautSafe; document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab))); document.addEventListener('click', e => { const btn = e.target.closest('.backend-jump'); if (!btn) return; switchTab('generation'); const backend = btn.dataset.backend; const sel = $('tts-backend-select'); if (sel && [...sel.options].some(o => o.value === backend)) { sel.value = backend; sel.dispatchEvent(new Event('change')); } }); // ── Cross-workflow pipeline stepper ───────────────────────────────────────── // PDF → Text → Cast Audiobook → Cast Characters → Cast → Script Rehearser // → Generate MP3s → Audiobook. A persistent strip (rendered into any element // with class .wf-stepper — currently one in s-reader.html, one in // s-rehearser.html) that lets you jump directly to any reachable stage // without losing state: each stage's data lives in its own owner // (readerState / _audiobook / rehState) regardless of which stage is on // screen, so this only ever toggles visibility/phase — it never rebuilds // anything from scratch. let _wfActive = 'source'; // PDF import and text extraction used to be two stepper stops, but they both // just land you on the same Reader screen — merged into one "Source" stop // (matches the "Source" label already used on the Reader's import card). const WF_STEPS = [ { key: 'source', label: 'Source', enabled: () => true, hint: 'Import a PDF or text file and skim the extracted pages. Once the text looks right, move on.' }, { key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length, hint: 'Split the text into narration and dialogue and attribute each line to a speaker. Check the result and fix any misattributed lines before continuing.' }, { key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true, hint: 'Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip if you just want to cast voices quickly.' }, { key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length, hint: 'Review the cast list. Merge any duplicate characters and fix names before assigning voices.' }, { key: 'voices', label: 'Assign Voices', enabled: () => !!window.readerState?.sentences?.length, optional: true, hint: 'Every character needs a voice before audio can be generated — assign one manually or use "Auto assign". Come back here if a voice sounds wrong.' }, { key: 'rehearser', label: 'Script Rehearser', // Gating this on rehState.lines.length alone was circular: that's only // ever populated by loading a script INTO the Rehearser, which is // exactly what clicking this step does (workflowCrumbGo builds it from // the live Audiobook segments) — so the button could never become // enabled from a fresh session, permanently blocking the only path in. enabled: () => !!(window.rehState?.lines?.length || window._audiobook?.segments?.length), hint: 'Fine-tune the script line by line — attribution, emotion, and pacing. Fix problems here rather than after audio is generated.' }, { key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length, hint: 'Generate audio for every line. Wait for synthesis to finish and re-run any failed lines before merging into the final audiobook.' }, { key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length, hint: 'Merge the generated clips into the final audiobook file and download it. Go back to Generate MP3s if anything sounds off.' }, ]; function workflowCrumbGo(key) { if (key === 'source') { // navTo's cast-session restore is synchronous, so the explicit view choice // below simply wins by running after it. if (typeof navTo === 'function') navTo('s-reader'); if (typeof showReaderView === 'function') showReaderView('main'); } else if (key === 'cast') { if (typeof navTo === 'function') navTo('s-reader'); if (typeof audiobookOpenCastView === 'function') audiobookOpenCastView(); } else if (key === 'chars') { // Navigate to the character-sheet stage in Read Aloud. if (typeof navTo === 'function') navTo('s-reader'); if (typeof showReaderView === 'function') showReaderView('chars'); _wfActive = 'chars'; refreshWorkflowCrumbs(); } else if (key === 'castlib') { // Navigate to the cast roster/library view. if (typeof navTo === 'function') navTo('s-library'); if (typeof navLibraryView === 'function') navLibraryView('characters'); _wfActive = 'castlib'; refreshWorkflowCrumbs(); } else if (key === 'voices') { // Same Character Library grid as "Cast" — voice/image assignment already // lives there (per-character voice pick/auto, avatar upload, AI-generate // image, and "Cast selected character roles" for a missing sheet). This // step just scrolls straight to the CURRENT book's block instead of // landing on the whole cross-book library unfocused. window._libCharsScrollToBook = window.readerState?.title || window.rehState?.title || null; if (typeof navTo === 'function') navTo('s-library'); if (typeof navLibraryView === 'function') navLibraryView('characters'); _wfActive = 'voices'; refreshWorkflowCrumbs(); } else if (key === 'rehearser') { // A bare navTo left the Stage empty (0/0 lines) whenever this step was // reached by clicking the stepper/Next directly, since nothing actually // parsed the current cast into rehState — only opening the Rehearser via // its own "Rehearse" entry points did that. Build it from the live // Audiobook segments (same cast/voices already assigned in Assign // Voices) unless a script is already loaded, in which case leave it alone // rather than re-parsing over in-progress Stage edits. if (window.rehState?.lines?.length) { if (typeof navTo === 'function') navTo('s-rehearser'); } else if (typeof audiobookOpenCurrentInRehearser === 'function' && (window._audiobook?.segments || []).length) { audiobookOpenCurrentInRehearser(); } else { if (typeof navTo === 'function') navTo('s-rehearser'); } } else if (key === 'mp3') { if (typeof navTo === 'function') navTo('s-rehearser'); if (typeof showPhase === 'function' && window.rehState?.lines?.length) showPhase(3); } else if (key === 'audiobook') { if (typeof navTo === 'function') navTo('s-rehearser'); if (typeof showPhase === 'function' && window.rehState?.clips?.length) showPhase(4); } } window.workflowCrumbGo = workflowCrumbGo; // Nearest reachable step from curIdx in the given direction (+1/-1), or null. function _wfNeighbor(curIdx, dir) { for (let i = curIdx + dir; i >= 0 && i < WF_STEPS.length; i += dir) { if (WF_STEPS[i].enabled()) return WF_STEPS[i]; } return null; } function refreshWorkflowCrumbs(active) { if (active) _wfActive = active; const title = window.readerState?.title || window.rehState?.title || ''; const containers = document.querySelectorAll('.wf-stepper'); // Hide the strip until some stage beyond the always-available "Source" is // reachable — otherwise a fresh session shows a stepper with one live stop. const anyState = WF_STEPS.some(st => st.key !== 'source' && st.enabled()); const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive); if (!containers.length) { _wfUpdateHeaderNav(anyState, curIdx); return; } containers.forEach(el => { if (!anyState) { el.innerHTML = ''; el.hidden = true; return; } el.hidden = false; const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null; const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null; // Two rows instead of one long strip: the book title on top, the // numbered step sequence (now leading with the back-to-previous-step // nudge, moved down from the title row to sit with the rest of the step // navigation) below — the single-row version forced the steps into a // horizontally-scrolling sliver that hid most of them behind a scrollbar. const row1 = []; if (title) row1.push(`${escHtml(title)}`); const row2 = []; row2.push(``); WF_STEPS.forEach((st, i) => { const enabled = st.enabled(); const cur = _wfActive === st.key; const done = curIdx >= 0 && i < curIdx && enabled; if (i > 0) row2.push(``); row2.push( `` ); }); row2.push(``); el.innerHTML = `
${row1.join('')}
${row2.join('')}
`; el.querySelectorAll('.wf-step[data-wf]').forEach(btn => { btn.addEventListener('click', () => workflowCrumbGo(btn.dataset.wf)); }); const prevBtn = el.querySelector('.wf-nav-prev'); if (prevBtn && !prevBtn.disabled) prevBtn.addEventListener('click', () => workflowCrumbGo(prevStep.key)); const nextBtn = el.querySelector('.wf-nav-next'); if (nextBtn && !nextBtn.disabled) nextBtn.addEventListener('click', () => workflowCrumbGo(nextStep.key)); }); _wfUpdateHeaderNav(anyState, curIdx); } window.refreshWorkflowCrumbs = refreshWorkflowCrumbs; // Previous/Next workflow buttons on every page's own section header (not // just the stepper widget further down) — lets you step through the // pipeline without scrolling to find the stepper first. Injected once per // .section-head (every section has one) and just updated in place after // that, since section HTML is loaded once and never rebuilt. function _wfUpdateHeaderNav(anyState, curIdx) { const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null; const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null; const curStep = curIdx >= 0 ? WF_STEPS[curIdx] : null; document.querySelectorAll('.section-head').forEach(head => { // Studio has its own self-contained 4-phase nav (studio.js) — this // stepper's Previous/Next buttons are hardcoded to the old s-reader/ // s-library/s-rehearser sections and don't know about Studio's phases, // so injecting them here just breaks navigation when clicked. if (head.closest('#s-caststudio')) { const staleNav = head.querySelector('.wf-header-nav'); if (staleNav) staleNav.hidden = true; const staleHint = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null; if (staleHint) staleHint.hidden = true; return; } let nav = head.querySelector('.wf-header-nav'); // A slim guidance bar right under the header — what to actually do on // THIS step and when it's safe to move on/back — since the generic // "Previous"/"Next" buttons alone gave no clue what either direction // actually did or when a step was "done". let hintBar = head.nextElementSibling?.classList?.contains('wf-step-hint') ? head.nextElementSibling : null; if (!anyState) { if (nav) nav.hidden = true; if (hintBar) hintBar.hidden = true; return; } if (!nav) { nav = document.createElement('div'); nav.className = 'wf-header-nav'; nav.innerHTML = `` + ``; head.appendChild(nav); nav.querySelector('.wf-header-nav-prev').addEventListener('click', () => { if (nav.dataset.prevKey) workflowCrumbGo(nav.dataset.prevKey); }); nav.querySelector('.wf-header-nav-next').addEventListener('click', () => { if (nav.dataset.nextKey) workflowCrumbGo(nav.dataset.nextKey); }); } if (!hintBar) { hintBar = document.createElement('div'); hintBar.className = 'wf-step-hint'; head.insertAdjacentElement('afterend', hintBar); } nav.hidden = false; const prevBtn = nav.querySelector('.wf-header-nav-prev'); const nextBtn = nav.querySelector('.wf-header-nav-next'); nav.dataset.prevKey = prevStep ? prevStep.key : ''; nav.dataset.nextKey = nextStep ? nextStep.key : ''; prevBtn.disabled = !prevStep; prevBtn.title = prevStep ? 'Go back to fix something on ' + prevStep.label : 'No previous step'; prevBtn.querySelector('.wf-header-nav-label').textContent = prevStep ? prevStep.label : 'Previous'; nextBtn.disabled = !nextStep; nextBtn.title = nextStep ? 'Continue to ' + nextStep.label : 'No next step'; nextBtn.querySelector('.wf-header-nav-label').textContent = nextStep ? nextStep.label : 'Next'; if (curStep?.hint) { hintBar.hidden = false; hintBar.innerHTML = `Step ${curIdx + 1}/${WF_STEPS.length}${curStep.optional ? ' · optional' : ''}` + `${escHtml(curStep.hint)}`; } else { hintBar.hidden = true; } }); } // Rehearser's own phase tabs (Library/Cast/Stage/Summary) map onto the last // four stepper stops — keep the stepper's "current" highlight following them. window.onRehearserPhaseChange = function (n) { refreshWorkflowCrumbs(n === 3 ? 'mp3' : n === 4 ? 'audiobook' : 'rehearser'); };