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>
267 lines
13 KiB
JavaScript
267 lines
13 KiB
JavaScript
// ── 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, '>').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 ? `<span class="fa-meta-chip">${FLAG[v.language] || '🌐'} ${esc(v.language)}</span>` : '',
|
|
v.gender ? `<span class="fa-meta-chip">${esc(v.gender)}</span>` : '',
|
|
v.age ? `<span class="fa-meta-chip">${esc(v.age)}</span>` : '',
|
|
].join('');
|
|
const avatar = v.image
|
|
? `<img class="fa-avatar" src="${esc(v.image)}" alt="" loading="lazy" onerror="this.parentElement.classList.add('fa-avatar-fallback');this.remove();">`
|
|
: '';
|
|
return `<div class="fa-card${can ? '' : ' fa-card-disabled'}" data-id="${esc(v.id)}">
|
|
<div class="fa-card-avatar${v.image ? '' : ' fa-avatar-fallback'}">
|
|
${avatar}
|
|
${can ? `<button class="fa-play" title="Preview"><span class="mdi mdi-play"></span></button>` : ''}
|
|
</div>
|
|
<div class="fa-card-body">
|
|
<div class="fa-card-top">
|
|
<span class="fa-card-name" title="${esc(v.title)}">${esc(v.title) || 'Untitled'}</span>
|
|
${v.author ? `<span class="fa-card-author">· ${esc(v.author)}</span>` : ''}
|
|
</div>
|
|
<div class="fa-card-desc">${esc(v.description || v.sample_text || '')}</div>
|
|
<div class="fa-card-meta">${meta}</div>
|
|
<div class="fa-card-foot">
|
|
<span class="fa-stat" title="Generations"><span class="mdi mdi-poll"></span> ${fmtCount(v.plays)}</span>
|
|
<span class="fa-stat" title="Likes"><span class="mdi mdi-heart-outline"></span> ${fmtCount(v.likes)}</span>
|
|
${can ? `<button class="btn-primary btn-sm fa-import" title="Import as a clonable voice"><span class="mdi mdi-download"></span> Use</button>` : ''}
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function render(data) {
|
|
const items = data.items || [];
|
|
if (!items.length) { grid.innerHTML = '<div class="el-idle">No voices found — try a different search, language or filter.</div>'; }
|
|
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 = '<div class="el-idle"><span class="reh-imsdb-spinner"></span> Loading…</div>';
|
|
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 = `<div class="el-idle">Failed to load: ${esc(e.message)}</div>`;
|
|
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 = '<span class="reh-imsdb-spinner"></span>';
|
|
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 = '<span class="mdi mdi-check"></span> 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);
|
|
})();
|