tts-voice-creator-clone-and.../static/js/rehearser-cast.js

303 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── Rehearser cast view controls ────────────────────────────────────────────
// The cast renderer remains in rehearser.js; these handlers only resolve state
// when the cast panel is rendered, making them safe to load as a leaf module.
function applyCastView() {
const list = $('reh-cast-list'); if (!list) return;
const view = rehState.castView === 'list' ? 'list' : 'card';
list.classList.toggle('reh-cast-view-card', view === 'card');
list.classList.toggle('reh-cast-view-list', view === 'list');
document.querySelectorAll('#reh-cast-view-toggle .reh-view-btn').forEach(b => b.classList.toggle('active', b.dataset.view === view));
}
function _wireCastControls() {
const toggle = $('reh-cast-view-toggle');
if (!toggle || toggle._wired) return;
toggle._wired = true;
toggle.querySelectorAll('.reh-view-btn').forEach(b => b.addEventListener('click', () => {
rehState.castView = b.dataset.view;
try { localStorage.setItem('reh-cast-view', b.dataset.view); } catch (_) {}
applyCastView();
}));
const search = $('reh-cast-search'), fg = $('reh-cast-filter-gender'), fl = $('reh-cast-filter-lang');
const sortSel = $('reh-cast-sort'), dirBtn = $('reh-cast-sort-dir');
const setDirIcon = () => { if (dirBtn) { dirBtn.dataset.dir = rehState.castSort.dir; dirBtn.querySelector('.mdi').className = 'mdi mdi-sort-' + (rehState.castSort.dir === 'desc' ? 'descending' : 'ascending'); } };
const persist = () => { try { localStorage.setItem('reh-cast-sort', JSON.stringify(rehState.castSort)); } catch (_) {} };
if (sortSel) sortSel.value = rehState.castSort.by;
setDirIcon();
search?.addEventListener('input', () => { rehState.castFilter.search = search.value.trim(); renderCastList(); search.focus(); });
fg?.addEventListener('change', () => { rehState.castFilter.gender = fg.value; renderCastList(); });
fl?.addEventListener('change', () => { rehState.castFilter.lang = fl.value; renderCastList(); });
sortSel?.addEventListener('change', () => {
rehState.castSort.by = sortSel.value;
rehState.castSort.dir = sortSel.value === 'lines' ? 'desc' : 'asc';
setDirIcon(); persist(); renderCastList();
});
dirBtn?.addEventListener('click', () => {
rehState.castSort.dir = rehState.castSort.dir === 'desc' ? 'asc' : 'desc';
setDirIcon(); persist(); renderCastList();
});
}
function _castSortFilter(speakers, lineCount) {
const f = rehState.castFilter || {}, s = rehState.castSort || { by: 'name', dir: 'asc' };
const out = speakers.filter(sp => {
const c = rehState.cast[sp] || {};
if (f.search) { const q = f.search.toLowerCase(); if (!sp.toLowerCase().includes(q) && !(c.tags || '').toLowerCase().includes(q)) return false; }
if (f.gender && (c.gender || '') !== f.gender) return false;
if (f.lang && (c.lang || '') !== f.lang) return false;
return true;
});
const byName = (a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' });
const cmp = {
name: byName,
gender: (a, b) => (rehState.cast[a].gender || '~').localeCompare(rehState.cast[b].gender || '~') || byName(a, b),
lang: (a, b) => (rehState.cast[a].lang || '~').localeCompare(rehState.cast[b].lang || '~') || byName(a, b),
lines: (a, b) => (lineCount(a) - lineCount(b)) || byName(a, b),
tag: (a, b) => (rehState.cast[a].tags || '~').localeCompare(rehState.cast[b].tags || '~') || byName(a, b),
}[s.by] || byName;
out.sort(cmp);
if (s.dir === 'desc') out.reverse();
return out;
}
function renderCastList() {
const list = $('reh-cast-list'); if (!list) return;
// Make sure the voice library is loaded so every picker (incl. the narrator) has
// voices to choose from even when the Rehearser was opened directly.
if (!(window._voices || []).length && typeof loadVoiceLibrary === 'function' && !rehState._castLibFetch) {
rehState._castLibFetch = true;
loadVoiceLibrary().then(() => renderCastList()).catch(() => {});
}
_ensureNarrator();
const narr = REH_NARRATOR_KEY;
const lineCount = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;
const allOthers = Object.keys(rehState.cast).filter(s => s !== narr);
const others = _castSortFilter(allOthers, lineCount); // narrator always stays pinned on top
const speakers = [narr, ...others];
const scriptTitle = $('reh-script-title')?.value.trim() || '';
_rehEnsureLibCharsCache(scriptTitle);
const libRecByName = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r]));
const emotionsFor = sp => {
const seen = new Map();
rehState.lines.forEach(l => {
if (l.speaker === sp && l.type === 'dialog' && l.emotion && !seen.has(l.emotion)) {
seen.set(l.emotion, getEmotionInfo(l.emotion));
}
});
return [...seen.values()];
};
list.innerHTML = speakers.map(sp => {
const c = rehState.cast[sp];
const isNarr = sp === narr;
const isMe = c.voice === 'me';
const label = isNarr ? 'Narrator' : sp;
const n = lineCount(sp);
const sub = isNarr ? 'scene headings & descriptions' : `${n} line${n!==1?'s':''}${scriptTitle ? ` · ${escHtml(scriptTitle)}` : ''}`;
const cardCls = 'reh-cast-card' + (isNarr ? ' reh-cast-narrator' : '') + (c.ignored ? ' reh-cast-ignored' : '') + (c.hidden ? ' reh-cast-hidden-c' : '');
const langSel = REH_CAST_LANGS.map(l => `<option${(c.lang||'')===l?' selected':''}>${l}</option>`).join('');
const genSel = REH_CAST_GENDERS.map(([v,t]) => `<option value="${v}"${(c.gender||'')===v?' selected':''}>${t}</option>`).join('');
const pickerId = 'reh-voice-sel-' + _safeDomId(sp);
const voiceSel = `<select id="${pickerId}" class="reh-voice-sel" data-speaker="${escHtml(sp)}"${isMe?' style="display:none"':''}>
<option value="">— ${isNarr ? 'none (skip descriptions)' : 'assign a voice'} —</option>
${_rehAllVoiceIds(c.voice).map(v=>`<option value="${escHtml(v)}"${v===c.voice?' selected':''}>${escHtml(v)}</option>`).join('')}
</select>`;
const voiceName = !isMe && c.voice ? (getVoiceData(c.voice)?.name || c.voice) : (isMe ? 'Your mic' : '');
// Library cross-reference — same book/script, matched by name. When a
// real character record exists, the card IS the exact Library card
// (_charCardHtml) — same colorful portrait/tier/occupation/alignment
// design already used in Library → Cast, not a separate look-alike —
// and voice assignment happens by clicking into the full profile (or
// the bulk match tools above) rather than a dropdown on the card.
// Narrator/unmatched speakers (no Library record to point at) keep the
// simple fallback header with an inline voice dropdown, since there's
// no profile page for them to assign a voice from.
const libRec = !isNarr ? libRecByName.get(String(sp).trim().toLowerCase()) : null;
if (libRec) {
const libVoice = typeof libRec.voice === 'object' ? (libRec.voice?.id || '') : (libRec.voice || '');
if (libVoice) c.voice = libVoice;
}
const emotions = isNarr ? [] : emotionsFor(sp);
const emotionsHtml = emotions.length
? `<div class="reh-cc-emotions">${emotions.map(info => `<span class="reh-cc-emo-chip">${info.emoji} ${escHtml(info.label)}</span>`).join('')}</div>`
: '';
const controlsHtml = `<div class="reh-cc-controls">
<label class="reh-cc-me-row" title="Record this character with your own mic instead of a TTS voice"><input type="checkbox" class="reh-me-check" data-speaker="${escHtml(sp)}" ${isMe?'checked':''}><span><span class="mdi mdi-microphone"></span> I play this</span></label>
${(!isMe && c.voice) ? `<button type="button" class="reh-cc-sample-btn" data-speaker="${escHtml(sp)}" title="Hear a sample line in this voice" aria-label="Hear a sample line in this voice"><span class="mdi mdi-play"></span> Hear a line</button>` : ''}
<span class="reh-cc-actions-spacer"></span>
<button class="reh-cc-iconbtn${c.ignored?' active':''}" data-act="ignore" title="Ignore — grey out &amp; skip this character’s lines" aria-label="Ignore this character’s lines"><span class="mdi mdi-eye-off-outline"></span></button>
<button class="reh-cc-iconbtn${c.hidden?' active':''}" data-act="hide" title="Hide this character’s lines from the script" aria-label="Hide this character’s lines"><span class="mdi mdi-minus-circle-outline"></span></button>
<button class="reh-cc-iconbtn reh-cc-del" data-act="delete" title="Delete this character &amp; all their lines" aria-label="Delete this character"><span class="mdi mdi-trash-can-outline"></span></button>
</div>
${emotionsHtml}`;
if (libRec) {
return `<div class="${cardCls} reh-cast-card-libcard" data-speaker="${escHtml(sp)}" data-char-id="${escHtml(libRec.id)}" style="--cc-color:${c.color}">
${(typeof _charCardHtml === 'function') ? _charCardHtml(libRec, _rehLibCharsCache || []) : ''}
<div class="reh-cc-body">${controlsHtml}</div>
</div>`;
}
return `<div class="${cardCls}" data-speaker="${escHtml(sp)}" style="--cc-color:${c.color}">
<div class="reh-cc-head">
<div class="reh-cc-avatar">${castAvatarHtml(sp)}</div>
<div class="reh-cc-titles">
<div class="reh-cc-name" style="color:${c.color}">${isNarr ? '<span class="mdi mdi-book-open-page-variant"></span> ' : ''}${escHtml(label)}</div>
<div class="reh-cc-sub">${sub}</div>
<div class="reh-cc-voicechip${(!isMe && !c.voice) ? ' empty' : ''}"><span class="mdi mdi-${isMe ? 'microphone' : 'account-music-outline'}"></span> ${escHtml(voiceName || 'No voice assigned')}</div>
</div>
</div>
<div class="reh-cc-body"${isMe?' style="display:none"':''}>
<div class="reh-cc-field reh-cc-voice"><label>Voice</label>${voiceSel}</div>
${controlsHtml}
<details class="reh-cc-more">
<summary><span class="mdi mdi-tune-variant"></span> More options</summary>
<div class="reh-cc-extra">
${!isNarr ? castOnlinePanelHtml(sp, c) : ''}
${!isNarr ? `<div class="reh-cc-grid">
<div class="reh-cc-field"><label>Language</label><select class="reh-cc-lang">${langSel}</select></div>
<div class="reh-cc-field"><label>Gender</label><select class="reh-cc-gender">${genSel}</select></div>
<div class="reh-cc-field"><label>Tags</label><input type="text" class="reh-cc-tags" value="${escHtml(c.tags||'')}" placeholder="e.g. villain, raspy"></div>
</div>` : ''}
<div class="reh-cc-field"><label>Speaking style · voice-design prompt</label>
<input type="text" class="reh-cast-instruct" value="${escHtml(c.instruct||'')}" placeholder="${isNarr ? 'Narrator style: calm, measured, cinematic storyteller…' : 'British accent, commanding baritone, quietly menacing…'}"></div>
${!isNarr ? `<details class="reh-cc-soul"${c.soul?' open':''}>
<summary><span class="mdi mdi-script-text-outline"></span> Character soul · LLM brief
<button class="btn-secondary btn-sm reh-cc-develop" type="button" title="Let the LLM read the script &amp; flesh out this character"><span class="mdi mdi-auto-fix"></span> Develop</button>
</summary>
<textarea class="reh-cc-soul-text" placeholder="Backstory, motivation, vocal manner… (used as the LLM brief when designing this voice)">${escHtml(c.soul||'')}</textarea>
</details>` : ''}
</div>
</details>
</div>
</div>`;
}).join('');
// Cast count (reflects active filter) + keep the chosen view (card / list)
const countEl = $('reh-cast-count');
if (countEl) {
const shown = others.length, total = allOthers.length;
countEl.textContent = shown === total
? `${total} ${total === 1 ? 'character' : 'characters'} + narrator`
: `${shown} of ${total} characters`;
}
// Wire the reused Library cards (portrait upload, export, click → full
// profile) exactly like the Library grid does — the profile page opens
// in place of this list and "back" re-renders the cast list, same pattern
// used for the post-recast results grid on the Read Aloud page.
if (typeof _wireCharCards === 'function' && (_rehLibCharsCache || []).length) {
const recsById = new Map(_rehLibCharsCache.map(r => [r.id, r]));
_wireCharCards(list, recsById, _rehLibCharsCache, renderCastList, { container: list, onBack: renderCastList });
}
_wireCastControls();
applyCastView();
// ── Wiring ────────────────────────────────────────────────────────────────
const card = el => el.closest('.reh-cast-card');
const spOf = el => card(el).dataset.speaker;
if (window.VoicePicker) {
list.querySelectorAll('.reh-voice-sel[id]').forEach(sel => {
const cur = sel.value;
VoicePicker.upgrade(sel.id);
if (cur) VoicePicker.setValue(sel.id, cur);
});
}
list.querySelectorAll('.reh-me-check').forEach(cb => cb.addEventListener('change', function () {
const sp = spOf(this);
rehState.cast[sp].voice = this.checked ? 'me' : (card(this).querySelector('.reh-voice-sel')?.value || '');
rehState.cast[sp].voiceData = this.checked ? null : getVoiceData(rehState.cast[sp].voice);
renderCastList();
}));
list.querySelectorAll('.reh-voice-sel').forEach(sel => sel.addEventListener('change', function () {
const sp = this.dataset.speaker;
rehState.cast[sp].voice = this.value;
rehState.cast[sp].voiceData = getVoiceData(this.value);
delete rehState.cast[sp].online; // manual override → drop the online-match picker context
if (sp === REH_NARRATOR_KEY) rehState.narratorVoice = this.value;
renderCastList();
}));
list.querySelectorAll('.reh-cast-instruct').forEach(inp => inp.addEventListener('input', function () {
rehState.cast[spOf(this)].instruct = this.value;
}));
list.querySelectorAll('.reh-cc-lang').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].lang = this.value; }));
list.querySelectorAll('.reh-cc-gender').forEach(s => s.addEventListener('change', function () { rehState.cast[spOf(this)].gender = this.value; }));
list.querySelectorAll('.reh-cc-tags').forEach(i => i.addEventListener('input', function () { rehState.cast[spOf(this)].tags = this.value; }));
list.querySelectorAll('.reh-cc-soul-text').forEach(t => t.addEventListener('input', function () { rehState.cast[spOf(this)].soul = this.value; }));
list.querySelectorAll('.reh-cc-develop').forEach(b => b.addEventListener('click', function (e) { e.preventDefault(); _castDevelop(spOf(this), this); }));
list.querySelectorAll('.reh-cc-iconbtn').forEach(b => b.addEventListener('click', function () {
const sp = spOf(this), act = this.dataset.act, c = rehState.cast[sp];
if (act === 'ignore') { c.ignored = !c.ignored; _castApplyToLines(sp, l => l.ignored = c.ignored); renderCastList(); }
else if (act === 'hide') { c.hidden = !c.hidden; _castApplyToLines(sp, l => l.hidden = c.hidden); renderCastList(); }
else if (act === 'delete') { _castDeleteCharacter(sp); }
}));
// Online voice picker — delegated so dynamically-injected result rows work too.
// `list` persists across re-renders, so attach these handlers only once.
if (!list._voDelegated) {
list._voDelegated = true;
list.addEventListener('click', e => {
const sampleBtn = e.target.closest('.reh-cc-sample-btn');
if (sampleBtn) { e.preventDefault(); _rehPreviewCastLine(sampleBtn.dataset.speaker, sampleBtn); return; }
const playBtn = e.target.closest('.reh-vo-play');
if (playBtn) { e.preventDefault(); if (playBtn.dataset.voice) _rehPreviewLocal(playBtn.dataset.voice, playBtn); else _rehAudition(playBtn.dataset.url, playBtn); return; }
const toggle = e.target.closest('.reh-vo-toggle');
if (toggle) {
e.preventDefault();
const alts = toggle.closest('.reh-cc-online')?.querySelector('.reh-vo-alts');
if (alts) { alts.hidden = !alts.hidden; toggle.textContent = toggle.textContent.replace(/[▾▴]\s*$/, '') + (alts.hidden ? '▾' : '▴'); }
return;
}
const tool = e.target.closest('.reh-vo-tool');
if (tool) {
e.preventDefault();
const panel = tool.closest('.reh-cc-online');
const want = tool.dataset.tool;
const localP = panel.querySelector('.reh-vo-local-panel');
const searchP = panel.querySelector('.reh-vo-search-panel');
const showLocal = want === 'local' && (localP?.hidden ?? true);
const showSearch = want === 'search' && (searchP?.hidden ?? true);
if (localP) localP.hidden = !showLocal;
if (searchP) searchP.hidden = !showSearch;
panel.querySelectorAll('.reh-vo-tool').forEach(t => t.classList.toggle('active', (t.dataset.tool === 'local' && showLocal) || (t.dataset.tool === 'search' && showSearch)));
if (showLocal) { const card = e.target.closest('.reh-cast-card'); _rehRenderLocalResults(card, spOf(tool), ''); card.querySelector('.reh-vo-local-input')?.focus(); }
if (showSearch) panel.querySelector('.reh-vo-search-input')?.focus();
return;
}
const goBtn = e.target.closest('.reh-vo-search-go');
if (goBtn) { e.preventDefault(); const card = e.target.closest('.reh-cast-card'); _rehSearchOnline(card, spOf(goBtn), card.querySelector('.reh-vo-search-input')?.value); return; }
const use = e.target.closest('.reh-vo-use');
if (use) {
e.preventDefault();
const sp = spOf(use), act = use.dataset.act;
if (act === 'local') _rehAssignLocal(sp, use.dataset.voice);
else if (act === 'search') _rehUseSearchResult(sp, parseInt(use.dataset.idx, 10), use);
else _rehUseCandidate(sp, parseInt(use.dataset.idx, 10), use); // 'alt'
}
});
list.addEventListener('input', e => {
const li = e.target.closest('.reh-vo-local-input');
if (li) _rehRenderLocalResults(e.target.closest('.reh-cast-card'), spOf(li), li.value);
});
list.addEventListener('keydown', e => {
const si = e.target.closest('.reh-vo-search-input');
if (si && e.key === 'Enter') { e.preventDefault(); _rehSearchOnline(e.target.closest('.reh-cast-card'), spOf(si), si.value); }
});
}
}
window.TTVC = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {};
window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter, renderCastList };