// ── 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 (root) { 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 => `${l}`).join(''); const genSel = REH_CAST_GENDERS.map(([v,t]) => ``).join(''); const pickerId = 'reh-voice-sel-' + _safeDomId(sp); const voiceSel = ``; 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 ? `
${emotions.map(info => `${info.emoji} ${escHtml(info.label)}`).join('')}
` : ''; const controlsHtml = `
${(!isMe && c.voice) ? `` : ''}
${emotionsHtml}`; if (libRec) { return `
${(typeof _charCardHtml === 'function') ? _charCardHtml(libRec, _rehLibCharsCache || []) : ''}
${controlsHtml}
`; } return `
${castAvatarHtml(sp)}
${isNarr ? ' ' : ''}${escHtml(label)}
${sub}
${escHtml(voiceName || 'No voice assigned')}
`; }).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); } }); } } Object.assign(root, { applyCastView, _wireCastControls, _castSortFilter, renderCastList }); root.TTVC = root.TTVC || {}; root.TTVC.rehearser = root.TTVC.rehearser || {}; root.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter, renderCastList }; })(window);