refactor: extract rehearser cast controls

This commit is contained in:
mARTin-B78 2026-09-04 11:04:13 +02:00
parent ed3b2d42cd
commit d613591526
8 changed files with 78 additions and 69 deletions

View File

@ -8,6 +8,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Changed
- **Rehearser cast-controls extraction.** Cast view switching, sorting, filtering, and their guarded event wiring now
live in `rehearser-cast.js`, reducing the main renderer's responsibility while preserving the legacy API.
- **Namespace migration surface.** Extracted Rehearser and Audiobook modules now publish parse, stage, voice-picker,
and persistence APIs through `window.TTVC` while retaining legacy globals for the ongoing migration.
- **CI frontend gate.** The manifest-driven JavaScript syntax and top-level declaration collision check now runs in the

View File

@ -130,6 +130,7 @@ architecture change: the larger Audiobook/Rehearser/style split.
a full style/lint policy remains a follow-up.
- [ ] Extract Rehearser voice matching and voice design.
- [x] Extract Rehearser voice-picker presentation and preview state into `rehearser-voice-picker.js`.
- [x] Extract Rehearser cast view, sorting, filtering, and toolbar wiring into `rehearser-cast.js`.
- [x] Extract Rehearser library persistence and IndexedDB migration into `rehearser-library.js`.
- [ ] Extract Audiobook parsing and casting.
- [x] Extract Audiobook parsing primitives and speaker-heuristic constants into `audiobook-parse.js`.

View File

@ -1 +1 @@
1.24.30
1.24.31

View File

@ -33,6 +33,7 @@ Done incrementally as global-scope ordered files (no ES-module change needed)
- ⬜ `rehearser-cast.js` — `renderCastList`, sort/filter, online voice picker, 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`.
- ✅ `audiobook-parse.js` — dialogue detection, dehyphenation, and speaker-heuristic constants; the casting pipeline remains in `audiobook.js`.
- ⬜ `rehearser-train.js` — mic record / STT compare.
- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.24.30">
<meta name="app-version" content="1.24.31">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.24.30">
<link rel="stylesheet" href="/static/style.css?v=1.24.31">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.24.30"></script>
<script src="/static/loader.js?v=1.24.31"></script>
</body>
</html>

View File

@ -0,0 +1,66 @@
// ── 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;
}
window.TTVC = window.TTVC || {};
window.TTVC.rehearser = window.TTVC.rehearser || {};
window.TTVC.rehearser.cast = { applyCastView, filterAndSort: _castSortFilter };

View File

@ -1508,14 +1508,6 @@ function renderCastList() {
}
}
// Cast card / list view toggle
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));
}
try { rehState.castView = localStorage.getItem('reh-cast-view') || 'card'; } catch (_) { rehState.castView = 'card'; }
// Cast sort + filter state (narrator excluded — it's pinned on top by renderCastList)
@ -1523,59 +1515,6 @@ rehState.castFilter = { search: '', gender: '', lang: '' };
try { rehState.castSort = JSON.parse(localStorage.getItem('reh-cast-sort')) || { by: 'name', dir: 'asc' }; }
catch (_) { rehState.castSort = { by: 'name', dir: 'asc' }; }
// Wire the cast toolbar (view toggle + sort/filter). Called from renderCastList so it
// always binds once the section is mounted; guarded to attach only once.
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'; // lines → most first
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;
}
// Remove a character and all of their dialog lines (with index-state remap)
function _castDeleteCharacter(sp) {
const n = rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length;

View File

@ -4,9 +4,9 @@
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
],
"lazy": {
"s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
"s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets"],
"s-library": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
"s-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
"s-rehearser": ["rehearser-parse", "rehearser-cast", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker"],
"s-reader": ["rehearser-parse", "rehearser-cast", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets"],
"s-library": ["rehearser-parse", "rehearser-cast", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
"s-caststudio": ["rehearser-parse", "rehearser-cast", "rehearser", "rehearser-library", "rehearser-stage", "rehearser-voice-picker", "characters-library", "reader", "audiobook/network", "audiobook-parse", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
}
}