// ── 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); } 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; el.innerHTML = items.length ? items.map(it => ``).join('') : `
${escHtml(emptyLabel || 'Nothing available')}
`; 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(); }); }); _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; } return bestN >= 3 ? best : 'English'; } window.detectLang = detectLang; 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 }, { key: 'cast', label: 'Cast Audiobook', enabled: () => !!window.readerState?.sentences?.length }, { key: 'chars', label: 'Cast Characters', enabled: () => !!window.readerState?.sentences?.length, optional: true }, { key: 'castlib', label: 'Cast', enabled: () => !!window.readerState?.sentences?.length }, { key: 'rehearser', label: 'Script Rehearser',enabled: () => !!window.rehState?.lines?.length }, { key: 'mp3', label: 'Generate MP3s', enabled: () => !!window.rehState?.lines?.length }, { key: 'audiobook', label: 'Audiobook', enabled: () => !!window.rehState?.clips?.length }, ]; 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 === 'rehearser') { 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'); if (!containers.length) return; // 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()); containers.forEach(el => { if (!anyState) { el.innerHTML = ''; el.hidden = true; return; } el.hidden = false; const curIdx = WF_STEPS.findIndex(st => st.key === _wfActive); const prevStep = curIdx >= 0 ? _wfNeighbor(curIdx, -1) : null; const nextStep = curIdx >= 0 ? _wfNeighbor(curIdx, 1) : null; const parts = []; parts.push(``); if (title) parts.push(`${escHtml(title)}`); WF_STEPS.forEach((st, i) => { const enabled = st.enabled(); const cur = _wfActive === st.key; const done = curIdx >= 0 && i < curIdx && enabled; if (i > 0) parts.push(``); parts.push( `` ); }); parts.push(``); el.innerHTML = parts.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)); }); } window.refreshWorkflowCrumbs = refreshWorkflowCrumbs; // 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'); };