refactor: extract rehearser cast renderer
This commit is contained in:
parent
ae9df4f7f0
commit
eb008475ed
@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### 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,
|
- **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.
|
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
|
- **Audiobook cast normalization extraction.** Same-speaker post-cast merging now lives in
|
||||||
|
|||||||
5
TODO.md
5
TODO.md
@ -88,7 +88,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
|
|||||||
forms feed prompt generation and identity fingerprints.
|
forms feed prompt generation and identity fingerprints.
|
||||||
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
|
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
|
||||||
bridge, and section-scoped lazy loading are complete foundations; responsibility-level
|
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
|
- [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.
|
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
|
- [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
|
## 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`.
|
- [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
|
- [ ] 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.
|
exposes the existing Audiobook, Rehearser, and job APIs while responsibility-by-responsibility migration continues.
|
||||||
|
|||||||
@ -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
|
### 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:
|
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-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-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-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.
|
- ✅ `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-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`.
|
- ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`.
|
||||||
|
|||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="format-detection" content="telephone=no">
|
<meta name="format-detection" content="telephone=no">
|
||||||
<meta name="color-scheme" content="light dark">
|
<meta name="color-scheme" content="light dark">
|
||||||
<meta name="theme-color" content="#2563EB">
|
<meta name="theme-color" content="#2563EB">
|
||||||
<meta name="app-version" content="1.24.43">
|
<meta name="app-version" content="1.24.44">
|
||||||
<link rel="manifest" href="/manifest.webmanifest">
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||||
@ -27,10 +27,10 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.24.43">
|
<link rel="stylesheet" href="/static/style.css?v=1.24.44">
|
||||||
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.43">
|
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.24.44">
|
||||||
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.43">
|
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.24.44">
|
||||||
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.43">
|
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.24.44">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -395,7 +395,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.24.43"></script>
|
<script src="/static/loader.js?v=1.24.44"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -61,6 +61,242 @@ function _castSortFilter(speakers, lineCount) {
|
|||||||
return out;
|
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 & 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 & 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 & 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 = window.TTVC || {};
|
||||||
window.TTVC.rehearser = window.TTVC.rehearser || {};
|
window.TTVC.rehearser = window.TTVC.rehearser || {};
|
||||||
window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter };
|
window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter, renderCastList };
|
||||||
|
|||||||
@ -1273,240 +1273,6 @@ function _rehEnsureLibCharsCache(scriptTitle) {
|
|||||||
}).catch(() => {});
|
}).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 => `<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 & 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 & 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 & 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); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }
|
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,8 @@ test('parses a screenplay and builds the cast', async ({ page }) => {
|
|||||||
await expect.poll(() => page.evaluate(() => Boolean(
|
await expect.poll(() => page.evaluate(() => Boolean(
|
||||||
window.TTVC?.rehearser?.parse?.parseScript &&
|
window.TTVC?.rehearser?.parse?.parseScript &&
|
||||||
window.TTVC?.rehearser?.stage?.renderCastStrip &&
|
window.TTVC?.rehearser?.stage?.renderCastStrip &&
|
||||||
window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml
|
window.TTVC?.rehearser?.voicePicker?.castOnlinePanelHtml &&
|
||||||
|
window.TTVC?.rehearser?.cast?.renderCastList
|
||||||
))).toBe(true);
|
))).toBe(true);
|
||||||
await page.evaluate(() => window.navRehearserPhase(1));
|
await page.evaluate(() => window.navRehearserPhase(1));
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user