Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1155 lines
61 KiB
JavaScript
1155 lines
61 KiB
JavaScript
// ── 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<boolean> 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 = `<div class="audiobook-box">
|
||
<div class="audiobook-title"><span class="mdi mdi-alert-circle-outline"></span> ${escHtml(opts.title || 'Are you sure?')}</div>
|
||
<div class="audiobook-msg">${escHtml(message || '')}</div>
|
||
<div class="audiobook-actions">
|
||
<button type="button" class="btn-secondary btn-sm" id="cd-cancel">${escHtml(opts.cancelLabel || 'Cancel')}</button>
|
||
<button type="button" class="btn-primary btn-sm${opts.danger ? ' btn-danger' : ''}" id="cd-ok">${escHtml(opts.okLabel || 'OK')}</button>
|
||
</div>
|
||
</div>`;
|
||
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 `<button type="button" class="status-engine ${cls}" data-status-kind="${info.kind}" title="${escHtml(info.title || '')} — click to switch">
|
||
<span class="status-dot" aria-hidden="true"></span>
|
||
<span class="status-engine-label">${escHtml(info.label)}</span>
|
||
<span class="status-engine-value">${escHtml(info.value)}</span>
|
||
<span class="mdi mdi-chevron-up status-engine-caret"></span>
|
||
</button>`;
|
||
}
|
||
|
||
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
|
||
? `<div class="status-flyup-search"><span class="mdi mdi-magnify"></span><input type="text" placeholder="Search…" spellcheck="false"></div>`
|
||
: '';
|
||
const listHtml = items.length
|
||
? items.map(it => `<button type="button" class="status-flyup-item${it.active ? ' is-active' : ''}" data-val="${escHtml(it.id)}" data-search="${escHtml(it.label.toLowerCase())}">${it.active ? '<span class="mdi mdi-check"></span>' : ''}<span>${escHtml(it.label)}</span></button>`).join('')
|
||
: `<div class="status-flyup-empty">${escHtml(emptyLabel || 'Nothing available')}</div>`;
|
||
el.innerHTML = searchHtml + `<div class="status-flyup-list">${listHtml}</div>`;
|
||
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 <select> in the DOM (each
|
||
// screen keeps its own backend picker; this is the "quick switch everywhere"
|
||
// shortcut instead of hunting down each one individually).
|
||
function _statusApplyToSelects(ids, value) {
|
||
for (const id of ids) {
|
||
const el = $(id);
|
||
if (el && [...el.options || []].some(o => o.value === value)) {
|
||
el.value = value;
|
||
el.dispatchEvent(new Event('change'));
|
||
}
|
||
}
|
||
}
|
||
|
||
async function _statusOpenLlmFlyup(anchor) {
|
||
const { url, model, apiKey } = statusLlmTarget();
|
||
if (!url) { _statusFlyupList(anchor, [], null, 'No LLM endpoint configured', 'llm'); return; }
|
||
_statusFlyupList(anchor, [], null, 'Loading models…', 'llm');
|
||
let models = [];
|
||
try {
|
||
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
|
||
const data = await fetch(fetchUrl).then(r => r.json());
|
||
models = Array.isArray(data.models) ? data.models : [];
|
||
} catch (_) {}
|
||
if (!_statusFlyup || _statusFlyup.dataset.forKind !== 'llm') return; // closed/switched while awaiting
|
||
_statusFlyupList(anchor, models.map(m => ({ id: m, label: m, active: m === model })), (picked) => {
|
||
if (typeof _appSettings !== 'undefined') _appSettings.llm_model = picked;
|
||
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ llm_model: picked }) }).catch(() => {});
|
||
_statusApplyToSelects(['reh-llm-model', 'conv-llm-model-select'], picked);
|
||
refreshStatusBarEngines({ force: true });
|
||
toast('LLM model set to ' + picked, 'success');
|
||
}, 'No models found at this endpoint', 'llm');
|
||
}
|
||
|
||
function _statusOpenSttFlyup(anchor) {
|
||
const all = Array.isArray(_sttBackends) ? _sttBackends : [];
|
||
const preferred = $('conv-stt-select')?.value || $('stt-tts-stt-backend')?.value || $('clone-stt-backend')?.value || '';
|
||
_statusFlyupList(anchor, all.filter(b => b.available).map(b => ({
|
||
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||
})), (picked) => {
|
||
_statusApplyToSelects(['conv-stt-select', 'stt-tts-stt-backend', 'clone-stt-backend'], picked);
|
||
updateStatusBar();
|
||
toast('STT backend set to ' + picked, 'success');
|
||
}, 'No reachable STT backend', 'stt');
|
||
}
|
||
|
||
function _statusOpenTtsFlyup(anchor) {
|
||
const all = (typeof availableTtsBackends === 'function') ? availableTtsBackends() : [];
|
||
const preferred = $('tts-backend-select')?.value || $('reader-backend-select')?.value || $('reh-backend-select')?.value || '';
|
||
_statusFlyupList(anchor, all.map(b => ({
|
||
id: b.id, label: b.label || b.id, active: b.id === preferred,
|
||
})), (picked) => {
|
||
_statusApplyToSelects(['tts-backend-select', 'reader-backend-select', 'reh-backend-select'], picked);
|
||
updateStatusBar();
|
||
toast('TTS backend set to ' + picked, 'success');
|
||
}, 'No reachable TTS backend', 'tts');
|
||
}
|
||
|
||
document.addEventListener('click', e => {
|
||
const chip = e.target.closest('.status-engine');
|
||
if (chip) {
|
||
e.stopPropagation();
|
||
const kind = chip.dataset.statusKind;
|
||
if (_statusFlyup && _statusFlyup.dataset.forKind === kind) { _statusCloseFlyup(); return; }
|
||
if (kind === 'llm') _statusOpenLlmFlyup(chip);
|
||
else if (kind === 'stt') _statusOpenSttFlyup(chip);
|
||
else if (kind === 'tts') _statusOpenTtsFlyup(chip);
|
||
return;
|
||
}
|
||
if (!e.target.closest('.status-flyup')) _statusCloseFlyup();
|
||
});
|
||
|
||
async function refreshStatusBarEngines({ force = false } = {}) {
|
||
updateStatusBar();
|
||
const { url, model, apiKey } = statusLlmTarget();
|
||
if (!url || STATUS_LLM_CACHE.pending) return;
|
||
const fresh = STATUS_LLM_CACHE.url === url && STATUS_LLM_CACHE.model === model && (Date.now() - STATUS_LLM_CACHE.checked) < 30000;
|
||
if (fresh && !force) return;
|
||
STATUS_LLM_CACHE.pending = true;
|
||
try {
|
||
let fetchUrl = '/api/conversation/llm-models?url=' + encodeURIComponent(url);
|
||
if (apiKey) fetchUrl += '&api_key=' + encodeURIComponent(apiKey);
|
||
const data = await fetch(fetchUrl).then(r => r.json());
|
||
const models = Array.isArray(data.models) ? data.models : [];
|
||
STATUS_LLM_CACHE.url = url;
|
||
STATUS_LLM_CACHE.model = model;
|
||
STATUS_LLM_CACHE.ok = !data.error && (!!models.length || !model) && (!model || !models.length || models.includes(model));
|
||
STATUS_LLM_CACHE.checked = Date.now();
|
||
} catch (_) {
|
||
STATUS_LLM_CACHE.url = url;
|
||
STATUS_LLM_CACHE.model = model;
|
||
STATUS_LLM_CACHE.ok = false;
|
||
STATUS_LLM_CACHE.checked = Date.now();
|
||
} finally {
|
||
STATUS_LLM_CACHE.pending = false;
|
||
updateStatusBar();
|
||
}
|
||
}
|
||
|
||
window.updateStatusBar = updateStatusBar;
|
||
window.refreshStatusBarEngines = refreshStatusBarEngines;
|
||
function escHtml(s) {
|
||
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
function debounce(fn, ms) { let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); }; }
|
||
// Log a non-fatal error instead of silently swallowing it (was `.catch(()=>{})`).
|
||
// Use in best-effort paths so failures are visible in the console for debugging.
|
||
function logErr(context, err) { try { console.warn(`[${context}]`, err && (err.message || err)); } catch (_) {} }
|
||
// Run an async worker over items with bounded concurrency (default 4). `onProgress(done,total)`
|
||
// fires after each item. Replaces slow serial `for…await` loops over network calls.
|
||
async function runPool(items, worker, concurrency = 4, onProgress) {
|
||
const arr = [...items]; let i = 0, done = 0; const total = arr.length;
|
||
async function run() {
|
||
while (i < arr.length) {
|
||
const idx = i++;
|
||
try { await worker(arr[idx], idx); } catch (e) { logErr('runPool', e); }
|
||
done++; if (onProgress) { try { onProgress(done, total); } catch (_) {} }
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: Math.min(concurrency, arr.length) }, run));
|
||
}
|
||
async function copyText(text) {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
} catch(e) {
|
||
const ta = document.createElement('textarea');
|
||
ta.value = text;
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
ta.remove();
|
||
}
|
||
}
|
||
function fmtDuration(sec) {
|
||
if (sec == null || Number.isNaN(Number(sec))) return '-';
|
||
const total = Math.max(0, Math.round(Number(sec)));
|
||
const m = Math.floor(total / 60), s = total % 60;
|
||
return `${m}:${String(s).padStart(2,'0')}`;
|
||
}
|
||
|
||
function loadingMarkup(title, detail = '', rows = 4) {
|
||
const skeleton = Array.from({length: rows}, () => '<div class="skeleton-row"></div>').join('');
|
||
return `
|
||
<div class="loading-panel" role="status" aria-live="polite">
|
||
<div class="loading-box">
|
||
<div class="loading-head"><span class="spinner" aria-hidden="true"></span><span>${escHtml(title)}</span></div>
|
||
${detail ? `<div class="loading-sub">${escHtml(detail)}</div>` : ''}
|
||
<div class="skeleton-stack">${skeleton}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function setBusyButton(id, busy) {
|
||
const el = $(id);
|
||
if (el) el.disabled = !!busy;
|
||
}
|
||
async function clientAutoTrimBounds(fid) {
|
||
const resp = await fetch('/api/audio/' + encodeURIComponent(fid));
|
||
if (!resp.ok) throw new Error(resp.statusText || 'Audio not found');
|
||
const audioData = await resp.arrayBuffer();
|
||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||
const buffer = await ctx.decodeAudioData(audioData.slice(0));
|
||
try { ctx.close(); } catch (_) {} // iOS Safari hard-limits live AudioContexts — free it now
|
||
const samples = buffer.getChannelData(0);
|
||
const sr = buffer.sampleRate;
|
||
const dur = buffer.duration;
|
||
if (dur <= 20) return {start:0, end:dur, duration:dur, reason:'Audio is already short enough.'};
|
||
|
||
const chunkSec = 0.25, chunkSize = Math.max(1, Math.floor(sr * chunkSec));
|
||
const chunks = [];
|
||
for (let i = 0; i < samples.length; i += chunkSize) {
|
||
let sum = 0, peak = 0, n = Math.min(chunkSize, samples.length - i);
|
||
for (let j = 0; j < n; j++) {
|
||
const v = samples[i + j];
|
||
sum += v * v;
|
||
peak = Math.max(peak, Math.abs(v));
|
||
}
|
||
const rms = Math.sqrt(sum / Math.max(1, n));
|
||
const db = rms > 0 ? 20 * Math.log10(rms) : -80;
|
||
chunks.push({db, speech:false, clipped:peak > 0.96});
|
||
}
|
||
const avgDb = chunks.reduce((a,c)=>a+c.db,0) / chunks.length;
|
||
const floor = Math.max(avgDb - 18, -45);
|
||
chunks.forEach(c => c.speech = c.db >= floor);
|
||
|
||
function scoreWindow(startSec, lengthSec) {
|
||
const first = Math.floor(startSec / chunkSec);
|
||
const last = Math.min(chunks.length, Math.ceil((startSec + lengthSec) / chunkSec));
|
||
const win = chunks.slice(first, last);
|
||
if (!win.length) return {score:-9999};
|
||
const speech = win.filter(c => c.speech);
|
||
const speechRatio = speech.length / win.length;
|
||
const silenceRatio = 1 - speechRatio;
|
||
const clipRatio = win.filter(c => c.clipped).length / win.length;
|
||
const speechAvg = speech.length ? speech.reduce((a,c)=>a+c.db,0) / speech.length : -80;
|
||
const variance = speech.length ? speech.reduce((a,c)=>a+Math.pow(c.db-speechAvg,2),0) / speech.length : 100;
|
||
const score = speechRatio * 100 - silenceRatio * 55 - clipRatio * 85 - Math.abs(speechAvg + 20) * 1.7 - Math.min(18, Math.sqrt(variance) * 1.4) - Math.abs(lengthSec - 12) * 0.9;
|
||
return {score, speechRatio, silenceRatio, speechAvg};
|
||
}
|
||
|
||
let best = null;
|
||
[8,10,12,15,18].forEach(length => {
|
||
if (length > dur) return;
|
||
for (let start = 0; start <= dur - length; start += 0.5) {
|
||
const s = scoreWindow(start, length);
|
||
if (!best || s.score > best.score) best = {start, end:start + length, duration:length, ...s};
|
||
}
|
||
});
|
||
if (!best) return {start:0, end:Math.min(12,dur), duration:Math.min(12,dur), reason:'Using the beginning because no stable speech window was found.'};
|
||
return {
|
||
start:Number(best.start.toFixed(2)),
|
||
end:Number(best.end.toFixed(2)),
|
||
duration:Number(best.duration.toFixed(2)),
|
||
reason:`Selected ${best.duration.toFixed(1)}s with ${Math.round(best.speechRatio*100)}% speech, ${Math.round(best.silenceRatio*100)}% silence, avg ${best.speechAvg.toFixed(1)} dBFS.`
|
||
};
|
||
}
|
||
|
||
|
||
// ── Accessibility helpers ────────────────────────────────────────────────
|
||
function _a11yText(el) {
|
||
return String(el?.textContent || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function _a11yLabel(el) {
|
||
return el?.getAttribute('aria-label') || el?.getAttribute('title') || _a11yText(el) || '';
|
||
}
|
||
|
||
function enhanceAccessibility(root = document) {
|
||
const scope = root && root.querySelectorAll ? root : document;
|
||
const statusBar = $('status-bar');
|
||
if (statusBar) {
|
||
statusBar.setAttribute('role', 'status');
|
||
statusBar.setAttribute('aria-live', 'polite');
|
||
statusBar.setAttribute('aria-atomic', 'false');
|
||
}
|
||
const toastEl = $('toast');
|
||
if (toastEl) {
|
||
toastEl.setAttribute('aria-live', 'polite');
|
||
toastEl.setAttribute('aria-atomic', 'true');
|
||
}
|
||
|
||
scope.querySelectorAll('.nav-item, .nav-tree-item, .tab, [onclick]').forEach(el => {
|
||
if (/^(A|BUTTON|INPUT|SELECT|TEXTAREA|SUMMARY)$/i.test(el.tagName)) return;
|
||
if (!el.hasAttribute('role')) el.setAttribute('role', 'button');
|
||
if (!el.hasAttribute('tabindex')) el.setAttribute('tabindex', '0');
|
||
const label = _a11yLabel(el);
|
||
if (label && !el.getAttribute('aria-label')) el.setAttribute('aria-label', label);
|
||
if (label && !el.getAttribute('title')) el.setAttribute('title', label);
|
||
});
|
||
|
||
scope.querySelectorAll('button, [role=button], a, input, select, textarea').forEach(el => {
|
||
const label = _a11yLabel(el);
|
||
if (label && !el.getAttribute('title') && el.matches('button, [role=button]')) el.setAttribute('title', label);
|
||
if (label && !el.getAttribute('aria-label') && !_a11yText(el) && el.matches('button, [role=button]')) el.setAttribute('aria-label', label);
|
||
});
|
||
|
||
scope.querySelectorAll('button .mdi, [role=button] .mdi, .nav-icon .mdi').forEach(icon => {
|
||
icon.setAttribute('aria-hidden', 'true');
|
||
});
|
||
|
||
_syncNavActiveState();
|
||
}
|
||
|
||
// Split out of enhanceAccessibility() so the MutationObserver's attribute
|
||
// handler (fires on every class-attribute change anywhere in the app — very
|
||
// frequent once a session has any dynamic UI toggling active/hover classes)
|
||
// doesn't have to re-run the full enhancement pass (several document-wide
|
||
// querySelectorAll calls) just to keep nav aria-current in sync. That
|
||
// mismatch is what caused multi-second rAF-adjacent blocking / an
|
||
// effectively frozen page once this session's larger grids made class
|
||
// mutations much more frequent than when this observer was first written.
|
||
function _syncNavActiveState() {
|
||
document.querySelectorAll('.nav-item, .nav-tree-item').forEach(el => {
|
||
const active = el.classList.contains('active') || el.classList.contains('is-active');
|
||
if (active && el.getAttribute('aria-current') !== 'page') el.setAttribute('aria-current', 'page');
|
||
else if (!active && el.hasAttribute('aria-current')) el.removeAttribute('aria-current');
|
||
});
|
||
}
|
||
|
||
// Focus trap + restore for the ~11 places across the app that build a modal
|
||
// as `<div class="audiobook-overlay">…</div>` appended straight to
|
||
// document.body (confirmDialog, the avatar lightbox, csShow, voice pickers,
|
||
// etc.) — none of them individually managed focus, so a keyboard/screen-
|
||
// reader user could Tab straight out of an open dialog into the page behind
|
||
// it, and focus never returned to whatever triggered the dialog on close.
|
||
// Hooking this into the childList observer that's already watching
|
||
// document.body for every dynamically-inserted node avoids touching all 11
|
||
// call sites individually.
|
||
const _a11yModalStack = [];
|
||
function _a11yFocusables(root) {
|
||
return [...root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')]
|
||
.filter(el => !el.disabled && el.offsetParent !== null);
|
||
}
|
||
function _a11yOnModalOpened(ov) {
|
||
_a11yModalStack.push({ ov, trigger: document.activeElement });
|
||
const focusables = _a11yFocusables(ov);
|
||
(focusables[0] || ov).focus?.({ preventScroll: true });
|
||
if (!ov.hasAttribute('tabindex') && !focusables.length) ov.setAttribute('tabindex', '-1');
|
||
}
|
||
function _a11yOnModalClosed(ov) {
|
||
const idx = _a11yModalStack.findIndex(entry => entry.ov === ov);
|
||
if (idx < 0) return;
|
||
const [entry] = _a11yModalStack.splice(idx, 1);
|
||
if (document.body.contains(entry.trigger)) entry.trigger.focus?.({ preventScroll: true });
|
||
}
|
||
|
||
function initAccessibilityEnhancements() {
|
||
if (window.__ttsvcA11yReady) { enhanceAccessibility(document); return; }
|
||
window.__ttsvcA11yReady = true;
|
||
enhanceAccessibility(document);
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
const el = e.target?.closest?.('[role=button]');
|
||
if (!el || /^(BUTTON|A|INPUT|SELECT|TEXTAREA)$/i.test(el.tagName)) return;
|
||
e.preventDefault();
|
||
el.click();
|
||
});
|
||
// Tab trap: while any modal is open, Tab/Shift+Tab cycles only within the
|
||
// TOPMOST one's focusable elements instead of escaping into the page
|
||
// behind it.
|
||
document.addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Tab' || !_a11yModalStack.length) return;
|
||
const { ov } = _a11yModalStack[_a11yModalStack.length - 1];
|
||
const focusables = _a11yFocusables(ov);
|
||
if (!focusables.length) return;
|
||
const first = focusables[0], last = focusables[focusables.length - 1];
|
||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||
});
|
||
const obs = new MutationObserver((mutations) => {
|
||
for (const m of mutations) {
|
||
if (m.type === 'childList') {
|
||
m.addedNodes.forEach(n => {
|
||
if (n.nodeType !== 1) return;
|
||
enhanceAccessibility(n);
|
||
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalOpened(n);
|
||
});
|
||
m.removedNodes.forEach(n => {
|
||
if (n.nodeType !== 1) return;
|
||
if (n.classList?.contains('audiobook-overlay')) _a11yOnModalClosed(n);
|
||
});
|
||
} else if (m.type === 'attributes') {
|
||
// Cheap, targeted sync only — NOT a full-document re-scan.
|
||
_syncNavActiveState();
|
||
}
|
||
}
|
||
});
|
||
obs.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
|
||
}
|
||
|
||
// ── Light / dark theme ────────────────────────────────────────────────────
|
||
|
||
function applyTheme(t) {
|
||
document.documentElement.dataset.theme = t;
|
||
const btn = $('theme-btn');
|
||
if (btn) { btn.innerHTML = t === 'dark' ? '<span class="mdi mdi-weather-sunny"></span>' : '<span class="mdi mdi-weather-night"></span>'; btn.title = t === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'; }
|
||
const sel = $('s-theme-select');
|
||
if (sel) sel.value = t;
|
||
localStorage.setItem('vcf-theme', t);
|
||
}
|
||
$('theme-btn')?.addEventListener('click', () =>
|
||
applyTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark')
|
||
);
|
||
applyTheme(localStorage.getItem('vcf-theme') || 'dark');
|
||
|
||
// ── Language helpers ──────────────────────────────────────────────────────
|
||
|
||
function cc2flag(cc) {
|
||
if (!cc || cc.length !== 2) return '<span class="mdi mdi-earth"></span>';
|
||
return cc.toUpperCase().replace(/./g, c => String.fromCodePoint(c.charCodeAt(0) + 127397));
|
||
}
|
||
|
||
// Display label for a country code (3-letter where conventional)
|
||
const CC_DISPLAY = {
|
||
AU:'AUS', NZ:'NZL', GB:'GB', US:'US', CA:'CA', IE:'IRE', ZA:'ZAF', IN:'IND', SG:'SGP', PH:'PHL', NG:'NGA', KE:'KEN', GH:'GHA', JM:'JAM', TT:'TTO', MT:'MLT',
|
||
CH:'CH', AT:'AT', BE:'BE', CN:'CN', TW:'TWN', HK:'HKG', MO:'MAC', JP:'JP', KR:'KR', VN:'VNM', TH:'THA', ID:'IDN', MY:'MYS', PK:'PAK', BD:'BGD', LK:'LKA', NP:'NPL', IR:'IRN', IL:'ISR',
|
||
MX:'MEX', AR:'ARG', CO:'COL', CL:'CHL', PE:'PER', VE:'VEN', UY:'URY', EC:'ECU', BO:'BOL', CR:'CRI', CU:'CUB', DO:'DOM',
|
||
BR:'BRA', SA:'SAU', EG:'EGY', AE:'UAE', MA:'MAR', QA:'QAT', KW:'KWT', OM:'OMN', JO:'JOR', LB:'LBN', IQ:'IRQ',
|
||
DK:'DNK', NO:'NOR', SE:'SE', FI:'FIN', IS:'ISL', NL:'NL', LU:'LUX', LI:'LIE', FR:'FR', DE:'DE', ES:'ES', PT:'PT', IT:'IT', GR:'GRC', CY:'CYP', TR:'TR', PL:'PL', CZ:'CZE', SK:'SVK', HU:'HUN', RO:'ROU', BG:'BGR', HR:'HRV', SI:'SVN', RS:'SRB', BA:'BIH', ME:'MNE', MK:'MKD', AL:'ALB', EE:'EST', LV:'LVA', LT:'LTU', UA:'UKR', RU:'RU', BY:'BLR', MD:'MDA'
|
||
};
|
||
function ccDisplay(cc) { return CC_DISPLAY[cc] || cc; }
|
||
|
||
const EUROPE_FLAGS = [
|
||
['GB','GB - British'], ['IE','IRE - Irish'], ['DE','DE - German'], ['AT','AT - Austrian'], ['CH','CH - Swiss'], ['FR','FR - French'], ['BE','BE - Belgian'], ['NL','NL - Dutch'], ['LU','LUX - Luxembourgish'], ['LI','LIE - Liechtenstein'],
|
||
['ES','ES - Spanish'], ['PT','PT - Portuguese'], ['IT','IT - Italian'], ['MT','MLT - Maltese'], ['GR','GRC - Greek'], ['CY','CYP - Cypriot'],
|
||
['DK','DNK - Danish'], ['NO','NOR - Norwegian'], ['SE','SE - Swedish'], ['FI','FIN - Finnish'], ['IS','ISL - Icelandic'],
|
||
['PL','PL - Polish'], ['CZ','CZE - Czech'], ['SK','SVK - Slovak'], ['HU','HUN - Hungarian'], ['RO','ROU - Romanian'], ['BG','BGR - Bulgarian'], ['HR','HRV - Croatian'], ['SI','SVN - Slovenian'], ['RS','SRB - Serbian'], ['BA','BIH - Bosnian'], ['ME','MNE - Montenegrin'], ['MK','MKD - Macedonian'], ['AL','ALB - Albanian'],
|
||
['EE','EST - Estonian'], ['LV','LVA - Latvian'], ['LT','LTU - Lithuanian'], ['UA','UKR - Ukrainian'], ['RU','RU - Russian'], ['BY','BLR - Belarusian'], ['MD','MDA - Moldovan'], ['TR','TR - Turkish'],
|
||
];
|
||
const ASIA_FLAGS = [
|
||
['CN','CN - Mainland Chinese'], ['TW','TWN - Taiwanese'], ['HK','HKG - Hong Kong'], ['MO','MAC - Macau'], ['SG','SGP - Singapore'], ['JP','JP - Japanese'], ['KR','KR - Korean'],
|
||
['VN','VNM - Vietnamese'], ['TH','THA - Thai'], ['ID','IDN - Indonesian'], ['MY','MYS - Malaysian'], ['PH','PHL - Filipino'], ['IN','IND - Indian'], ['PK','PAK - Pakistani'], ['BD','BGD - Bangladeshi'], ['LK','LKA - Sri Lankan'], ['NP','NPL - Nepali'],
|
||
['SA','SAU - Saudi'], ['AE','UAE - Emirati'], ['QA','QAT - Qatari'], ['KW','KWT - Kuwaiti'], ['OM','OMN - Omani'], ['JO','JOR - Jordanian'], ['LB','LBN - Lebanese'], ['IQ','IRQ - Iraqi'], ['IR','IRN - Iranian'], ['IL','ISR - Israeli'],
|
||
];
|
||
const ENGLISH_FLAGS = [
|
||
['GB','GB - British'], ['US','US - American'], ['CA','CA - Canadian'], ['AU','AUS - Australian'], ['NZ','NZL - New Zealand'], ['IE','IRE - Irish'], ['ZA','ZAF - South African'], ['IN','IND - Indian English'], ['SG','SGP - Singapore English'], ['PH','PHL - Filipino English'], ['NG','NGA - Nigerian English'], ['KE','KEN - Kenyan English'], ['GH','GHA - Ghanaian English'], ['JM','JAM - Jamaican English'], ['TT','TTO - Trinidad and Tobago English'], ['MT','MLT - Maltese English'],
|
||
];
|
||
const LATAM_FLAGS = [
|
||
['MX','MEX - Mexican'], ['AR','ARG - Argentine'], ['CO','COL - Colombian'], ['CL','CHL - Chilean'], ['PE','PER - Peruvian'], ['VE','VEN - Venezuelan'], ['UY','URY - Uruguayan'], ['EC','ECU - Ecuadorian'], ['BO','BOL - Bolivian'], ['CR','CRI - Costa Rican'], ['CU','CUB - Cuban'], ['DO','DOM - Dominican'], ['BR','BRA - Brazilian'],
|
||
];
|
||
function uniqueFlagOptions(groups) {
|
||
const seen = new Set(), out = [];
|
||
groups.flat().forEach(item => { if (item && !seen.has(item[0])) { seen.add(item[0]); out.push(item); } });
|
||
return out;
|
||
}
|
||
|
||
const FLAG_OPTIONS = {
|
||
EN: ENGLISH_FLAGS,
|
||
DE: uniqueFlagOptions([[['DE','DE - German'],['AT','AT - Austrian'],['CH','CH - Swiss (DE)']], EUROPE_FLAGS]),
|
||
FR: uniqueFlagOptions([[['FR','FR - French'],['BE','BE - Belgian'],['CH','CH - Swiss (FR)'],['CA','CA - Canadian']], EUROPE_FLAGS]),
|
||
ZH: uniqueFlagOptions([[['CN','CN - Mainland'],['TW','TWN - Taiwanese'],['HK','HKG - Hong Kong'],['SG','SGP - Singaporean']], ASIA_FLAGS]),
|
||
ES: uniqueFlagOptions([[['ES','ES - Spain']], LATAM_FLAGS, EUROPE_FLAGS]),
|
||
PT: uniqueFlagOptions([[['PT','PT - Portuguese'],['BR','BRA - Brazilian']], EUROPE_FLAGS, LATAM_FLAGS]),
|
||
AR: uniqueFlagOptions([[['SA','SAU - Saudi'],['EG','EGY - Egyptian'],['AE','UAE - Emirati'],['MA','MAR - Moroccan']], ASIA_FLAGS]),
|
||
NL: uniqueFlagOptions([[['NL','NL - Dutch'],['BE','BE - Belgian']], EUROPE_FLAGS]),
|
||
JA: uniqueFlagOptions([[['JP','JP - Japanese']], ASIA_FLAGS]),
|
||
KO: uniqueFlagOptions([[['KR','KR - Korean']], ASIA_FLAGS]),
|
||
IT: uniqueFlagOptions([[['IT','IT - Italian']], EUROPE_FLAGS]),
|
||
RU: uniqueFlagOptions([[['RU','RU - Russian']], EUROPE_FLAGS, ASIA_FLAGS]),
|
||
PL: uniqueFlagOptions([[['PL','PL - Polish']], EUROPE_FLAGS]),
|
||
SV: uniqueFlagOptions([[['SE','SE - Swedish']], EUROPE_FLAGS]),
|
||
TR: uniqueFlagOptions([[['TR','TR - Turkish']], EUROPE_FLAGS, ASIA_FLAGS]),
|
||
HI: uniqueFlagOptions([[['IN','IND - Indian']], ASIA_FLAGS]),
|
||
};
|
||
|
||
const LANG_FLAG_DEFAULT = {
|
||
EN:'GB', DE:'DE', ZH:'CN', FR:'FR', ES:'ES', JA:'JP', KO:'KR',
|
||
IT:'IT', PT:'BR', RU:'RU', AR:'SA', PL:'PL', NL:'NL', SV:'SE', TR:'TR', HI:'IN',
|
||
};
|
||
|
||
const ALL_FLAGS = uniqueFlagOptions([ENGLISH_FLAGS, EUROPE_FLAGS, ASIA_FLAGS, LATAM_FLAGS, [
|
||
['EG','EGY - Egyptian'], ['MA','MAR - Moroccan'], ['ZA','ZAF - South African'], ['NG','NGA - Nigerian'], ['KE','KEN - Kenyan'], ['GH','GHA - Ghanaian'],
|
||
]]);
|
||
|
||
// ── Preview sample texts per language ────────────────────────────────────
|
||
const VL_SAMPLE_TEXTS = {
|
||
EN: 'Hello, how are you today? Please read this sample clearly for a fair voice benchmark.',
|
||
DE: 'Hallo, wie geht es Ihnen heute? Bitte lesen Sie diesen Text deutlich vor — für einen fairen Stimmvergleich.',
|
||
FR: 'Bonjour, comment allez-vous aujourd\'hui ? Veuillez lire ce texte clairement pour un test vocal équitable.',
|
||
ES: '¡Hola! ¿Cómo estás hoy? Por favor, lee este texto con claridad para una evaluación justa de la voz.',
|
||
PT: 'Olá, como vai você hoje? Por favor, leia este texto claramente para uma avaliação justa da voz.',
|
||
IT: 'Ciao, come stai oggi? Per favore leggi questo testo in modo chiaro per una valutazione equa della voce.',
|
||
NL: 'Hallo, hoe gaat het vandaag? Lees dit voorbeeld alstublieft duidelijk voor een eerlijke stembeoordeling.',
|
||
PL: 'Cześć, jak się dziś masz? Przeczytaj proszę ten tekst wyraźnie, aby dokonać rzetelnej oceny głosu.',
|
||
SV: 'Hej, hur mår du idag? Vänligen läs detta exempel tydligt för en rättvis röstbedömning.',
|
||
DA: 'Hej, hvordan har du det i dag? Læs venligst dette eksempel tydeligt for en retfærdig stemmevurdering.',
|
||
NB: 'Hei, hvordan har du det i dag? Vennligst les dette eksempelet tydelig for en rettferdig stemmevurdering.',
|
||
FI: 'Hei, kuinka voit tänään? Lue tämä esimerkki selkeästi reilua ääniarviointia varten.',
|
||
HU: 'Szia, hogy vagy ma? Kérlek, olvasd fel ezt a szöveget érthetően az igazságos hangértékelés érdekében.',
|
||
CS: 'Dobrý den, jak se máte? Přečtěte prosím tento text zřetelně pro spravedlivé hodnocení hlasu.',
|
||
RO: 'Bună ziua, cum vă simțiți astăzi? Vă rugăm citiți acest text clar pentru o evaluare corectă a vocii.',
|
||
UK: 'Привіт, як ви сьогодні? Будь ласка, прочитайте цей текст чітко для справедливого тестування голосу.',
|
||
RU: 'Здравствуйте, как вы сегодня? Пожалуйста, прочитайте этот текст чётко для справедливого тестирования голоса.',
|
||
TR: 'Merhaba, bugün nasılsınız? Adil bir ses değerlendirmesi için lütfen bu örneği açıkça okuyun.',
|
||
AR: 'مرحباً، كيف حالك اليوم؟ يرجى قراءة هذا النص بوضوح لإجراء اختبار صوت عادل.',
|
||
HI: 'नमस्ते, आप आज कैसे हैं? कृपया इस नमूने को स्पष्ट रूप से पढ़ें ताकि एक उचित आवाज़ मूल्यांकन हो सके।',
|
||
ZH: '你好,你今天怎么样?请清晰地朗读这段示例,以便进行公正的语音评测。',
|
||
JA: 'こんにちは、今日はいかがですか?公平な音声評価のために、このサンプルをはっきりと読んでください。',
|
||
KO: '안녕하세요, 오늘 어떠세요? 공정한 음성 벤치마크를 위해 이 샘플을 명확하게 읽어주세요.',
|
||
};
|
||
|
||
const _VL_LANG_FLAG = Object.assign({ DA:'DK', NB:'NO', FI:'FI', HU:'HU', CS:'CZ', RO:'RO', UK:'UA' }, LANG_FLAG_DEFAULT);
|
||
|
||
function setPreviewLang(lang) {
|
||
const ta = $('vl-preview-sample');
|
||
const hidden = $('benchmark-sample-text');
|
||
if (!ta) return;
|
||
const text = VL_SAMPLE_TEXTS[lang] || VL_SAMPLE_TEXTS.EN;
|
||
ta.value = text;
|
||
ta.dispatchEvent(new Event('input'));
|
||
if (hidden) { hidden.value = text; hidden.dispatchEvent(new Event('input')); }
|
||
try { localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, text); } catch {}
|
||
const btn = $('vl-preview-lang-btn');
|
||
if (btn) {
|
||
const cc = (_VL_LANG_FLAG[lang] || 'gb').toLowerCase();
|
||
btn.innerHTML = `<span class="fi fi-${cc}"></span>`;
|
||
btn.title = (LANGUAGE_LABELS[lang] || lang) + ' — click to change';
|
||
btn.dataset.lang = lang;
|
||
}
|
||
}
|
||
|
||
function openPreviewLangPicker(anchor) {
|
||
const items = Object.keys(VL_SAMPLE_TEXTS).map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]);
|
||
createSearchablePicker(anchor, items, setPreviewLang, {
|
||
placeholder: 'Sample language…',
|
||
renderItem: (l) => {
|
||
const cc = (_VL_LANG_FLAG[l] || 'gb').toLowerCase();
|
||
return `<span class="fi fi-${cc}" style="width:20px;height:14px;background-size:cover;border-radius:2px;flex-shrink:0;display:inline-block"></span>`
|
||
+ `<span class="ipi-code">${escHtml(l)}</span>`
|
||
+ `<span>${escHtml(LANGUAGE_LABELS[l] || l)}</span>`;
|
||
},
|
||
});
|
||
}
|
||
|
||
// ── Searchable picker dropdown ────────────────────────────────────────────
|
||
function createSearchablePicker(anchor, items, onSelect, { placeholder = 'Search…', renderItem } = {}) {
|
||
document.querySelector('.insp-picker')?.remove();
|
||
const panel = document.createElement('div');
|
||
panel.className = 'insp-picker';
|
||
|
||
const search = document.createElement('input');
|
||
search.type = 'search'; search.className = 'insp-picker-search';
|
||
search.placeholder = placeholder; search.autocomplete = 'off';
|
||
|
||
const list = document.createElement('div');
|
||
list.className = 'insp-picker-list';
|
||
|
||
const renderList = (q) => {
|
||
const f = q.trim().toLowerCase();
|
||
const filtered = f
|
||
? items.filter(([code, name]) => name.toLowerCase().includes(f) || code.toLowerCase().includes(f))
|
||
: items;
|
||
list.innerHTML = '';
|
||
if (!filtered.length) { list.innerHTML = '<div class="insp-picker-empty">No matches</div>'; return; }
|
||
filtered.forEach(([code, name]) => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'insp-picker-item'; btn.type = 'button';
|
||
btn.innerHTML = renderItem ? renderItem(code, name) : `<span class="ipi-code">${escHtml(code)}</span><span>${escHtml(name)}</span>`;
|
||
btn.addEventListener('mousedown', (e) => { e.preventDefault(); onSelect(code); panel.remove(); cleanup(); });
|
||
list.appendChild(btn);
|
||
});
|
||
};
|
||
search.addEventListener('input', () => renderList(search.value));
|
||
renderList('');
|
||
|
||
panel.appendChild(search); panel.appendChild(list);
|
||
document.body.appendChild(panel);
|
||
|
||
const rect = anchor.getBoundingClientRect();
|
||
const pw = 244;
|
||
let left = rect.left;
|
||
if (left + pw > window.innerWidth - 8) left = window.innerWidth - pw - 8;
|
||
panel.style.top = (rect.bottom + 4) + 'px';
|
||
panel.style.left = Math.max(8, left) + 'px';
|
||
|
||
const cleanup = () => { document.removeEventListener('mousedown', onOut); document.removeEventListener('keydown', onKey); };
|
||
const onOut = (e) => { if (!panel.contains(e.target)) { panel.remove(); cleanup(); } };
|
||
const onKey = (e) => { if (e.key === 'Escape') { panel.remove(); cleanup(); } };
|
||
setTimeout(() => { document.addEventListener('mousedown', onOut); document.addEventListener('keydown', onKey); }, 0);
|
||
search.focus();
|
||
}
|
||
|
||
// ── Tag reuse (localStorage) ──────────────────────────────────────────────
|
||
const _TAG_KEY = 'ttsvc-voice-tags';
|
||
function getStoredTags() {
|
||
try { return JSON.parse(localStorage.getItem(_TAG_KEY) || '[]'); } catch { return []; }
|
||
}
|
||
function addStoredTag(tag) {
|
||
const t = tag.trim(); if (!t) return;
|
||
const tags = getStoredTags().filter(x => x !== t);
|
||
tags.unshift(t);
|
||
try { localStorage.setItem(_TAG_KEY, JSON.stringify(tags.slice(0, 60))); } catch {}
|
||
}
|
||
|
||
// Voice-library rendering can be expensive with large local collections because it
|
||
// creates many rich rows and image requests. Schedule automatic refreshes after the
|
||
// current startup/navigation task so core routing is interactive first.
|
||
const _voiceLibraryLoadCallbacks = [];
|
||
const _VOICE_LIBRARY_LOAD_NAV_WAIT_MS = 3500;
|
||
function scheduleVoiceLibraryLoad(onDone) {
|
||
if (typeof onDone === 'function') _voiceLibraryLoadCallbacks.push(onDone);
|
||
if (window.__voiceLibraryLoadScheduled) return;
|
||
window.__voiceLibraryLoadScheduled = true;
|
||
const scheduledAt = (performance && performance.now) ? performance.now() : Date.now();
|
||
const run = () => {
|
||
const now = (performance && performance.now) ? performance.now() : Date.now();
|
||
if (typeof window.navTo !== 'function' && now - scheduledAt < _VOICE_LIBRARY_LOAD_NAV_WAIT_MS) {
|
||
setTimeout(run, 80);
|
||
return;
|
||
}
|
||
window.__voiceLibraryLoadScheduled = false;
|
||
if (typeof loadVoiceLibrary !== 'function') {
|
||
_voiceLibraryLoadCallbacks.splice(0);
|
||
return;
|
||
}
|
||
loadVoiceLibrary()
|
||
.then(() => {
|
||
const callbacks = _voiceLibraryLoadCallbacks.splice(0);
|
||
callbacks.forEach(fn => { try { fn(); } catch (e) { logErr('scheduleVoiceLibraryLoad callback', e); } });
|
||
})
|
||
.catch(e => {
|
||
_voiceLibraryLoadCallbacks.splice(0);
|
||
status('Voice library load failed: ' + (e.message || e));
|
||
});
|
||
};
|
||
if ('requestIdleCallback' in window) requestIdleCallback(run, { timeout: 1200 });
|
||
else setTimeout(run, 0);
|
||
}
|
||
window.scheduleVoiceLibraryLoad = scheduleVoiceLibraryLoad;
|
||
|
||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||
|
||
function disabledBackendTabMessage(tab) {
|
||
const required = tab?.dataset.backendRequired || '';
|
||
if (required === 'any_tts') return 'No Qwen3-TTS backend is running or configured. Check Settings.';
|
||
const backend = (_ttsBackends || []).find(b => b.id === required);
|
||
const label = backend?.label || required.replace(/_/g, ' ');
|
||
const url = backend?.url ? ` (${backend.url})` : '';
|
||
return `${label}${url} is not running or not configured in Settings.`;
|
||
}
|
||
|
||
function switchTab(name) {
|
||
const targetTab = document.querySelector(`.tab[data-tab="${name}"]`);
|
||
if (targetTab?.classList.contains('backend-unavailable')) {
|
||
toast(disabledBackendTabMessage(targetTab), 'error');
|
||
return false;
|
||
}
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
|
||
if (name === 'library') scheduleVoiceLibraryLoad();
|
||
if (name === 'integrations') {
|
||
if (!_voices.length) scheduleVoiceLibraryLoad(renderIntegrationSnippets);
|
||
renderIntegrationSnippets();
|
||
}
|
||
if (name === 'performance' && typeof window.populateBatchBenchmarkFromLibrary === 'function') {
|
||
window.populateBatchBenchmarkFromLibrary();
|
||
}
|
||
if (name === 'performance' && typeof window.loadBenchmarkSectionData === 'function') {
|
||
window.loadBenchmarkSectionData();
|
||
}
|
||
if (name === 'routing') loadRoutingTab();
|
||
if (name === 'getvoices') {
|
||
if (typeof window.initElevenLabsBrowser === 'function') window.initElevenLabsBrowser();
|
||
loadGetVoices();
|
||
}
|
||
return true;
|
||
}
|
||
// ── Language detection ────────────────────────────────────────────────────────
|
||
// Lightweight, dependency-free detector. First by script (CJK / Cyrillic /
|
||
// Hangul), then by stop-word frequency for Latin-script languages. Returns a
|
||
// full English language name ("German", "English", …) matching the design-language
|
||
// <select>, 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(`<span class="wf-stepper-title" title="${escHtml(title)}">${escHtml(title)}</span>`);
|
||
const row2 = [];
|
||
row2.push(`<button type="button" class="wf-nav-btn wf-nav-prev" data-wf-nav="prev" title="${prevStep ? 'Back to ' + escHtml(prevStep.label) : 'No previous step'}"${prevStep ? '' : ' disabled'}><span class="mdi mdi-chevron-left"></span></button>`);
|
||
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(`<span class="wf-step-arrow${done ? ' is-done' : ''}">›</span>`);
|
||
row2.push(
|
||
`<button type="button" class="wf-step${cur ? ' is-current' : ''}${done ? ' is-done' : ''}${st.optional ? ' is-optional' : ''}" ` +
|
||
`data-wf="${st.key}"${(!enabled || cur) ? ' disabled' : ''} title="${st.optional ? 'Optional — ' : ''}${escHtml(st.label)}">` +
|
||
`<span class="wf-step-num">${done ? '<span class="mdi mdi-check"></span>' : i + 1}</span><span>${escHtml(st.label)}${st.optional ? '<small>optional</small>' : ''}</span></button>`
|
||
);
|
||
});
|
||
row2.push(`<button type="button" class="wf-nav-btn wf-nav-next" data-wf-nav="next" title="${nextStep ? 'On to ' + escHtml(nextStep.label) : 'No next step'}"${nextStep ? '' : ' disabled'}><span class="mdi mdi-chevron-right"></span></button>`);
|
||
el.innerHTML = `<div class="wf-stepper-row1">${row1.join('')}</div><div class="wf-stepper-row2">${row2.join('')}</div>`;
|
||
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 =
|
||
`<button type="button" class="wf-header-nav-btn wf-header-nav-prev"><span class="mdi mdi-chevron-left"></span><span class="wf-header-nav-label">Previous</span></button>` +
|
||
`<button type="button" class="wf-header-nav-btn wf-header-nav-next"><span class="wf-header-nav-label">Next</span><span class="mdi mdi-chevron-right"></span></button>`;
|
||
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 =
|
||
`<span class="wf-step-hint-badge">Step ${curIdx + 1}/${WF_STEPS.length}${curStep.optional ? ' · optional' : ''}</span>` +
|
||
`<span class="wf-step-hint-text">${escHtml(curStep.hint)}</span>`;
|
||
} 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');
|
||
};
|