241 lines
9.6 KiB
JavaScript
241 lines
9.6 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
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;
|
|
drop.style.display = '';
|
|
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;
|
|
drop.style.display = 'none';
|
|
trigger.classList.remove('open');
|
|
searchInp.blur();
|
|
}
|
|
|
|
function _select(value) {
|
|
sel.value = value;
|
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
_syncTrigger();
|
|
_close();
|
|
trigger.focus();
|
|
}
|
|
|
|
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('pointerdown', e => {
|
|
const item = e.target.closest('.vp-item');
|
|
if (!item) return;
|
|
e.preventDefault();
|
|
_select(item.dataset.value);
|
|
});
|
|
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);
|
|
|
|
// Any real select change means the user picked something; close the popup.
|
|
sel.addEventListener('change', () => {
|
|
_syncTrigger();
|
|
_close();
|
|
});
|
|
|
|
// 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 };
|
|
})();
|