From d6135915260c68f990a2213a90747757918725a9 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 11:04:13 +0200 Subject: [PATCH] refactor: extract rehearser cast controls --- CHANGELOG.md | 2 ++ TODO.md | 1 + VERSION | 2 +- docs/ARCHITECTURE.md | 1 + static/index.html | 6 ++-- static/js/rehearser-cast.js | 66 +++++++++++++++++++++++++++++++++++++ static/js/rehearser.js | 61 ---------------------------------- static/module-manifest.json | 8 ++--- 8 files changed, 78 insertions(+), 69 deletions(-) create mode 100644 static/js/rehearser-cast.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e00a2..ff43f0f 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-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 diff --git a/TODO.md b/TODO.md index 409374e..8d0f5e1 100644 --- a/TODO.md +++ b/TODO.md @@ -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`. diff --git a/VERSION b/VERSION index 4dc8bed..e74fe9d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.30 +1.24.31 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 85bbedd..8150fc4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/static/index.html b/static/index.html index d7bc86e..8f09852 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -392,7 +392,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/rehearser-cast.js b/static/js/rehearser-cast.js new file mode 100644 index 0000000..dfe6183 --- /dev/null +++ b/static/js/rehearser-cast.js @@ -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 }; diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 9c3af70..4453421 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -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; diff --git a/static/module-manifest.json b/static/module-manifest.json index aaca1a5..232e888 100644 --- a/static/module-manifest.json +++ b/static/module-manifest.json @@ -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"] } }