// ── 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}
${fmtCount(v.plays)} ${fmtCount(v.likes)} ${can ? `` : ''}
`; } 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 = (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); })();