From e98c5ffe333896c15030e50adf757efc7fa0f770 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 4 Sep 2026 10:52:05 +0200 Subject: [PATCH] refactor: extract rehearser stage controls --- CHANGELOG.md | 10 ++- TODO.md | 1 + VERSION | 2 +- docs/ARCHITECTURE.md | 2 +- static/index.html | 6 +- static/js/rehearser-stage.js | 101 ++++++++++++++++++++++++++++ static/js/rehearser.js | 123 ----------------------------------- static/module-manifest.json | 8 +-- 8 files changed, 118 insertions(+), 135 deletions(-) create mode 100644 static/js/rehearser-stage.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8594757..7449c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Changed +- **Rehearser stage extraction.** Stage font scaling, cast-sidebar collapse state, cast filtering/sorting, and cast-strip + rendering now live in `rehearser-stage.js`, reducing the main Rehearser module while preserving its legacy globals. +- **Release cache-busting.** Bumped the app version and HTML stylesheet/loader URLs to `1.24.25` so the extracted module + manifest and current frontend assets are not hidden behind stale browser caches. - **Rehearser library extraction.** SQLite persistence helpers and the one-time IndexedDB migration now live in `rehearser-library.js`, loaded with the Rehearser/Reader/Library lazy groups; behavior remains global-compatible. - **Expanded resource diagnostics.** The Performance panel now reports bounded browser cache counters, container @@ -27,9 +31,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads Rehearser, Reader/Audiobook, Library/Characters, and Studio code only when their sections are opened. Repeated navigation reuses loaded modules, while deep links load their group before activation. -- **P1 Roadmap status.** Documented the completed namespace/manifest foundations and durable background artifacts, - while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation, - full workload measurements, and browser image-cache eviction explicitly open. +- **P1 Roadmap status.** Documented the completed namespace/manifest foundations, section-scoped lazy loading, + durable background artifacts, provider-health consolidation, workload measurements, image-cache eviction, and + the first Rehearser responsibility extraction; remaining frontend splits stay explicitly open. ### Added - **Bounded Rehearser Audio Cache.** Decoded/synthesized line audio now uses a 128-entry LRU cache so long scripts diff --git a/TODO.md b/TODO.md index 6738be1..cf967fd 100644 --- a/TODO.md +++ b/TODO.md @@ -116,6 +116,7 @@ architecture change: the larger Audiobook/Rehearser/style split. ## P1 — Modular frontend architecture - [ ] Split `audiobook.js`, `rehearser.js`, and `style.css` by responsibility. +- [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. - [x] Generate the feature-module list for `loader.js` and `scripts/minify.mjs` from one shared manifest; diff --git a/VERSION b/VERSION index 5f4c2c9..80dd64f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.24.24 +1.24.25 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c35dca0..2f6e22b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -31,7 +31,7 @@ The global scope + monkey-patching is the root cause of the recurring "wiring/ti 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-stage.js` — playback, synth cache, pre-decode window, transport. +- ✅ `rehearser-stage.js` — stage font/cast sidebar controls and cast-strip rendering; playback and transport remain in `rehearser.js`. - ⬜ `rehearser-train.js` — mic record / STT compare. - ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain in `rehearser.js` until their UI/event-handler dependencies are isolated. diff --git a/static/index.html b/static/index.html index 88a88d7..09fedb4 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-stage.js b/static/js/rehearser-stage.js new file mode 100644 index 0000000..4884198 --- /dev/null +++ b/static/js/rehearser-stage.js @@ -0,0 +1,101 @@ +// ── Rehearser Stage sidebar and display controls ───────────────────────────── +// These controls consume rehState only when invoked, so they can be loaded +// after the core Rehearser module without changing the global compatibility API. + +const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale'; +const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed'; +function rehStageScale() { + const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1'); + return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v)); +} +function rehApplyStageFont() { + // Set on the page wrapper so the scale inherits to BOTH the single A4 page + // (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode. + const scale = String(rehStageScale()); + const wrap = document.querySelector('.reh-page-wrap'); + if (wrap) wrap.style.setProperty('--reh-stage-scale', scale); + const page = $('reh-a4-page'); + if (page) page.style.setProperty('--reh-stage-scale', scale); +} +function rehStageFontStep(delta) { + const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100)); + localStorage.setItem(REH_STAGE_FONT_KEY, String(next)); + rehApplyStageFont(); +} +function rehApplyCastCollapsed() { + const row = $('reh-cast-row'); + const stage = $('reh-stage-area'); + if (!row) return; + const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; + row.classList.toggle('is-collapsed', collapsed); + if (stage) stage.classList.toggle('side-collapsed', collapsed); + const btn = $('reh-cast-toggle'); + if (btn) { + btn.setAttribute('aria-expanded', String(!collapsed)); + btn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; + const icon = btn.querySelector('.mdi'); + if (icon) icon.className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'); + } +} +function rehToggleCast() { + const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; + localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1'); + rehApplyCastCollapsed(); +} + +function renderCastStrip() { + const castStrip = $('reh-cast-strip'); + if (!castStrip) return; + const scriptTitle = $('reh-script-title')?.value.trim() || ''; + _rehEnsureLibCharsCache(scriptTitle); + const names = Object.keys(rehState.cast); + const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length; + const libRecByNameStage = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r])); + const sortMode = $('reh-cast-side-sort')?.value || localStorage.getItem('reh_cast_side_sort') || 'lines'; + const query = ($('reh-cast-side-search')?.value || '').trim().toLowerCase(); + + let rows = names.map(sp => { + const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp; + const libRec = sp === REH_NARRATOR_KEY ? null : libRecByNameStage.get(String(sp).trim().toLowerCase()); + const fallbackName = displayName === 'Narrator' ? displayName + : displayName.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()); + return { sp, displayName: libRec?.name || fallbackName, n: lineCountFor(sp), libRec }; + }); + if (query) rows = rows.filter(r => r.displayName.toLowerCase().includes(query)); + rows.sort((a, b) => sortMode === 'name' + ? a.displayName.localeCompare(b.displayName) + : (b.n - a.n) || a.displayName.localeCompare(b.displayName)); + + castStrip.innerHTML = rows.map(({ sp, displayName, n, libRec }) => { + const c = rehState.cast[sp], isMe = c.voice === 'me'; + const avatarHtml = libRec?.image + ? `` + : `${escHtml((displayName||'?')[0].toUpperCase())}`; + return `
+ ${avatarHtml}${escHtml(displayName)} + ${isMe?'':''} + ${n}
`; + }).join(''); + castStrip.querySelectorAll('.ab-char-item').forEach(item => { + item.addEventListener('click', () => { + const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`); + if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }); + }); + const lbl = $('reh-cast-toggle-label'); + if (lbl) lbl.textContent = `Characters (${names.length})`; + const searchInp = $('reh-cast-side-search'); + const sortSel = $('reh-cast-side-sort'); + if (searchInp && !searchInp.dataset.wired) { + searchInp.dataset.wired = '1'; + searchInp.addEventListener('input', () => renderCastStrip()); + } + if (sortSel && !sortSel.dataset.wired) { + sortSel.dataset.wired = '1'; sortSel.value = sortMode; + sortSel.addEventListener('change', () => { localStorage.setItem('reh_cast_side_sort', sortSel.value); renderCastStrip(); }); + } +} + +$('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1)); +$('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1)); +$('reh-cast-toggle')?.addEventListener('click', rehToggleCast); diff --git a/static/js/rehearser.js b/static/js/rehearser.js index 8bcdb48..a2cc8db 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -2462,129 +2462,6 @@ $('reh-start-btn')?.addEventListener('click', () => { highlightCurrentLine(); }); -// ── Stage display controls (font size + collapsible cast) ─────────────────── - -const REH_STAGE_FONT_KEY = 'ttsvc_reh_stage_scale'; -const REH_CAST_COLLAPSED_KEY = 'ttsvc_reh_cast_collapsed'; - -function rehStageScale() { - const v = parseFloat(localStorage.getItem(REH_STAGE_FONT_KEY) || '1'); - return isNaN(v) ? 1 : Math.max(0.7, Math.min(2, v)); -} -function rehApplyStageFont() { - // Set on the page wrapper so the scale inherits to BOTH the single A4 page - // (#reh-a4-page) and the paginated `.reh-paper` pages built by page-mode. - const scale = String(rehStageScale()); - const wrap = document.querySelector('.reh-page-wrap'); - if (wrap) wrap.style.setProperty('--reh-stage-scale', scale); - const page = $('reh-a4-page'); - if (page) page.style.setProperty('--reh-stage-scale', scale); -} -function rehStageFontStep(delta) { - const next = Math.max(0.7, Math.min(2, Math.round((rehStageScale() + delta) * 100) / 100)); - localStorage.setItem(REH_STAGE_FONT_KEY, String(next)); - rehApplyStageFont(); -} - -function rehApplyCastCollapsed() { - // Same collapse pattern as Read Aloud's Casting sidebar (.ab-cv-side.is-collapsed - // shrinks to just the avatar dots); the stage area's grid column narrows to match. - const row = $('reh-cast-row'); - const stage = $('reh-stage-area'); - if (!row) return; - const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; - row.classList.toggle('is-collapsed', collapsed); - if (stage) stage.classList.toggle('side-collapsed', collapsed); - const btn = $('reh-cast-toggle'); - if (btn) { - btn.setAttribute('aria-expanded', String(!collapsed)); - btn.title = collapsed ? 'Expand character list' : 'Collapse to avatars'; - const icon = btn.querySelector('.mdi'); - if (icon) icon.className = 'mdi ' + (collapsed ? 'mdi-chevron-right' : 'mdi-chevron-left'); - } -} -function rehToggleCast() { - const collapsed = localStorage.getItem(REH_CAST_COLLAPSED_KEY) === '1'; - localStorage.setItem(REH_CAST_COLLAPSED_KEY, collapsed ? '0' : '1'); - rehApplyCastCollapsed(); -} - -$('reh-font-inc')?.addEventListener('click', () => rehStageFontStep(0.1)); -$('reh-font-dec')?.addEventListener('click', () => rehStageFontStep(-0.1)); -$('reh-cast-toggle')?.addEventListener('click', rehToggleCast); - -// ── A4 Script page ───────────────────────────────────────────────────────── - -// Cast sidebar — same look (.ab-char-item rows) as Read Aloud's Casting -// sidebar, but with real portraits (cross-referenced from the Library, same -// cache the Cast tab already builds) instead of plain colored-letter dots, -// plus search and sort. Split out from buildScriptPage so typing in the -// search box doesn't re-render the whole (potentially 1000+ line) script -// page on every keystroke — just this strip. -function renderCastStrip() { - const castStrip = $('reh-cast-strip'); - if (!castStrip) return; - const scriptTitle = $('reh-script-title')?.value.trim() || ''; - _rehEnsureLibCharsCache(scriptTitle); - const names = Object.keys(rehState.cast); - const lineCountFor = sp => rehState.lines.filter(l => l.speaker === sp && l.type === 'dialog').length; - const libRecByNameStage = new Map((_rehLibCharsCache || []).map(r => [String(r.name || '').trim().toLowerCase(), r])); - const sortMode = $('reh-cast-side-sort')?.value || localStorage.getItem('reh_cast_side_sort') || 'lines'; - const query = ($('reh-cast-side-search')?.value || '').trim().toLowerCase(); - - let rows = names.map(sp => { - const displayName = (sp === REH_NARRATOR_KEY) ? 'Narrator' : sp; - const libRec = sp === REH_NARRATOR_KEY ? null : libRecByNameStage.get(String(sp).trim().toLowerCase()); - // `sp` is the raw speaker key straight out of script parsing, which - // follows screenplay convention (ALL CAPS speaker tags) — rendering it - // verbatim meant every name in this sidebar showed shouting-case - // regardless of how it's actually spelled anywhere else in the app. - // Prefer the Library's own properly-cased name when there's a match; - // a plain per-word title-case is still better than shouting-case for - // the rarer speaker key with no Library record to match against. - const fallbackName = displayName === 'Narrator' ? displayName - : displayName.replace(/\w\S*/g, w => w[0].toUpperCase() + w.slice(1).toLowerCase()); - return { sp, displayName: libRec?.name || fallbackName, n: lineCountFor(sp), libRec }; - }); - if (query) rows = rows.filter(r => r.displayName.toLowerCase().includes(query)); - rows.sort((a, b) => sortMode === 'name' - ? a.displayName.localeCompare(b.displayName) - : (b.n - a.n) || a.displayName.localeCompare(b.displayName)); - - castStrip.innerHTML = rows.map(({ sp, displayName, n, libRec }) => { - const c = rehState.cast[sp], isMe = c.voice === 'me'; - const avatarHtml = libRec?.image - ? `` - : `${escHtml((displayName||'?')[0].toUpperCase())}`; - return `
- ${avatarHtml} - ${escHtml(displayName)} - ${isMe?'':''} - ${n} -
`; - }).join(''); - castStrip.querySelectorAll('.ab-char-item').forEach(item => { - item.addEventListener('click', () => { - const line = document.querySelector(`.reh-block[data-speaker="${CSS.escape(item.dataset.speaker)}"]`); - if (line) line.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }); - }); - const lbl = $('reh-cast-toggle-label'); - if (lbl) lbl.textContent = `Characters (${names.length})`; - - const searchInp = $('reh-cast-side-search'); - const sortSel = $('reh-cast-side-sort'); - if (searchInp && !searchInp.dataset.wired) { - searchInp.dataset.wired = '1'; - searchInp.addEventListener('input', () => renderCastStrip()); - } - if (sortSel && !sortSel.dataset.wired) { - sortSel.dataset.wired = '1'; - sortSel.value = sortMode; - sortSel.addEventListener('change', () => { localStorage.setItem('reh_cast_side_sort', sortSel.value); renderCastStrip(); }); - } -} - function buildScriptPage() { const titleEl = $('reh-page-title'); if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script'; diff --git a/static/module-manifest.json b/static/module-manifest.json index 972367e..14fc179 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"], - "s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"], - "s-library": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], - "s-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] + "s-rehearser": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage"], + "s-reader": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"], + "s-library": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"], + "s-caststudio": ["rehearser-parse", "rehearser", "rehearser-library", "rehearser-stage", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"] } }