From 28f5ec2e255939f2b0281c3844328cb3a80068bd Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Tue, 26 May 2026 11:23:27 +0200 Subject: [PATCH] Redesign inspector pane, add searchable pickers, and style AI Backends tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspector: - Skinny 2-row header: 72px avatar + name/ID row / subtitle row / note row - Searchable flag picker (dblclick flag icon) — filtered by voice language, falls back to ALL_FLAGS - Searchable language picker (dblclick lang code) — shows full language names - Tag reuse: entered tags persist to localStorage as datalist suggestions - Compact active toggle (32×18px), slim save button (12px/4px padding) - Show/hide eye toggle and "✓ Key saved" badge on API key fields AI Backends (s-llms.html + style.css): - Full CSS design: pill tabs with active accent, animated section transitions - Service cards: icon bubbles, tier badges (Free/Demo/Paid), stat chips, endpoint rows, model tags - Highlighted recommended card with accent border - Dark code blocks for local service snippets with copy feedback - Show/hide password toggle and auto-appearing "✓ Key saved" badge per card Co-Authored-By: Claude Sonnet 4.6 --- static/app.js | 586 +++++++++++++++++++++++++- static/index.html | 20 +- static/loader.js | 2 +- static/nav.js | 96 +++-- static/sections/s-llms.html | 634 +++++++++++++++++++++++++++++ static/sections/s-voices.html | 447 ++++++++++---------- static/style.css | 744 ++++++++++++++++++++++++++++++++-- 7 files changed, 2218 insertions(+), 311 deletions(-) create mode 100644 static/sections/s-llms.html diff --git a/static/app.js b/static/app.js index 725b989..79eb23e 100644 --- a/static/app.js +++ b/static/app.js @@ -1,6 +1,322 @@ // ── 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]; +} + +// ── Voice inspector (3-pane workbench) ─────────────────────────────────── +let _selectedVoiceWrap = null; + +function selectVoice(wrap) { + const inspector = document.getElementById('voices-inspector'); + if (!inspector) return; + + function restoreToRow(targetWrap) { + // Restore all extracted elements back to their original DOM parents + (targetWrap._extracted || []).forEach(({el, target}) => { + if (el && target) target.appendChild(el); + }); + targetWrap._extracted = []; + // Move main-row and optimizer back from inspector body to wrap + inspector.querySelectorAll('.vr-main-row,.vr-optimizer').forEach(el => targetWrap.appendChild(el)); + } + + // Deselect previous voice + if (_selectedVoiceWrap && _selectedVoiceWrap !== wrap) { + _selectedVoiceWrap.classList.remove('vr-selected'); + restoreToRow(_selectedVoiceWrap); + } + + if (_selectedVoiceWrap === wrap) { + restoreToRow(wrap); + wrap.classList.remove('vr-selected'); + _selectedVoiceWrap = null; + inspector.innerHTML = '
🔊

Pick a voice on the left
to edit it here

'; + return; + } + + _selectedVoiceWrap = wrap; + wrap.classList.add('vr-selected'); + + const voiceId = wrap.dataset.id || ''; + const color = wrap.dataset.color || '#9575CD'; + const isClone = wrap.dataset.hasRef === 'true'; + const dbfs = wrap.dataset.dbfs || '-'; + const hasPicture = wrap.dataset.hasPicture === 'true'; + + // Live voice object — authoritative for mutable fields + const v = (_voices || []).find(vv => vv.id === voiceId) || {}; + const langCode = v.lang || wrap.dataset.lang || ''; + const flagCc = v.flag || wrap.dataset.flagCc || langCode; + const rating = v.rating || 0; + + const nameParts = voiceId.split('_'); + const dispName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : voiceId; + const initial = (dispName[0] || voiceId[0] || '?').toUpperCase(); + const picSrcInsp = hasPicture ? `/api/voice/picture/${encodeURIComponent(voiceId)}` : null; + const inspFlagCc = flagCc || langCode; + const inspFlagIcon = inspFlagCc + ? `` : null; + + const avatarHtml = picSrcInsp + ? `` + : inspFlagIcon || initial; + const avatarBgStyle = (picSrcInsp || inspFlagIcon) ? '' : `style="background:${color}"`; + + const flagIconHtml = inspFlagIcon || ''; + + const makeStars = n => [1,2,3,4,5].map(i => + `` + ).join(''); + + const LANGS = ['EN','DE','IT','ES','FR','PT','NL','PL','ZH','JA','KO','AR','RU','TR','HI','SV','DA','FI','NB','HU','CS','RO','UK']; + + // Gender maps — used in template AND in event handlers + const _gM = {F:'♀', M:'♂', N:'⚥', '':'?'}; + const _gL = {F:'Female', M:'Male', N:'Diverse', '':'—'}; + const _gC = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; + const curGender = v.gender || 'N'; + const genderLabelHtml = `${_gM[curGender]||'?'} ${_gL[curGender]||'—'}`; + + inspector.innerHTML = ` +
+
+
${avatarHtml}
+
+
+

${escHtml(dispName)}

+ +
+
+
+
+ ${escHtml(voiceId)} + +
+
+
+
+
+ + ${flagIconHtml} + + + ${escHtml(langCode)} + + ${escHtml(genderLabelHtml)} + ${isClone ? 'Clone' : 'Design'} + + ${makeStars(rating)} + ${rating}/5 + + +
+
+
+
+
+
+
+
+
+ `; + + const saveSlot = inspector.querySelector('.insp-actions-save'); + const activeSlot = inspector.querySelector('.insp-actions-active'); + const deleteSlot = inspector.querySelector('.insp-actions-delete'); + const body = inspector.querySelector('.inspector-body'); + + // ── Save changes → row 1 right ─────────────────────────────────────────── + const saveBtn = document.createElement('button'); + saveBtn.className = 'btn-primary insp-save-btn'; + saveBtn.textContent = 'Save changes'; + saveBtn.addEventListener('click', () => body.querySelector('.opt-save-text')?.click()); + saveSlot.appendChild(saveBtn); + + // ── Active toggle → row 2 right ────────────────────────────────────────── + const detailRow = wrap.querySelector('.vr-detail-row'); + const activeEl = detailRow?.querySelector('.vr-detail-active'); + const deleteEl = detailRow?.querySelector('.vr-detail-delete'); + if (activeEl) activeSlot.appendChild(activeEl); + + // ── Delete → note row right ─────────────────────────────────────────────── + if (deleteEl) { + deleteSlot.appendChild(deleteEl); + const _dBtn = deleteEl.querySelector('.delete-btn'); + const _dCnl = deleteEl.querySelector('.delete-confirm-cancel'); + _dBtn?.addEventListener('click', () => deleteEl.classList.add('delete-pending')); + _dCnl?.addEventListener('click', () => deleteEl.classList.remove('delete-pending')); + } + + // ── Avatar click → photo upload ─────────────────────────────────────────── + inspector.querySelector('.inspector-avatar').addEventListener('click', () => { + body.querySelector('.photo-input')?.click(); + }); + + // ── Copy ID button ──────────────────────────────────────────────────────── + inspector.querySelector('.insp-copy-id-btn').addEventListener('click', () => { + copyText(voiceId).then(() => toast('Copied: ' + voiceId)); + }); + + // ── Double-click name/ID → inline rename ────────────────────────────────── + const dispNameEl = inspector.querySelector('.insp-disp-name'); + const nameEditEl = inspector.querySelector('.insp-name-edit'); + const fullIdEl = inspector.querySelector('.insp-full-id'); + + const startInspRename = () => { + dispNameEl.style.display = 'none'; fullIdEl.style.display = 'none'; + nameEditEl.style.display = 'block'; + nameEditEl.value = voiceId; nameEditEl.focus(); nameEditEl.select(); + }; + const commitInspRename = () => { + dispNameEl.style.display = ''; fullIdEl.style.display = ''; + nameEditEl.style.display = 'none'; + const newId = nameEditEl.value.trim(); + if (!newId || newId === voiceId) return; + const rowInput = wrap.querySelector('.vr-name-input'); + const rowOk = wrap.querySelector('.rename-ok'); + if (rowInput && rowOk) { rowInput.value = newId; rowOk.click(); } + }; + dispNameEl.addEventListener('dblclick', startInspRename); + fullIdEl.addEventListener('dblclick', startInspRename); + nameEditEl.addEventListener('blur', commitInspRename); + nameEditEl.addEventListener('keydown', e => { + if (e.key === 'Enter') nameEditEl.blur(); + if (e.key === 'Escape') { nameEditEl.value = voiceId; nameEditEl.blur(); } + }); + + // ── Flag (accent/country) — decoupled from language ────────────────────── + const flagSpan = inspector.querySelector('.insp-flag'); + const flagIconEl = inspector.querySelector('.insp-flag-icon'); + + const applyFlag = async (cc) => { + v.flag = cc; wrap.dataset.flagCc = cc; + const fi = ``; + if (flagIconEl) flagIconEl.innerHTML = fi; + await saveMeta(voiceId, { flag: cc }); + }; + flagSpan.addEventListener('dblclick', () => { + const items = (FLAG_OPTIONS[langCode.toUpperCase()] || ALL_FLAGS).map(([cc, name]) => [cc.toLowerCase(), name]); + createSearchablePicker(flagSpan, items, applyFlag, { + placeholder: 'Country or accent…', + renderItem: (cc, name) => + `${escHtml(name)}`, + }); + }); + + // ── Language — double-click lang code to change ─────────────────────────── + const langCodeEl = inspector.querySelector('.insp-lang-code'); + const langWrap = inspector.querySelector('.insp-lang-wrap'); + + const applyLang = async (newLang) => { + v.lang = newLang; wrap.dataset.lang = newLang; + if (langCodeEl) langCodeEl.textContent = newLang; + await saveMeta(voiceId, { lang: newLang }); + }; + langWrap?.addEventListener('dblclick', () => { + const items = LANGS.map(l => [l, LANGUAGE_LABELS[l] ? `${LANGUAGE_LABELS[l]} (${l})` : l]); + createSearchablePicker(langWrap, items, applyLang, { + placeholder: 'Language…', + renderItem: (l, label) => + `${escHtml(l)}${escHtml(LANGUAGE_LABELS[l] || l)}`, + }); + }); + + // ── Gender label (subtitle) — click to cycle ────────────────────────────── + const genderLabelEl = inspector.querySelector('.insp-gender-label'); + const genderSel = inspector.querySelector('.insp-gender-sel'); + const applyGender = async (ng) => { + v.gender = ng; + if (genderLabelEl) genderLabelEl.textContent = `${_gM[ng]||'?'} ${_gL[ng]||'—'}`; + if (genderSel) genderSel.value = ng; + const gBadge = wrap.querySelector('.gender-badge'); + if (gBadge) { + gBadge.innerHTML = `${_gM[ng]||'?'}${_gL[ng]||'—'}`; + gBadge.className = 'gender-badge ' + (_gC[ng]||'g-n'); + } + await saveMeta(voiceId, { gender: ng }); + }; + genderLabelEl?.addEventListener('click', () => { + const cycle = ['F','M','N']; + applyGender(cycle[(cycle.indexOf(v.gender||'N')+1)%3]); + }); + genderSel?.addEventListener('change', () => applyGender(genderSel.value)); + + // ── Tag input with reuse list ───────────────────────────────────────────── + const tagInput = inspector.querySelector('.insp-tag-input'); + let _tagDl = document.getElementById('insp-tag-opts'); + if (!_tagDl) { _tagDl = document.createElement('datalist'); _tagDl.id = 'insp-tag-opts'; document.body.appendChild(_tagDl); } + _tagDl.innerHTML = getStoredTags().map(t => ``).join(''); } -function styleBackendOptions(selected = 'customvoice') { +function styleBackendOptions(selected = 'customvoice', preferStyleAware = false) { const backends = availableTtsBackends(); if (!backends.length) return ''; - const preferred = backends.some(b => b.id === selected) ? selected : backends[0].id; + const preferred = backends.some(b => b.id === selected) ? selected + : preferStyleAware + ? (backends.find(b => b.style_aware)?.id || backends[0].id) + : backends[0].id; return backends.map(b => ``).join(''); } @@ -1281,7 +1660,16 @@ function updateBackendHelp() { function updateStyleBackendHelp(scope = document) { scope.querySelectorAll('.opt-style-backend').forEach(sel => { const box = sel.closest('.opt-style-panel')?.querySelector('.opt-style-backend-help'); - if (box) box.innerHTML = backendHelpHtml(backendById(sel.value), true); + if (!box) return; + const b = backendById(sel.value); + box.innerHTML = backendHelpHtml(b, true); + if (b && !b.style_aware) { + const styleAwareBacks = availableTtsBackends().filter(x => x.style_aware); + const suggestion = styleAwareBacks.length + ? ` Try ${escHtml(styleAwareBacks[0].label)} instead.` + : ' No style-aware backend is currently reachable.'; + box.innerHTML += `
⚠ This backend ignores the style instruction — output will sound the same regardless of what you type.${suggestion}
`; + } }); } @@ -2694,6 +3082,7 @@ async function loadVoiceLibrary() { const r = await fetch('/api/voices'); if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); } _voices = await r.json(); + if (typeof window.updateVoiceTree === 'function') window.updateVoiceTree(_voices); renderVoiceList(); updatePreviewVoiceMatchPanel(); status(`Loaded ${_voices.length} voices`); @@ -3576,7 +3965,24 @@ function renderVoiceList() { populateLibraryFilters(); readLibraryFilters(); - let filtered = _voices.filter(v => showDisabled || v.enabled !== false); + + // Apply sidebar category filter + const cat = window._voiceSidebarCat || 'all'; + let filtered = _voices.filter(v => { + if (cat === 'cloned') return v.has_ref; + if (cat === 'designed') return !v.has_ref; + if (cat === 'favorites') return (v.rating || 0) >= 4; + if (cat === 'hidden') return v.enabled === false; + if (cat === 'tools') return false; // no rows for tools view + // 'all' — respect show-disabled toggle + return showDisabled || v.enabled !== false; + }); + // For hidden/favorites cats always show all regardless of show-disabled toggle + if (cat !== 'all' && cat !== 'tools') { + // already filtered above — no extra enabled filter needed + } else if (cat === 'all') { + // already applied enabled filter in the lambda above + } const visibleCount = filtered.length; filtered = filtered.filter(libraryFilterMatch); const filterCount = filtered.length; @@ -3599,7 +4005,67 @@ function renderVoiceList() { list.appendChild(note); } if (!filtered.length) { - list.innerHTML += '
No voices found.
'; + if (_voices.length === 0) { + // True empty — render a two-case helper panel + const emptyEl = document.createElement('div'); + emptyEl.className = 'voices-empty-state'; + emptyEl.innerHTML = ` +
+
+
📁
+

Wrong folder path?

+

Set the path where your .wav voice files live inside the container.

+
+ + +
+

Map a host folder via docker-compose:
+ - /your/host/path:/voices:rw
+ or set VOICE_HOST_DIR=/your/host/path in the stack env.

+
+
or
+
+
🎤
+

Folder is empty?

+

Create your first voice from a recording or download ready-made voices.

+
+ + +
+
+
+ `; + list.appendChild(emptyEl); + + fetch('/api/settings').then(r => r.json()).then(s => { + const inp = document.getElementById('voices-empty-scan-dir'); + if (inp) inp.value = s.voices_scan_dir || '/voices'; + }).catch(() => {}); + + document.getElementById('voices-empty-save-btn')?.addEventListener('click', async () => { + const inp = document.getElementById('voices-empty-scan-dir'); + const dir = inp?.value?.trim(); + if (!dir) return; + const btn = document.getElementById('voices-empty-save-btn'); + btn.disabled = true; btn.textContent = 'Saving…'; + try { + const s = await fetch('/api/settings').then(r => r.json()); + s.voices_scan_dir = dir; + const r = await fetch('/api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(s) }); + if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || r.statusText); + toast('Folder saved — reloading voices…', 'success'); + _libraryLoadPromise = null; + await loadVoiceLibrary(); + } catch(e) { + toast('Failed: ' + e.message, 'error'); + btn.disabled = false; btn.textContent = 'Set & reload'; + } + }); + document.getElementById('voices-goto-clone')?.addEventListener('click', () => navTo('s-clone')); + document.getElementById('voices-goto-studio')?.addEventListener('click', () => navTo('s-studio')); + } else { + list.innerHTML += '
No voices match the current filters.
'; + } return; } filtered.forEach(v => list.appendChild(makeVoiceRow(v))); @@ -3684,6 +4150,13 @@ function makeVoiceRow(v) { wrap.className = 'vl-row' + (v.enabled===false ? ' vr-disabled' : ''); wrap.dataset.id = v.id; + // Data attrs used by inspector header + const color = avatarColor(v.id); + wrap.dataset.color = color; + wrap.dataset.hasRef = v.has_ref ? 'true' : 'false'; + wrap.dataset.dbfs = fmtDbfs(v); + wrap.dataset.lang = v.lang || v.id.split('_')[0].toUpperCase(); + const langCode = v.lang || v.id.split('_')[0].toUpperCase(); const langOpts = FLAG_OPTIONS[langCode] || []; // Use language-specific variants if there are multiple; fall back to world picker otherwise @@ -3691,6 +4164,10 @@ function makeVoiceRow(v) { const currentFlag = v.flag || LANG_FLAG_DEFAULT[langCode] || ''; const flagEmoji = currentFlag ? cc2flag(currentFlag) : '🌐'; const flagCode = currentFlag ? ccDisplay(currentFlag) : '?'; + wrap.dataset.flagEmoji = flagEmoji; + wrap.dataset.flagCc = currentFlag || ''; + wrap.dataset.rating = v.rating || 0; + wrap.dataset.hasPicture = v.has_picture ? 'true' : 'false'; const fileType = voiceFileType(v); const dbfs = fmtDbfs(v); const dbTitle = v.loudness ? `avg ${dbfs} dBFS${v.loudness.peak_dbfs != null ? ', peak ' + Number(v.loudness.peak_dbfs).toFixed(1) + ' dBFS' : ''}` : ''; @@ -3699,6 +4176,7 @@ function makeVoiceRow(v) { const benchCls = benchmarkClass(v); const genderMap = {F:'♀', M:'♂', N:'⚥', '':'?'}; + const genderLabel = {F:'Female', M:'Male', N:'Diverse', '':'—'}; const genderClass = {F:'g-f', M:'g-m', N:'g-n', '':'g-n'}; const gender = v.gender || ''; @@ -3714,7 +4192,36 @@ function makeVoiceRow(v) { ).join('') : `No regional variants`; + // Compact list item elements (visible in list pane, hidden in inspector) + const isClone = v.has_ref; + const initial = v.id[0] ? v.id[0].toUpperCase() : '?'; + const flagIconHtml = currentFlag + ? `` + : null; + + // Avatar: photo > flag icon > initial letter (with color) + const avatarClass = picSrc ? 'vl-avatar vl-avatar-photo' : flagIconHtml ? 'vl-avatar vl-avatar-flag' : 'vl-avatar vl-avatar-initial'; + const avatarStyle = picSrc || flagIconHtml ? '' : `style="background:${color}"`; + const avatarContent = picSrc + ? `` + : flagIconHtml || `${initial}`; + wrap.innerHTML = ` +
+
${avatarContent}
+
+ ${escHtml(v.id)} +
+ ${isClone ? 'Clone' : 'Design'} + ${gender ? `${genderMap[gender]||'?'} ${genderLabel[gender]||''}` : ''} +
+
+
+
+
+
+
+
${picSrc ? `` : '
👤
'} @@ -3729,7 +4236,10 @@ function makeVoiceRow(v) {
- ${genderMap[gender]||'?'} + + ${genderMap[gender]||'?'} + ${genderLabel[gender]||'—'} +
@@ -3801,7 +4311,7 @@ function makeVoiceRow(v) {
- +
Delete? ${escHtml(v.id)} @@ -3813,8 +4323,8 @@ function makeVoiceRow(v) {
-
-
1 Reference audio trim
+
+
Reference audio · crop
@@ -3825,8 +4335,8 @@ function makeVoiceRow(v) {
-
-
2 Reference text
+
+
Reference transcript
@@ -3834,7 +4344,7 @@ function makeVoiceRow(v) {
-
3 Voice match check
+
Voice match
@@ -3854,10 +4364,10 @@ function makeVoiceRow(v) {
-
4 Style variation
+
Style variation
-
+

Preview first. Saving creates a new active WAV voice from the current reference text. Same-voice style only works when the selected backend knows this voice and honors instruct; Base/Streaming are fastest but often ignore style.

@@ -3872,7 +4382,7 @@ function makeVoiceRow(v) {
-
5 Volume and backend refresh
+
Loudness
@@ -3972,8 +4482,8 @@ function makeVoiceRow(v) { gBadge.addEventListener('click', async () => { const cycle = ['F','M','N']; v.gender = cycle[(cycle.indexOf(v.gender||'F')+1)%3]; - gBadge.textContent = genderMap[v.gender]; - gBadge.className = 'gender-badge ' + genderClass[v.gender]; + gBadge.innerHTML = `${genderMap[v.gender]||'?'}${genderLabel[v.gender]||'—'}`; + gBadge.className = 'gender-badge ' + genderClass[v.gender]; await saveMeta(v.id, { gender: v.gender }); }); @@ -4015,6 +4525,12 @@ function makeVoiceRow(v) { renameOk.addEventListener('click', doRename); nameInput.addEventListener('keydown', e => { if(e.key==='Enter') doRename(); if(e.key==='Escape') cancelRename(); }); + // Select row → open inspector (click on compact area, but not on play/buttons) + wrap.querySelector('.vl-compact').addEventListener('click', function (e) { + if (e.target.closest('button')) return; + selectVoice(wrap); + }); + // Inline optimizer in Library const editAudioBtn = wrap.querySelector('.edit-audio-btn'); const optPanel = wrap.querySelector('.vr-optimizer'); @@ -4182,6 +4698,7 @@ function makeVoiceRow(v) { setVoiceRestartState(Boolean(v.needs_tts_restart)); setOptStatus(v.needs_tts_restart ? 'Optimizer ready. Restart TTS before benchmarking this edit.' : 'Optimizer ready'); }; + wrap._loadOptimizer = loadOptimizer; editAudioBtn.addEventListener('click', async () => { editAudioBtn.disabled = true; @@ -4573,12 +5090,16 @@ function makeVoiceRow(v) { try { if (kind === 'synth') { if (v.needs_tts_restart) toast('This voice changed since backend refresh; synthesized playback may use a cached voice.', 'error'); - const text = benchmarkSampleText(); + const synthMode = document.querySelector('#vl-synth-mode-seg .vl-synth-seg-btn.active')?.dataset.mode || 'preview'; + const text = synthMode === 'transcript' + ? (v.transcript?.trim() || benchmarkSampleText()) + : benchmarkSampleText(); + const textLabel = synthMode === 'transcript' ? 'reference transcript' : 'preview text'; const backend = libraryTtsBackend(); const source = await createTtsAudioSource(v.id, text, backend, 'settings', ''); audio.src = source.url; if (!source.streaming) _activePlayUrl = source.url; - $('lib-audio-label').textContent = v.id + ' · synthesized sample · ' + (backendById(backend)?.label || backend); + $('lib-audio-label').textContent = v.id + ' · synthesized ' + textLabel + ' · ' + (backendById(backend)?.label || backend); } else { audio.src = voiceFileUrl(v); $('lib-audio-label').textContent = v.id + ' · original recording'; @@ -5214,6 +5735,33 @@ $('stt-tts-save-wav-btn')?.addEventListener('click', () => { // ── Init ────────────────────────────────────────────────────────────────── initBenchmarkSampleControls(); +// Sync visible preview-text input with hidden benchmark-sample-text +(function syncPreviewInput() { + const visible = $('vl-preview-sample'); + const hidden = $('benchmark-sample-text'); + if (!visible) return; + const stored = localStorage.getItem(BENCHMARK_SAMPLE_STORAGE_KEY); + visible.value = stored || DEFAULT_BENCHMARK_SAMPLE_TEXT; + if (hidden) hidden.value = visible.value; + visible.addEventListener('input', () => { + if (hidden) hidden.value = visible.value; + localStorage.setItem(BENCHMARK_SAMPLE_STORAGE_KEY, visible.value.trim()); + }); +})(); + +// Synth mode segmented control (preview text vs. reference transcript) +(function initSynthMode() { + const seg = $('vl-synth-mode-seg'); + const wrap = $('vl-preview-text-wrap'); + if (!seg) return; + seg.querySelectorAll('.vl-synth-seg-btn').forEach(btn => { + btn.addEventListener('click', () => { + seg.querySelectorAll('.vl-synth-seg-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + if (wrap) wrap.classList.toggle('hidden', btn.dataset.mode === 'transcript'); + }); + }); +})(); loadSettings().then(() => { refreshSttBackends(); renderIntegrationSnippets(); diff --git a/static/index.html b/static/index.html index 00f8367..6c7b321 100644 --- a/static/index.html +++ b/static/index.html @@ -7,6 +7,7 @@ + @@ -36,8 +37,19 @@