// ── 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 || (typeof _appSettings !== 'undefined' && _appSettings?.stt_preferred_backend) || ''; 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 s on // other pages) is what makes the pick actually take effect anywhere that // resolves the STT backend server-side without an explicit one of its // own — e.g. the Voice Library's "Re-recognise text" — which otherwise // silently kept using the stale legacy "configured" Whisper URL no // matter what was chosen here (confirmed live). if (typeof _appSettings !== 'undefined') _appSettings.stt_preferred_backend = picked; fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stt_preferred_backend: picked }) }).catch(() => {}); _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 (_) {} } // Keep batch work conservative on small client devices. Desktop machines // without the Device Memory API keep the normal pool size; browsers that do // expose it get a hard cap before multiple audio/image requests can build up. function ttvcIsLowMemoryDevice() { const memory = Number(globalThis.navigator?.deviceMemory); return Number.isFinite(memory) && memory > 0 && memory <= 4; } window.ttvcIsLowMemoryDevice = ttvcIsLowMemoryDevice; // 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; const requested = Math.max(1, Number(concurrency) || 1); const effectiveConcurrency = ttvcIsLowMemoryDevice() ? Math.min(2, requested) : requested; 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(effectiveConcurrency, 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}, () => '
').join(''); return `
${escHtml(title)}
${detail ? `
${escHtml(detail)}
` : ''}
${skeleton}
`; } 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 `
…
` 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' ? '' : ''; 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 ''; 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 = ``; 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 `` + `${escHtml(l)}` + `${escHtml(LANGUAGE_LABELS[l] || l)}`; }, }); } // ── 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 = '
No matches
'; return; } filtered.forEach(([code, name]) => { const btn = document.createElement('button'); btn.className = 'insp-picker-item'; btn.type = 'button'; btn.innerHTML = renderItem ? renderItem(code, name) : `${escHtml(code)}${escHtml(name)}`; 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 // s with ~17 hardcoded codes, so any // language outside that list simply could not be entered (reported live: // Filipino). A fixed list will always be missing something, so these are now // editable comboboxes: type to search, or type a code that isn't listed at all. // One shared is injected once here rather than duplicated into each // section fragment, which would mean three copies of the same element id. // ISO 639-1 language codes, with regional variants expressed as BCP 47 // subtags (EN-GB, DE-AT, PT-BR) rather than bare country codes. That removes // the namespace collision the earlier attempt created: a bare "CA" cannot mean // both Canada and Catalan, and a bare "AR" cannot mean both Argentina and // Arabic — under BCP 47 those are EN-CA and ES-AR, while CA/AR keep their ISO // meanings. core/routing.py collapses any variant onto its base language. const LANG_CODE_OPTIONS = [ ['EN','English'], ['EN-GB','English · British'], ['EN-US','English · American'], ['EN-AU','English · Australian'], ['EN-CA','English · Canadian'], ['EN-IE','English · Irish'], ['EN-SCT','English · Scottish'], ['EN-NZ','English · New Zealand'], ['EN-ZA','English · South African'], ['EN-IN','English · Indian'], ['DE','German · Deutsch'],['DE-AT','German · Austrian'],['DE-CH','German · Swiss'], ['FR','French · Français'],['FR-CA','French · Québécois'], ['ES','Spanish · Español'],['ES-MX','Spanish · Mexican'],['ES-AR','Spanish · Argentinian'], ['IT','Italian · Italiano'], ['PT','Portuguese · Português'],['PT-BR','Portuguese · Brazilian'], ['NL','Dutch · Nederlands'],['PL','Polish · Polski'],['RU','Russian · Русский'],['UK','Ukrainian'], ['CS','Czech'],['SK','Slovak'],['SL','Slovenian'],['HR','Croatian'],['SR','Serbian'],['BG','Bulgarian'], ['RO','Romanian'],['HU','Hungarian'],['EL','Greek'],['TR','Turkish · Türkçe'], ['SV','Swedish · Svenska'],['NO','Norwegian'],['DA','Danish'],['FI','Finnish'],['IS','Icelandic'], ['ET','Estonian'],['LV','Latvian'],['LT','Lithuanian'], ['ZH','Chinese · 中文'],['JA','Japanese · 日本語'],['KO','Korean · 한국어'], ['TL','Filipino · Tagalog'],['ID','Indonesian'],['MS','Malay'],['VI','Vietnamese'], ['TH','Thai'],['KM','Khmer'],['MY','Burmese'],['LO','Lao'], ['HI','Hindi'],['BN','Bengali'],['UR','Urdu'],['PA','Punjabi'],['TA','Tamil'],['TE','Telugu'], ['ML','Malayalam'],['KN','Kannada'],['MR','Marathi'],['GU','Gujarati'],['NE','Nepali'],['SI','Sinhala'], ['AR','Arabic · العربية'],['HE','Hebrew'],['FA','Persian · Farsi'],['KU','Kurdish'], ['SW','Swahili'],['AM','Amharic'],['HA','Hausa'],['YO','Yoruba'],['IG','Igbo'],['ZU','Zulu'],['AF','Afrikaans'], ['CA','Catalan'],['EU','Basque'],['GL','Galician'],['CY','Welsh'],['GA','Irish'],['MT','Maltese'], ['SQ','Albanian'],['MK','Macedonian'],['KA','Georgian'],['HY','Armenian'],['AZ','Azerbaijani'], ['KK','Kazakh'],['UZ','Uzbek'],['MN','Mongolian'],['LA','Latin'], ]; function ensureLangDatalist() { if (document.getElementById('lang-codes')) return; const dl = document.createElement('datalist'); dl.id = 'lang-codes'; dl.innerHTML = LANG_CODE_OPTIONS .map(([code, name]) => ``).join(''); document.body.appendChild(dl); } // The code becomes part of the voice ID (EN_F_Anna), so a free-text field has // to be constrained to something that can live in a filename: letters only, // upper-cased, and short enough to stay readable as a prefix. function normalizeLangCodeInput(el) { if (!el) return; // Allows a BCP 47 subtag (EN-GB) as well as a bare code, since regional // accents are now expressed that way. const clean = String(el.value || '').replace(/[^A-Za-z-]/g, '').toUpperCase().slice(0, 8); if (clean !== el.value) el.value = clean; } document.addEventListener('input', (e) => { const t = e.target; if (t && t.matches && t.matches('input[list="lang-codes"]')) normalizeLangCodeInput(t); }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ensureLangDatalist); else ensureLangDatalist(); // Language code -> country code for the flag emoji. LANG_FLAG_DEFAULT only // covered the original 16 languages; this extends the mapping to everything in // LANG_CODE_OPTIONS so no entry falls back to a bare globe. const LANG_CC = { // BCP 47 regional subtags — the region decides the flag. 'EN-GB':'GB','EN-US':'US','EN-AU':'AU','EN-CA':'CA','EN-IE':'IE','EN-SCT':'GB', 'EN-NZ':'NZ','EN-ZA':'ZA','EN-IN':'IN','DE-AT':'AT','DE-CH':'CH','FR-CA':'CA', 'ES-MX':'MX','ES-AR':'AR','PT-BR':'BR', EN:'GB', DE:'DE', FR:'FR', ES:'ES', IT:'IT', PT:'BR', NL:'NL', PL:'PL', RU:'RU', UK:'UA', CS:'CZ', SK:'SK', SL:'SI', HR:'HR', SR:'RS', BG:'BG', RO:'RO', HU:'HU', EL:'GR', TR:'TR', SV:'SE', NO:'NO', DA:'DK', FI:'FI', IS:'IS', ET:'EE', LV:'LV', LT:'LT', ZH:'CN', JA:'JP', KO:'KR', TL:'PH', ID:'ID', MS:'MY', VI:'VN', TH:'TH', KM:'KH', MY:'MM', LO:'LA', HI:'IN', BN:'BD', UR:'PK', PA:'IN', TA:'IN', TE:'IN', ML:'IN', KN:'IN', MR:'IN', GU:'IN', NE:'NP', SI:'LK', AR:'SA', HE:'IL', FA:'IR', KU:'IQ', SW:'KE', AM:'ET', HA:'NG', YO:'NG', IG:'NG', ZU:'ZA', AF:'ZA', CA:'ES', EU:'ES', GL:'ES', CY:'GB', GA:'IE', MT:'MT', SQ:'AL', MK:'MK', KA:'GE', HY:'AM', AZ:'AZ', KK:'KZ', UZ:'UZ', MN:'MN', LA:'VA', }; const LANG_NAME_CC = { english:'GB', german:'DE', french:'FR', spanish:'ES', italian:'IT', portuguese:'BR', dutch:'NL', polish:'PL', russian:'RU', chinese:'CN', japanese:'JP', korean:'KR', arabic:'SA', hindi:'IN', turkish:'TR', swedish:'SE', filipino:'PH', tagalog:'PH', }; function langFlag(code) { const c = String(code || '').toUpperCase(); if (c === '*' ) return ''; if (c === 'AUTO') return ''; let cc = LANG_CC[c] || (typeof LANG_FLAG_DEFAULT !== 'undefined' ? LANG_FLAG_DEFAULT[c] : ''); // Name-valued selects ("English", "German") resolve through their own map. if (!cc) cc = LANG_NAME_CC[String(code || '').trim().toLowerCase()] || ''; // Unknown BCP 47 tag: fall back to the base language's flag (EN-XX -> EN). if (!cc && c.includes('-')) cc = LANG_CC[c.split('-')[0]] || ''; return cc ? cc2flag(cc) : ''; } // Turns a plain stays in the DOM as the single // source of truth (hidden), so every existing `.value` read/write and change // listener keeps working untouched. That matters here because routing reads // `row.querySelector('.route-lang').value` when saving — swapping in a custom // element would have silently broken saving rather than looking broken. function enhanceLangSelect(sel) { if (!sel || sel.dataset.enhanced === '1') return; sel.dataset.enhanced = '1'; const opts = [...sel.options].map(o => ({ value: o.value, label: o.textContent.trim() })); const wrap = document.createElement('div'); wrap.className = 'langsel'; const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'langsel-btn'; const pop = document.createElement('div'); pop.className = 'langsel-pop'; pop.hidden = true; pop.innerHTML = `` + `
`; sel.parentNode.insertBefore(wrap, sel); wrap.appendChild(btn); wrap.appendChild(pop); wrap.appendChild(sel); sel.classList.add('langsel-native'); const listEl = pop.querySelector('.langsel-list'); const searchEl = pop.querySelector('input'); const paint = () => { const cur = opts.find(o => o.value === sel.value) || opts[0] || { value: '', label: '' }; // Show the CODE, not the name: this control sits in a narrow table column, // where a name truncates to something useless ("German · Deutsch" -> "G."). // The flag carries recognition, the code is unambiguous, and the full name // is on the tooltip and in the open list. const short = cur.value === '*' ? 'Any' : cur.value === 'AUTO' ? 'Auto' : cur.value; btn.innerHTML = `${langFlag(cur.value)}` + `${escHtml(short)}`; btn.title = cur.label; }; const renderList = (q = '') => { const needle = q.trim().toLowerCase(); const shown = opts.filter(o => !needle || o.label.toLowerCase().includes(needle) || o.value.toLowerCase().includes(needle)); listEl.innerHTML = shown.length ? shown.map(o => ``).join('') : '
No language matches
'; }; const close = () => { pop.hidden = true; }; const open = () => { document.querySelectorAll('.langsel-pop').forEach(p => { if (p !== pop) p.hidden = true; }); renderList(''); searchEl.value = ''; pop.hidden = false; searchEl.focus(); }; btn.addEventListener('click', e => { e.stopPropagation(); pop.hidden ? open() : close(); }); searchEl.addEventListener('input', () => renderList(searchEl.value)); searchEl.addEventListener('keydown', e => { if (e.key === 'Escape') { close(); btn.focus(); } if (e.key === 'Enter') { const first = listEl.querySelector('.langsel-opt'); if (first) first.click(); } e.stopPropagation(); }); listEl.addEventListener('click', e => { const o = e.target.closest('.langsel-opt'); if (!o) return; sel.value = o.dataset.val; // Dispatch on the real