tts-voice-creator-clone-and.../static/js/voice-picker.js
mARTin-B78 40e42590cc Release v1.6.0: a11y (WCAG AA), i18n (DE), PWA, perf, tests, Cast UX
Cast: card/list views, sort & filter, online voice picker, "Hear a line"
sample button, AI character notes, import auto-save.

Platform: WCAG 2.1 AA accessibility pass; German UI translation + language
picker; installable PWA with offline shell; GZip + content-visibility
virtualization + lazy images + Rehearser PCM memory cap (mobile stability);
Playwright suite (desktop + iPhone); opt-in minified bundle build.

Fixes: screenplay parser false characters; Fish-Speech inline-tag tones;
narrator/voice pickers list full library; clone GUI rework; fish.audio
import dedup; voice-ID rename; bulk-delete modal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 14:23:35 +02:00

225 lines
9.2 KiB
JavaScript

// ── Voice Picker ──────────────────────────────────────────────────────────
// Replaces <select> elements with a searchable dropdown that shows voice
// avatars. Usage:
// VoicePicker.upgrade('tts-voice-select'); // upgrades by select id
// VoicePicker.populate('tts-voice-select', voices); // fill options
// VoicePicker.getValue('tts-voice-select'); // get current value
// VoicePicker.setValue('tts-voice-select', id); // set value
(function () {
'use strict';
const _pickers = {}; // selectId → { root, input, list, select }
function _voiceData(id) {
return (window._voices || []).find(v => v.id === id) || null;
}
// Shared gender/type avatar icons (male/female/neutral/robot/animal). Returns null
// when the voice has no avatar key, so callers fall back to photo/flag/letter.
const VOICE_AVATAR_ICONS = {
male: 'mdi-face-man', female: 'mdi-face-woman', neutral: 'mdi-account',
robot: 'mdi-robot-outline', animal: 'mdi-paw',
};
const VOICE_AVATAR_COLORS = {
male: '#3b82f6', female: '#ec4899', neutral: '#6b7280', robot: '#0ea5e9', animal: '#f59e0b',
};
window.VOICE_AVATAR_ICONS = VOICE_AVATAR_ICONS;
window.voiceAvatarIcon = function (avatarKey, size) {
const icon = VOICE_AVATAR_ICONS[avatarKey];
if (!icon) return null;
const s = size + 'px', r = Math.round(size / 2) + 'px';
const bg = VOICE_AVATAR_COLORS[avatarKey] || '#6b7280';
return `<span class="vp-avatar" style="width:${s};height:${s};border-radius:${r};background:${bg};color:#fff;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0"><span class="mdi ${icon}" style="font-size:${Math.round(size * 0.58)}px"></span></span>`;
};
function _avatarHtml(id, size) {
const v = _voiceData(id);
const s = size + 'px';
const r = Math.round(size / 2) + 'px';
if (v?.has_picture) {
return `<img src="/api/voice/picture/${encodeURIComponent(id)}" class="vp-avatar" style="width:${s};height:${s};border-radius:${r};object-fit:cover;flex-shrink:0" alt="">`;
}
const icon = window.voiceAvatarIcon ? window.voiceAvatarIcon(v?.avatar, size) : null;
if (icon) return icon;
const lang = v?.lang || '';
const color = _langColor(lang, id);
const init = (id || '?')[0].toUpperCase();
return `<span class="vp-avatar" style="width:${s};height:${s};border-radius:${r};background:${color};color:#fff;font-weight:700;font-size:${Math.round(size * 0.44)}px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${init}</span>`;
}
function _langColor(lang, id) {
const str = (lang || id || '').toLowerCase();
if (str.startsWith('de')) return '#3b82f6';
if (str.startsWith('en')) return '#10b981';
if (str.startsWith('fr')) return '#8b5cf6';
if (str.startsWith('es')) return '#f59e0b';
if (str.startsWith('it')) return '#ef4444';
if (str.startsWith('zh')) return '#ec4899';
if (str.startsWith('ja')) return '#f97316';
// deterministic fallback from string hash
const palette = ['#3b82f6','#10b981','#8b5cf6','#f59e0b','#ef4444','#ec4899','#06b6d4','#84cc16'];
let h = 0; for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
return palette[h % palette.length];
}
function _flagSpan(v) {
if (!v) return '';
if (v.flag) return `<span class="vp-flag">${v.flag}</span>`;
return '';
}
function _buildItem(id, label) {
const v = _voiceData(id);
return `<div class="vp-item" data-value="${_esc(id)}" tabindex="-1">
${_avatarHtml(id, 26)}
<span class="vp-item-name">${_esc(label || id)}</span>
${_flagSpan(v)}
</div>`;
}
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function upgrade(selectId) {
const sel = document.getElementById(selectId);
if (!sel || sel.dataset.vpUpgraded) return;
sel.dataset.vpUpgraded = '1';
sel.style.display = 'none';
// Build wrapper
const root = document.createElement('div');
root.className = 'vp-root';
root.dataset.vpFor = selectId;
sel.parentNode.insertBefore(root, sel);
// Trigger button
const trigger = document.createElement('div');
trigger.className = 'vp-trigger';
trigger.tabIndex = 0;
trigger.innerHTML = `<span class="vp-trigger-content"><span class="vp-trigger-placeholder">— select after fetch —</span></span><span class="mdi mdi-chevron-down vp-chevron"></span>`;
root.appendChild(trigger);
// Dropdown
const drop = document.createElement('div');
drop.className = 'vp-drop';
drop.hidden = true;
drop.innerHTML = `<div class="vp-search-wrap"><span class="mdi mdi-magnify vp-search-icon"></span><input class="vp-search" placeholder="Search voices…" autocomplete="off" spellcheck="false"></div><div class="vp-list"></div>`;
root.appendChild(drop);
const searchInp = drop.querySelector('.vp-search');
const list = drop.querySelector('.vp-list');
function _renderList(filter) {
const opts = Array.from(sel.options).filter(o => o.value);
const q = (filter || '').toLowerCase();
const filtered = q ? opts.filter(o => o.value.toLowerCase().includes(q) || o.textContent.toLowerCase().includes(q)) : opts;
if (!filtered.length) { list.innerHTML = '<div class="vp-empty">No voices found</div>'; return; }
list.innerHTML = filtered.map(o => _buildItem(o.value, o.textContent)).join('');
// highlight current
list.querySelectorAll('.vp-item').forEach(el => {
el.classList.toggle('selected', el.dataset.value === sel.value);
});
}
function _open() {
drop.hidden = false;
trigger.classList.add('open');
searchInp.value = '';
_renderList('');
searchInp.focus();
// scroll selected into view
requestAnimationFrame(() => {
const sel2 = list.querySelector('.vp-item.selected');
if (sel2) sel2.scrollIntoView({ block: 'nearest' });
});
}
function _close() {
drop.hidden = true;
trigger.classList.remove('open');
}
function _select(value) {
sel.value = value;
sel.dispatchEvent(new Event('change', { bubbles: true }));
_syncTrigger();
_close();
}
function _syncTrigger() {
const v = sel.value;
const content = trigger.querySelector('.vp-trigger-content');
if (!v) {
content.innerHTML = '<span class="vp-trigger-placeholder">— select after fetch —</span>';
} else {
const label = Array.from(sel.options).find(o => o.value === v)?.textContent || v;
content.innerHTML = `${_avatarHtml(v, 24)}<span class="vp-trigger-name">${_esc(label)}</span>`;
}
}
trigger.addEventListener('click', () => drop.hidden ? _open() : _close());
trigger.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _open(); } });
searchInp.addEventListener('input', () => _renderList(searchInp.value));
searchInp.addEventListener('keydown', e => {
if (e.key === 'Escape') { e.stopPropagation(); _close(); trigger.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); list.querySelector('.vp-item')?.focus(); }
});
list.addEventListener('click', e => {
const item = e.target.closest('.vp-item');
if (item) _select(item.dataset.value);
});
list.addEventListener('keydown', e => {
const item = e.target.closest('.vp-item');
if (!item) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); _select(item.dataset.value); }
if (e.key === 'ArrowDown') { e.preventDefault(); (item.nextElementSibling || item).focus(); }
if (e.key === 'ArrowUp') { e.preventDefault(); (item.previousElementSibling || item)?.focus() || searchInp.focus(); }
if (e.key === 'Escape') { _close(); trigger.focus(); }
});
// Close on outside click
document.addEventListener('click', e => {
if (!root.contains(e.target)) _close();
}, true);
// Watch for programmatic changes to the underlying select
const mo = new MutationObserver(() => { _syncTrigger(); });
mo.observe(sel, { childList: true, attributes: true, subtree: true });
_pickers[selectId] = { root, trigger, drop, searchInp, list, sel, renderList: _renderList, syncTrigger: _syncTrigger };
}
function populate(selectId, voices) {
const p = _pickers[selectId];
if (!p) return;
const { sel, renderList, syncTrigger } = p;
const prev = sel.value;
sel.innerHTML = '<option value="">— select after fetch —</option>';
voices.forEach(id => {
const opt = document.createElement('option');
opt.value = opt.textContent = id;
sel.appendChild(opt);
});
if (prev && voices.includes(prev)) sel.value = prev;
renderList('');
syncTrigger();
}
function getValue(selectId) {
return document.getElementById(selectId)?.value || '';
}
function setValue(selectId, value) {
const sel = document.getElementById(selectId);
if (!sel) return;
sel.value = value;
_pickers[selectId]?.syncTrigger();
}
window.VoicePicker = { upgrade, populate, getValue, setValue };
})();