From eb008475ed2f332b1077d7b286b2b8bb4e3c41fc Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:39:45 +0200 Subject: [PATCH] refactor: extract rehearser cast renderer --- CHANGELOG.md | 2 + TODO.md | 5 +- VERSION | 2 +- docs/ARCHITECTURE.md | 4 +- static/index.html | 12 +- static/js/rehearser-cast.js | 238 +++++++++++++++++++++++++++++++++++- static/js/rehearser.js | 234 ----------------------------------- tests/rehearser.spec.js | 3 +- 8 files changed, 253 insertions(+), 247 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a20cb1..a1ce9c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser cast-renderer extraction.** The full cast-card renderer and delegated cast interactions now live in + `rehearser-cast.js`; the legacy `renderCastList` global and existing behavior remain available. - **Provider status clarity.** TTS and STT backend discovery now reports configuration separately from liveness, with bounded endpoint/status/error details shown in backend help text so unreachable services are actionable. - **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in diff --git a/TODO.md b/TODO.md index 6a83239..c1a3219 100644 --- a/TODO.md +++ b/TODO.md @@ -88,7 +88,7 @@ architecture change: the larger Audiobook/Rehearser/style split. forms feed prompt generation and identity fingerprints. - [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace bridge, and section-scoped lazy loading are complete foundations; responsibility-level - extraction and the Audiobook/Rehearser/style split remain. + extraction and the remaining Audiobook/Rehearser/style split continue incrementally. - [x] **M — Add structured logs with request/job IDs.** API/MCP requests now receive sanitized correlation IDs and duration logs; route-specific logs retain provider/job context without request bodies. - [x] **L — Add linting and duplicate-function checks.** The manifest-driven frontend syntax/declaration gate now runs @@ -120,7 +120,8 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture -- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility. +- [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility; the Rehearser cast renderer is now isolated, + with the remaining transport/matching blocks still in the monolith. - [x] Extract Rehearser stage display and cast-sidebar controls into `rehearser-stage.js`. - [ ] Replace broad globals with namespaces such as `window.TTVC.audiobook`; a compatibility namespace bridge now exposes the existing Audiobook, Rehearser, and job APIs while responsibility-by-responsibility migration continues. diff --git a/VERSION b/VERSION index d1c9614..4d65d5e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.43 +1.24.44 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1053e94..cdef598 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,10 +31,10 @@ The global scope + monkey-patching is the root cause of the recurring "wiring/ti ### 2. Split `rehearser.js` (was 196 KB) — IN PROGRESS Done incrementally as global-scope ordered files (no ES-module change needed) — each loaded before `rehearser.js` in `loader.js` + added to the bundle list in `scripts/minify.mjs`, verified by `tests/rehearser.spec.js` (parse → cast) and the smoke suite: - ✅ `rehearser-parse.js` — `parseScript`, `detectCharacters` (pure; only call-time `SPEAKER_COLORS`). -- ⬜ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, character research. +- ✅ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, and character research. - ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering; playback and transport remain in `rehearser.js`. - ✅ `rehearser-voice-picker.js` — voice preview state and matched-choice presentation; import and assignment actions remain in `rehearser.js`. -- ✅ `rehearser-cast.js` — cast view, sort/filter state handlers, and cast toolbar wiring; card rendering remains in `rehearser.js`. +- ✅ `rehearser-cast.js` — cast view, full cast-card rendering, sort/filter state handlers, and delegated cast toolbar wiring. - ✅ `rehearser-voice-design.js` — character development, backend selection, and backend-refresh synchronization. - ✅ `audiobook-segmentation.js` — quote segmentation, conservative turn-taking, and dialogue-piece validation. - ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`. diff --git a/static/index.html b/static/index.html index 684bdd1..aafa52a 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,10 +27,10 @@ - - - - + + + + @@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-cast.js b/static/js/rehearser-cast.js index dfe6183..2fca1e8 100644 --- a/static/js/rehearser-cast.js +++ b/static/js/rehearser-cast.js @@ -61,6 +61,242 @@ function _castSortFilter(speakers, lineCount) { 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); } + }); + } +} + + window.TTVC = window.TTVC || {}; window.TTVC.rehearser = window.TTVC.rehearser || {}; -window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter }; +window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter, renderCastList }; diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 7a71f30..1e6044e 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -1273,240 +1273,6 @@ function _rehEnsureLibCharsCache(scriptTitle) { }).catch(() => {}); } -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); } - }); - } -} try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; } diff --git a/tests/rehearser.spec.js b/tests/rehearser.spec.js index 8c9d67b..fa090cf 100644 --- a/tests/rehearser.spec.js +++ b/tests/rehearser.spec.js @@ -34,7 +34,8 @@ test('parses a screenplay and builds the cast', async ({ page }) => { await expect.poll(() => page.evaluate(() => Boolean( window.TTVC?.rehearser?.parse?.parseScript && window.TTVC?.rehearser?.stage?.renderCastStrip && - window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml + window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml && + window.TTVC?.rehearser?.cast?.renderCastList ))).toBe(true); await page.evaluate(() => window.navRehearserPhase(1));