Add ElevenLabs Voice Library browser to Get Voices Online

- Backend proxy at /api/elevenlabs/voices forwarding to ElevenLabs shared-voices API
- elevenlabs_api_key added to settings (free key unlocks 12 000+ voices; 3 without key)
- Category pills: All, Featured, Professional, Narration, Conversational, News, Characters, Meditation, Gaming, Training
- Language filter with most common European languages + Arabic/Hindi/ZH/JA/KO
- Gender and age filters, debounced search, pagination (prev/next)
- Voice cards: colored avatar, name, language/gender/age/use-case tags, description, ▶ Play and ↓ Clone buttons
- Play button streams preview MP3 directly; Clone imports the audio into the Clone a Voice flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-05-26 13:14:40 +02:00
parent 79aaed4b7d
commit ff783a8ab9
4 changed files with 371 additions and 5 deletions

View File

@ -229,7 +229,7 @@ _SETTINGS_KEYS = {
"output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url", "output_dir", "voices_scan_dir", "voice_design_url", "customvoice_url",
"nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url", "nvidia_router_url", "nvidia_tts_url", "nvidia_asr_url", "nvidia_clone_url",
"nvidia_zeroshot_url", "nvidia_flow_url", "nvidia_zeroshot_url", "nvidia_flow_url",
"whisper_api_key", "tts_api_key", "voice_design_api_key", "whisper_api_key", "tts_api_key", "voice_design_api_key", "elevenlabs_api_key",
"tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend", "tts_stability_enabled", "tts_extra_params", "tts_extra_params_by_backend",
} }
@ -2721,6 +2721,29 @@ def _fetch_backend_voices(settings: dict, backend: str) -> list:
return [] return []
# ── ElevenLabs shared voice library proxy ────────────────────────────────────
@app.get("/api/elevenlabs/voices")
async def elevenlabs_shared_voices(request: Request):
settings = _load_settings()
api_key = (settings.get("elevenlabs_api_key") or "").strip()
_allowed = {"page_size", "page", "language", "gender", "age", "accent",
"use_case", "category", "search", "featured"}
params: dict = {k: v for k, v in request.query_params.items() if k in _allowed}
params.setdefault("page_size", "24")
headers = {"xi-api-key": api_key} if api_key else {}
try:
r = requests.get(
"https://api.elevenlabs.io/v1/shared-voices",
params=params,
headers=headers,
timeout=15,
)
return r.json()
except requests.exceptions.RequestException as exc:
raise HTTPException(502, f"ElevenLabs API error: {exc}")
@app.get("/api/tts-voices") @app.get("/api/tts-voices")
async def tts_voices(backend: str = "voice_clone"): async def tts_voices(backend: str = "voice_clone"):
return _fetch_backend_voices(_load_settings(), backend) return _fetch_backend_voices(_load_settings(), backend)

View File

@ -5906,4 +5906,188 @@ loadSettings().then(() => {
renderIntegrationSnippets(); renderIntegrationSnippets();
if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true); if (!localStorage.getItem(SETTINGS_SEEN_KEY)) openSettings(true);
}).catch(e => status('Settings load failed: ' + e.message)); }).catch(e => status('Settings load failed: ' + e.message));
// ── ElevenLabs Voice Library Browser ──────────────────────────────────────
(function initElevenLabsBrowser() {
if (!$('el-browser-card')) return;
let _elPage = 0, _elHasMore = false, _elTotal = 0;
let _elAudio = null, _elAudioBtn = null;
let _elFilters = {};
let _elSearchTimer;
const _elHue = str => {
let h = 0;
for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
return Math.abs(h) % 360;
};
const _elEsc = s => String(s || '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// Load key state from settings
function elLoadKey() {
fetch('/api/settings').then(r => r.json()).then(s => {
const key = (s.elevenlabs_api_key || '').trim();
const inp = $('el-api-key'), st = $('el-key-status');
if (inp) inp.placeholder = key ? '••••••••••••••• (key saved)' : 'ElevenLabs API key (free — unlocks full library)…';
if (st) { st.textContent = key ? '✓ Key active' : 'No key — max 3 results'; st.className = 'el-key-status ' + (key ? 'el-key-ok' : 'el-key-none'); }
elFetch();
}).catch(() => elFetch());
}
$('el-key-save').addEventListener('click', () => {
const inp = $('el-api-key');
const val = (inp?.value || '').trim();
if (!val || val.startsWith('•')) { _elPage = 0; elFetch(); return; }
fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ elevenlabs_api_key: val }) })
.then(() => {
const st = $('el-key-status');
if (st) { st.textContent = '✓ Key saved'; st.className = 'el-key-status el-key-ok'; }
if (inp) { inp.value = ''; inp.placeholder = '••••••••••••••• (key saved)'; }
_elPage = 0; elFetch();
});
});
$('el-api-key')?.addEventListener('keydown', e => { if (e.key === 'Enter') $('el-key-save')?.click(); });
$('el-cats')?.querySelectorAll('.el-cat').forEach(pill => {
pill.addEventListener('click', () => {
$('el-cats').querySelectorAll('.el-cat').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
_elFilters = {};
for (const attr of pill.getAttributeNames()) {
if (attr.startsWith('data-el-') && pill.getAttribute(attr))
_elFilters[attr.slice('data-el-'.length)] = pill.getAttribute(attr);
}
_elPage = 0; elFetch();
});
});
['el-lang', 'el-gender', 'el-age'].forEach(id => {
$(id)?.addEventListener('change', () => { _elPage = 0; elFetch(); });
});
$('el-search')?.addEventListener('input', () => {
clearTimeout(_elSearchTimer);
_elSearchTimer = setTimeout(() => { _elPage = 0; elFetch(); }, 420);
});
$('el-fetch')?.addEventListener('click', () => { _elPage = 0; elFetch(); });
$('el-prev')?.addEventListener('click', () => { if (_elPage > 0) { _elPage--; elFetch(); } });
$('el-next')?.addEventListener('click', () => { if (_elHasMore) { _elPage++; elFetch(); } });
function elParams() {
const p = new URLSearchParams({ page_size: '24', page: String(_elPage) });
const lang = $('el-lang')?.value, gender = $('el-gender')?.value, age = $('el-age')?.value;
const search = ($('el-search')?.value || '').trim();
if (lang) p.set('language', lang);
if (gender) p.set('gender', gender);
if (age) p.set('age', age);
if (search) p.set('search', search);
Object.entries(_elFilters).forEach(([k, v]) => { if (v) p.set(k, v); });
return p.toString();
}
async function elFetch() {
const grid = $('el-grid'), sb = $('el-status-bar'), st = $('el-status-text');
if (grid) grid.innerHTML = '<div class="el-loading"><span class="el-spinner"></span> Loading voices…</div>';
try {
const res = await fetch('/api/elevenlabs/voices?' + elParams());
const data = await res.json();
if (data.detail) {
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(data.detail?.message || JSON.stringify(data.detail))}</div>`;
if (sb) sb.hidden = true;
return;
}
_elHasMore = !!data.has_more;
_elTotal = data.total_count || 0;
const voices = data.voices || [];
if (st) {
const cap = voices.length < 24 && _elTotal > 3 ? ' · \u{1F511} Add your free API key for full access' : '';
st.innerHTML = `<strong>${_elTotal.toLocaleString()}</strong> voices &middot; showing ${voices.length} &middot; page ${_elPage + 1}${cap}`;
}
if (sb) sb.hidden = false;
if (!voices.length) {
if (grid) grid.innerHTML = '<div class="el-empty">No voices found — try different filters.</div>';
} else {
if (grid) { grid.innerHTML = voices.map(elCard).join(''); }
grid?.querySelectorAll('.el-play').forEach(b => b.addEventListener('click', () => elPlay(b)));
grid?.querySelectorAll('.el-clone').forEach(b => b.addEventListener('click', () => elImport(b)));
}
const pager = $('el-pager');
if (pager) {
pager.hidden = _elPage === 0 && !_elHasMore;
const pb = $('el-prev'), nb = $('el-next'), pi = $('el-pager-info');
if (pb) pb.disabled = _elPage === 0;
if (nb) nb.disabled = !_elHasMore;
if (pi) pi.textContent = `Page ${_elPage + 1}${_elTotal > 24 ? ' of ~' + Math.ceil(_elTotal / 24) : ''}`;
}
} catch (e) {
if (grid) grid.innerHTML = `<div class="el-error">⚠ ${_elEsc(e.message)}</div>`;
}
}
function elCard(v) {
const hue = _elHue(v.voice_id || v.name || '');
const letter = _elEsc((v.name || '?')[0].toUpperCase());
const lang = (v.language || '').toUpperCase();
const g = v.gender === 'female' ? 'F' : v.gender === 'male' ? 'M' : (v.gender || '');
const age = (v.age || '').replace(/_/g, ' ');
const uc = (v.use_case || '').replace(/_/g, ' ');
const clones = v.cloned_by_count || 0;
const cl = clones >= 1000 ? (clones / 1000).toFixed(1) + 'k' : clones > 0 ? String(clones) : '';
const desc = (v.description || '').slice(0, 88) + ((v.description || '').length > 88 ? '…' : '');
const prev = _elEsc(v.preview_url || '');
const nm = _elEsc(v.name || '');
const tags = [
lang ? `<span class="el-tag el-lang">${lang}</span>` : '',
g ? `<span class="el-tag el-tag-g">${g}</span>` : '',
age ? `<span class="el-tag">${_elEsc(age)}</span>` : '',
uc ? `<span class="el-tag el-uc">${_elEsc(uc)}</span>` : '',
(v.accent && v.accent !== 'standard') ? `<span class="el-tag">${_elEsc(v.accent)}</span>` : '',
v.free_users_allowed ? '<span class="el-tag el-free">Free</span>' : '',
v.featured ? '<span class="el-tag el-feat">★</span>' : '',
cl ? `<span class="el-tag el-clones">↓ ${cl}</span>` : '',
].join('');
return `<div class="el-card-voice">
<div class="el-vc-av" style="--elh:${hue}">${letter}</div>
<div class="el-vc-body">
<div class="el-vc-name">${nm}</div>
<div class="el-vc-tags">${tags}</div>
${desc ? `<div class="el-vc-desc">${_elEsc(desc)}</div>` : ''}
</div>
<div class="el-vc-btns">
<button class="el-play" title="Play preview" data-preview="${prev}"></button>
${prev ? `<button class="el-clone" title="Import to Clone a Voice" data-preview="${prev}" data-name="${nm}">↓ Clone</button>` : ''}
</div>
</div>`;
}
function elPlay(btn) {
const url = btn.dataset.preview;
if (!url) return;
if (_elAudio && _elAudioBtn === btn) {
_elAudio.paused ? _elAudio.play() : _elAudio.pause();
btn.textContent = _elAudio.paused ? '▶' : '⏸';
return;
}
if (_elAudio) { _elAudio.pause(); if (_elAudioBtn) _elAudioBtn.textContent = '▶'; }
_elAudio = new Audio(url);
_elAudioBtn = btn;
btn.textContent = '⏸';
_elAudio.play().catch(() => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
_elAudio.addEventListener('ended', () => { btn.textContent = '▶'; _elAudio = null; _elAudioBtn = null; });
}
function elImport(btn) {
const url = btn.dataset.preview;
if (!url) return;
if (typeof navTo === 'function') navTo('s-clone');
setTimeout(() => {
const inp = $('lib-add-url'), btn2 = $('lib-add-url-btn');
if (inp) { inp.value = url; inp.dispatchEvent(new Event('input')); }
if (btn2) btn2.click();
}, 120);
}
elLoadKey();
})();
loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message)); loadVoiceLibrary().then(renderIntegrationSnippets).catch(e => status('Voice library load failed: ' + e.message));

View File

@ -34,3 +34,106 @@
<div class="card"><p class="note">Click <strong>Scrape sources</strong> to fetch Aiartes VoiceAI clips, yaph/tts-samples MP3 files, and the jim-schwoebel voice dataset index.</p></div> <div class="card"><p class="note">Click <strong>Scrape sources</strong> to fetch Aiartes VoiceAI clips, yaph/tts-samples MP3 files, and the jim-schwoebel voice dataset index.</p></div>
</div> </div>
</div><!-- /tab-getvoices --> </div><!-- /tab-getvoices -->
<!-- ElevenLabs Voice Library Browser -->
<div class="card el-browser" id="el-browser-card">
<!-- Header: brand + API key -->
<div class="el-head">
<div class="el-brand">
<span class="el-brand-logo">11</span>
<div>
<h2>ElevenLabs Voice Library</h2>
<p class="note">Browse 12 000+ shared voices and import previews directly into Clone. <a href="https://elevenlabs.io/voice-library" target="_blank" rel="noopener">elevenlabs.io ↗</a></p>
</div>
</div>
<div class="el-key-row">
<div class="el-key-field">
<input type="password" id="el-api-key" placeholder="ElevenLabs API key (free — unlocks full library)…" autocomplete="new-password">
<button class="btn-primary el-key-btn" id="el-key-save">Save key</button>
</div>
<span class="el-key-status" id="el-key-status">No key — max 3 results</span>
<a class="note" href="https://elevenlabs.io/api" target="_blank" rel="noopener">Get free key ↗</a>
</div>
</div>
<!-- Category pills -->
<div class="el-cats" id="el-cats">
<button class="el-cat active" data-el-use_case="" data-el-featured="" data-el-category="">All</button>
<button class="el-cat" data-el-featured="true">&#11088; Featured</button>
<button class="el-cat" data-el-category="professional">Professional</button>
<button class="el-cat" data-el-use_case="narration">Narration</button>
<button class="el-cat" data-el-use_case="conversational">Conversational</button>
<button class="el-cat" data-el-use_case="news">News</button>
<button class="el-cat" data-el-use_case="characters_animation">Characters</button>
<button class="el-cat" data-el-use_case="meditation">Meditation</button>
<button class="el-cat" data-el-use_case="gaming">Gaming</button>
<button class="el-cat" data-el-use_case="training">Training</button>
</div>
<!-- Filters -->
<div class="el-filters">
<input type="search" class="el-search" id="el-search" placeholder="Search voices&hellip;" autocomplete="off">
<select id="el-lang" title="Language">
<option value="">All languages</option>
<option value="en">English</option>
<option value="de">German</option>
<option value="fr">French</option>
<option value="es">Spanish</option>
<option value="it">Italian</option>
<option value="pt">Portuguese</option>
<option value="nl">Dutch</option>
<option value="pl">Polish</option>
<option value="sv">Swedish</option>
<option value="da">Danish</option>
<option value="no">Norwegian</option>
<option value="fi">Finnish</option>
<option value="cs">Czech</option>
<option value="sk">Slovak</option>
<option value="hu">Hungarian</option>
<option value="ro">Romanian</option>
<option value="hr">Croatian</option>
<option value="bg">Bulgarian</option>
<option value="el">Greek</option>
<option value="uk">Ukrainian</option>
<option value="ru">Russian</option>
<option value="tr">Turkish</option>
<option value="ca">Catalan</option>
<option value="ar">Arabic</option>
<option value="hi">Hindi</option>
<option value="zh">Chinese</option>
<option value="ja">Japanese</option>
<option value="ko">Korean</option>
</select>
<select id="el-gender" title="Gender">
<option value="">Any gender</option>
<option value="male">Male</option>
<option value="female">Female</option>
</select>
<select id="el-age" title="Age">
<option value="">Any age</option>
<option value="young">Young</option>
<option value="middle_aged">Middle aged</option>
<option value="old">Old</option>
</select>
<button class="btn-primary" id="el-fetch">Browse</button>
</div>
<!-- Status -->
<div class="el-status-bar" id="el-status-bar" hidden>
<span id="el-status-text"></span>
</div>
<!-- Voice grid -->
<div class="el-grid" id="el-grid">
<div class="el-idle">Use the filters above and click <strong>Browse</strong> &mdash; or enter your API key for full access to 12 000+ voices.</div>
</div>
<!-- Pagination -->
<div class="el-pager" id="el-pager" hidden>
<button class="btn-secondary" id="el-prev" disabled>&larr; Prev</button>
<span class="el-pager-info" id="el-pager-info">Page 1</span>
<button class="btn-secondary" id="el-next">Next &rarr;</button>
</div>
</div><!-- /el-browser-card -->

View File

@ -514,7 +514,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.vl-row.edit-open .vr-optimizer { display: block; } .vl-row.edit-open .vr-optimizer { display: block; }
.optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; } .optimizer-grid { display: grid; grid-template-columns: minmax(280px,1.2fr) minmax(280px,1fr); gap: 12px; align-items: start; }
.opt-group { box-shadow: var(--shadow); margin-top: 20px; margin-right: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; } .opt-group { box-shadow: var(--shadow); margin-top: 20px; margin-right: 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface); padding: 10px; min-width: 0; }
.opt-group-title { font-size: 14px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; } .opt-group-title { font-size: 14px; font-weight: 800; color: var(--accent); text-transform: uppercase; letter-spacing: .08em;}
.opt-group-note { font-size: 12px; color: var(--subtext); line-height: 1.4; margin-top: 7px; } .opt-group-note { font-size: 12px; color: var(--subtext); line-height: 1.4; margin-top: 7px; }
.opt-compare-panel { grid-column: 1 / -1; } .opt-compare-panel { grid-column: 1 / -1; }
.opt-compare-grid { display: grid; grid-template-columns: repeat(2, minmax(240px,1fr)); gap: 10px; } .opt-compare-grid { display: grid; grid-template-columns: repeat(2, minmax(240px,1fr)); gap: 10px; }
@ -1139,9 +1139,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
} }
.inspector-body .opt-group-title { .inspector-body .opt-group-title {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
padding: 9px 14px; font-size: 10.5px; font-weight: 700; letter-spacing: .05em; padding: 10px 10px; font-size: 14px; font-weight: 700; letter-spacing: .05em;
text-transform: uppercase; color: var(--subtext); text-transform: uppercase; color: var(--subtext);
background: var(--panel); cursor: pointer; user-select: none; cursor: pointer; user-select: none;
transition: background .15s; transition: background .15s;
} }
.inspector-body .opt-group-title:hover { background: var(--border); } .inspector-body .opt-group-title:hover { background: var(--border); }
@ -1162,7 +1162,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.inspector-body .opt-group .opt-group-body > textarea { .inspector-body .opt-group .opt-group-body > textarea {
display: block; width: 100%; box-sizing: border-box; padding: 10px 14px; margin: 0; display: block; width: 100%; box-sizing: border-box; padding: 10px 14px; margin: 0;
background: transparent; border: none; outline: none; resize: vertical; background: transparent; border: none; outline: none; resize: vertical;
font-size: 13px; color: var(--text); font-family: inherit; min-height: 80px; font-size: 16px; color: var(--text); font-family: inherit; min-height: 80px;
} }
/* Section order */ /* Section order */
@ -1434,4 +1434,60 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.llm-howto-body p { margin: 0; color: var(--subtext); } .llm-howto-body p { margin: 0; color: var(--subtext); }
.llm-howto-body em { font-style: normal; font-weight: 600; color: var(--text); } .llm-howto-body em { font-style: normal; font-weight: 600; color: var(--text); }
/* ── ElevenLabs Voice Browser ──────────────────────────────────────────────── */
.el-browser { padding: 0; overflow: hidden; }
.el-head { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 14px; padding: 16px 20px 14px; border-bottom: 1px solid var(--border); }
.el-brand { display: flex; align-items: flex-start; gap: 12px; flex: 1; min-width: 200px; }
.el-brand-logo { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 9px; background: #131314; color: #fff; font-weight: 800; font-size: 13px; letter-spacing: -0.5px; flex-shrink: 0; margin-top: 3px; }
.el-brand h2 { margin: 0 0 3px; font-size: 14px; font-weight: 700; }
.el-brand p { margin: 0; font-size: 12px; }
.el-key-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.el-key-field { display: flex; gap: 6px; }
.el-key-field input { width: 230px; font-size: 12px; padding: 5px 9px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-family: monospace; }
.el-key-btn { font-size: 12px; padding: 5px 12px; }
.el-key-status { font-size: 11px; font-weight: 600; }
.el-key-ok { color: #38a169; }
.el-key-none { color: var(--subtext); }
.el-cats { display: flex; flex-wrap: wrap; gap: 6px; padding: 11px 20px; border-bottom: 1px solid var(--border); }
.el-cat { padding: 4px 13px; border-radius: 20px; border: 1px solid var(--border); background: transparent; color: var(--text); font-size: 12px; cursor: pointer; transition: border-color .15s, color .15s, background .15s; white-space: nowrap; }
.el-cat:hover { border-color: var(--accent); color: var(--accent); }
.el-cat.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.el-filters { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 20px; border-bottom: 1px solid var(--border); align-items: center; }
.el-search { flex: 1; min-width: 130px; font-size: 13px; padding: 5px 9px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); color: var(--text); font-family: inherit; }
.el-filters select { font-size: 12px; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); font-family: inherit; }
.el-status-bar { padding: 6px 20px; background: var(--panel); border-bottom: 1px solid var(--border); font-size: 12px; color: var(--subtext); }
.el-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(272px, 1fr)); }
.el-grid > .el-loading,
.el-grid > .el-error,
.el-grid > .el-empty,
.el-grid > .el-idle { grid-column: 1/-1; padding: 36px 20px; text-align: center; color: var(--subtext); font-size: 13px; }
.el-spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: elSpin .75s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes elSpin { to { transform: rotate(360deg); } }
.el-error { color: #e53e3e !important; }
.el-card-voice { display: flex; align-items: flex-start; gap: 10px; padding: 11px 13px; border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); transition: background .12s; }
.el-card-voice:hover { background: var(--panel); }
.el-vc-av { flex-shrink: 0; width: 34px; height: 34px; border-radius: 50%; background: hsl(var(--elh, 220), 52%, 52%); color: #fff; font-weight: 700; font-size: 15px; display: flex; align-items: center; justify-content: center; margin-top: 3px; }
.el-vc-body { flex: 1; min-width: 0; }
.el-vc-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 4px; }
.el-vc-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 4px; }
.el-tag { font-size: 10px; padding: 1px 5px; border-radius: 3px; background: var(--panel); border: 1px solid var(--border); color: var(--subtext); white-space: nowrap; }
.el-lang { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 700; }
.el-uc { color: var(--accent); border-color: var(--accent); background: transparent; }
.el-free { color: #38a169; border-color: #38a169; background: transparent; }
.el-feat { color: #d69e2e; border-color: #d69e2e; background: transparent; }
.el-clones { opacity: .7; }
.el-vc-desc { font-size: 11px; color: var(--subtext); line-height: 1.4; }
.el-vc-btns { display: flex; flex-direction: column; gap: 5px; flex-shrink: 0; padding-top: 2px; }
.el-play, .el-clone { border: 1px solid var(--border); border-radius: 5px; background: var(--surface); color: var(--text); font-size: 11px; padding: 4px 8px; cursor: pointer; white-space: nowrap; transition: border-color .15s, color .15s; font-family: inherit; }
.el-play:hover { border-color: var(--accent); color: var(--accent); }
.el-clone:hover { border-color: #38a169; color: #38a169; }
.el-pager { display: flex; align-items: center; gap: 12px; padding: 12px 20px; border-top: 1px solid var(--border); }
.el-pager-info { flex: 1; text-align: center; font-size: 12px; color: var(--subtext); }
/* Bottom action buttons */ /* Bottom action buttons */