// ── Fish.audio public voice library browser ─────────────────────────────────
// Lists voices from /api/fishaudio/voices (proxy to api.fish.audio) and imports a
// clonable sample (MP3 + transcript) into the voice library via /api/quick-import-voice.
(function () {
const $ = id => document.getElementById(id);
const grid = $('fa-grid');
if (!grid) return;
let _page = 1;
let _audio = null, _playingCard = null;
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
// 28902 → "28.9K", 698 → "698", 1_240_000 → "1.2M"
function fmtCount(n) {
n = +n || 0;
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'M';
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, '') + 'K';
return String(n);
}
const FLAG = { EN: '🇬🇧', DE: '🇩🇪', FR: '🇫🇷', ES: '🇪🇸', IT: '🇮🇹', PT: '🇵🇹', NL: '🇳🇱', JA: '🇯🇵', ZH: '🇨🇳', KO: '🇰🇷', RU: '🇷🇺', AR: '🇸🇦', PL: '🇵🇱' };
function status(msg, show = true) {
const bar = $('fa-status-bar'), txt = $('fa-status-text');
if (bar) bar.hidden = !show;
if (txt) txt.textContent = msg || '';
}
// Collect active filter values from the popover
function activeVals(group) {
return [...document.querySelectorAll(`#fa-filter-pop .fa-pchips[data-group="${group}"] .fa-pchip.active`)]
.map(c => c.dataset.val).filter(Boolean);
}
function filterCount() {
return activeVals('gender').length + activeVals('age').length + activeVals('tag').length
+ (($('fa-tag')?.value.trim()) ? 1 : 0);
}
function updateFilterBadge() {
const n = filterCount(), b = $('fa-filter-badge'), btn = $('fa-filter-btn');
if (b) { b.textContent = String(n); b.hidden = n === 0; }
btn?.classList.toggle('active', n > 0);
}
function params() {
const p = new URLSearchParams({ page: String(_page), page_size: '24' });
const s = $('fa-search')?.value.trim(); if (s) p.set('search', s);
const l = $('fa-lang')?.value; if (l) p.set('language', l);
const sort = $('fa-sort')?.value; if (sort) p.set('sort_by', sort);
const g = activeVals('gender')[0]; if (g) p.set('gender', g);
const a = activeVals('age')[0]; if (a) p.set('age', a);
activeVals('tag').forEach(t => p.append('tag', t)); // use-case + quality (multi)
const t = $('fa-tag')?.value.trim(); if (t) p.append('tag', t);
return p;
}
function card(v) {
const can = !!v.sample_audio;
const meta = [
v.language ? `${FLAG[v.language] || '🌐'} ${esc(v.language)}` : '',
v.gender ? `${esc(v.gender)}` : '',
v.age ? `${esc(v.age)}` : '',
].join('');
const avatar = v.image
? `
`
: '';
return `
${avatar}
${can ? `` : ''}
${esc(v.title) || 'Untitled'}
${v.author ? `· ${esc(v.author)}` : ''}
${esc(v.description || v.sample_text || '')}
${meta}
`;
}
function render(data) {
const items = data.items || [];
if (!items.length) { grid.innerHTML = 'No voices found — try a different search, language or filter.
'; }
else {
grid.innerHTML = items.map(card).join('');
grid.querySelectorAll('.fa-card').forEach((el, i) => {
const v = items[i];
el.querySelector('.fa-play')?.addEventListener('click', e => { e.stopPropagation(); preview(v, el); });
el.querySelector('.fa-import')?.addEventListener('click', e => { e.stopPropagation(); importVoice(v, el); });
});
}
const pager = $('fa-pager');
if (pager) {
pager.hidden = false;
$('fa-prev').disabled = _page <= 1;
const pages = Math.max(1, Math.ceil((data.total || 0) / (data.page_size || 24)));
$('fa-next').disabled = _page >= pages;
$('fa-pager-info').textContent = `Page ${_page} · ${(data.total || 0).toLocaleString()} voices`;
}
}
async function browse() {
status('Loading voices from fish.audio…');
grid.innerHTML = ' Loading…
';
try {
const d = await fetch('/api/fishaudio/voices?' + params().toString()).then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
});
render(d);
status(d.offline
? '⚠ fish.audio unreachable — showing cached results'
: `${(d.total || 0).toLocaleString()} matching voices`, !!d.offline);
} catch (e) {
grid.innerHTML = `Failed to load: ${esc(e.message)}
`;
status('');
}
}
function stopPreview() {
if (_audio) { _audio.pause(); _audio = null; }
_playingCard?.classList.remove('fa-card-playing');
_playingCard = null;
}
function preview(v, el) {
if (_playingCard === el) { stopPreview(); return; }
stopPreview();
if (!v.sample_audio) return;
_audio = new Audio(v.sample_audio);
_playingCard = el; el.classList.add('fa-card-playing');
_audio.addEventListener('ended', stopPreview);
_audio.play().catch(() => { stopPreview(); typeof toast === 'function' && toast('Could not play preview', 'error'); });
}
async function importVoice(v, el) {
const btn = el.querySelector('.fa-import');
const orig = btn.innerHTML;
btn.disabled = true; btn.innerHTML = '';
const lang = (v.language || 'EN').slice(0, 2).toUpperCase();
const base = (typeof _umlautSafe === 'function' ? _umlautSafe(v.title || 'fishaudio') : (v.title || 'fishaudio')).replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) || 'Voice';
const voiceId = `${lang}_${base}`;
try {
const r = await fetch('/api/quick-import-voice', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: voiceId, audio_url: v.sample_audio, transcript: v.sample_text || v.default_text || '' }),
});
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
const d = await r.json();
if (typeof saveMeta === 'function') {
await saveMeta(d.voice_id, {
name: v.title || base, tag: 'fish-audio', group: 'fish-audio', origin: 'cloned',
gender: (v.gender || '').charAt(0).toUpperCase(), note: (v.description || '').slice(0, 180),
}).catch(() => {});
}
// Import the fish.audio cover image as the voice's profile picture
if (v.image) {
await fetch('/api/voice/picture-url', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voice_id: d.voice_id, image_url: v.image }),
}).catch(() => {});
}
btn.innerHTML = ' Imported';
el.classList.add('fa-card-imported');
if (typeof toast === 'function') toast(`Imported "${v.title}" as ${d.voice_id}`, 'success');
if (typeof loadVoiceLibrary === 'function') loadVoiceLibrary().catch(() => {});
} catch (e) {
btn.disabled = false; btn.innerHTML = orig;
if (typeof toast === 'function') toast('Import failed: ' + e.message, 'error');
}
}
// ── Wiring ──────────────────────────────────────────────────────────────
$('fa-fetch')?.addEventListener('click', () => { _page = 1; browse(); });
$('fa-search')?.addEventListener('keydown', e => { if (e.key === 'Enter') { _page = 1; browse(); } });
$('fa-tag')?.addEventListener('keydown', e => { if (e.key === 'Enter') { _page = 1; browse(); applyFilters(); } });
$('fa-lang')?.addEventListener('change', () => { _page = 1; browse(); });
$('fa-sort')?.addEventListener('change', () => { _page = 1; browse(); });
$('fa-prev')?.addEventListener('click', () => { if (_page > 1) { _page--; browse(); } });
$('fa-next')?.addEventListener('click', () => { _page++; browse(); });
// ── Filter popover ──────────────────────────────────────────────────────
const pop = $('fa-filter-pop');
// The popover lives inside .el-browser (overflow:hidden), so anchor it as a
// fixed-position layer next to the filter button to avoid being clipped.
function positionPop() {
const btn = $('fa-filter-btn');
if (!pop || !btn || pop.hidden) return;
const r = btn.getBoundingClientRect();
const w = pop.offsetWidth || 320;
const margin = 8;
let left = Math.min(r.right - w, window.innerWidth - w - margin);
if (left < margin) left = margin;
pop.style.position = 'fixed';
pop.style.right = 'auto';
pop.style.left = left + 'px';
pop.style.top = (r.bottom + 8) + 'px';
pop.style.maxHeight = (window.innerHeight - r.bottom - 16) + 'px';
}
function openPop(show) {
if (!pop) return;
pop.hidden = (show === undefined) ? !pop.hidden : !show;
if (!pop.hidden) positionPop();
}
$('fa-filter-btn')?.addEventListener('click', e => { e.stopPropagation(); openPop(); });
$('fa-filter-close')?.addEventListener('click', e => { e.stopPropagation(); openPop(false); });
pop?.addEventListener('click', e => e.stopPropagation());
document.addEventListener('click', () => openPop(false));
document.addEventListener('keydown', e => { if (e.key === 'Escape') openPop(false); });
window.addEventListener('resize', positionPop);
window.addEventListener('scroll', positionPop, true);
// Chip selection: single-select for gender/age, multi-select for tag groups
document.querySelectorAll('#fa-filter-pop .fa-pchip').forEach(chip => {
chip.addEventListener('click', () => {
const wrap = chip.closest('.fa-pchips');
if (wrap.classList.contains('fa-pchips-multi')) chip.classList.toggle('active');
else { wrap.querySelectorAll('.fa-pchip').forEach(c => c.classList.remove('active')); chip.classList.add('active'); }
updateFilterBadge();
});
});
$('fa-tag')?.addEventListener('input', updateFilterBadge);
function applyFilters() { _page = 1; openPop(false); browse(); }
$('fa-filter-apply')?.addEventListener('click', applyFilters);
$('fa-filter-reset')?.addEventListener('click', () => {
document.querySelectorAll('#fa-filter-pop .fa-pchips[data-group="tag"] .fa-pchip').forEach(c => c.classList.remove('active'));
['gender', 'age'].forEach(g => {
const wrap = document.querySelector(`#fa-filter-pop .fa-pchips[data-group="${g}"]`);
wrap?.querySelectorAll('.fa-pchip').forEach((c, i) => c.classList.toggle('active', i === 0));
});
if ($('fa-tag')) $('fa-tag').value = '';
updateFilterBadge(); applyFilters();
});
})();
// ── Get Voices Online — source tabs (show one library at a time) ────────────
(function () {
const tabs = document.getElementById('gvo-tabs');
if (!tabs) return;
const map = { direct: 'tab-getvoices', fish: 'fa-browser-card', eleven: 'el-browser-card' };
function show(src) {
if (!map[src]) src = 'direct';
Object.entries(map).forEach(([k, id]) => {
const el = document.getElementById(id);
if (!el) return;
// tab-getvoices is a .tab-content with `display:flex !important`, so a plain
// inline display:none won't hide it — set/clear the property with priority.
if (k === src) el.style.removeProperty('display');
else el.style.setProperty('display', 'none', 'important');
});
tabs.querySelectorAll('.gvo-tab').forEach(t => t.classList.toggle('active', t.dataset.src === src));
try { localStorage.setItem('gvo-src', src); } catch (_) {}
}
tabs.querySelectorAll('.gvo-tab').forEach(t => t.addEventListener('click', () => show(t.dataset.src)));
let saved = 'direct';
try { saved = localStorage.getItem('gvo-src') || 'direct'; } catch (_) {}
show(saved);
})();