refactor: extract rehearser stage controls

This commit is contained in:
mARTin-B78 2026-09-04 10:52:05 +02:00
parent 2712843322
commit e98c5ffe33
8 changed files with 118 additions and 135 deletions

View File

@ -8,6 +8,10 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Changed ### 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 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. `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 - **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 - **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 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. 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, - **P1 Roadmap status.** Documented the completed namespace/manifest foundations, section-scoped lazy loading,
while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation, durable background artifacts, provider-health consolidation, workload measurements, image-cache eviction, and
full workload measurements, and browser image-cache eviction explicitly open. the first Rehearser responsibility extraction; remaining frontend splits stay explicitly open.
### Added ### Added
- **Bounded Rehearser Audio Cache.** Decoded/synthesized line audio now uses a 128-entry LRU cache so long scripts - **Bounded Rehearser Audio Cache.** Decoded/synthesized line audio now uses a 128-entry LRU cache so long scripts

View File

@ -116,6 +116,7 @@ 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.
- [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.
- [x] Generate the feature-module list for `loader.js` and `scripts/minify.mjs` from one shared manifest; - [x] Generate the feature-module list for `loader.js` and `scripts/minify.mjs` from one shared manifest;

View File

@ -1 +1 @@
1.24.24 1.24.25

View File

@ -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: 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, 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-train.js` — mic record / STT compare.
- ✅ `rehearser-library.js` — SQLite persistence helpers and IndexedDB migration; import/export and auto-save remain - ✅ `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. in `rehearser.js` until their UI/event-handler dependencies are isolated.

View File

@ -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.21"> <meta name="app-version" content="1.24.25">
<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,7 +27,7 @@
<!-- ── 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.21"> <link rel="stylesheet" href="/static/style.css?v=1.24.25">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -392,7 +392,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.21"></script> <script src="/static/loader.js?v=1.24.25"></script>
</body> </body>
</html> </html>

View File

@ -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
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${c.color}"><img src="${libRec.image}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${c.color}">${escHtml((displayName||'?')[0].toUpperCase())}</span>`;
return `<div class="ab-char-item" data-speaker="${escHtml(sp)}" title="${escHtml(displayName)} — ${isMe?'me':(c.voice||'no voice')}">
${avatarHtml}<span class="ab-char-name">${escHtml(displayName)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:11px;color:var(--subtext)"></span>':''}
<b class="ab-char-count">${n}</b></div>`;
}).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);

View File

@ -2462,129 +2462,6 @@ $('reh-start-btn')?.addEventListener('click', () => {
highlightCurrentLine(); 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
? `<span class="ab-char-dot ab-char-dot-img" style="border-color:${c.color}"><img src="${libRec.image}" alt=""></span>`
: `<span class="ab-char-dot" style="background:${c.color}">${escHtml((displayName||'?')[0].toUpperCase())}</span>`;
return `<div class="ab-char-item" data-speaker="${escHtml(sp)}" title="${escHtml(displayName)} — ${isMe?'me':(c.voice||'no voice')}">
${avatarHtml}
<span class="ab-char-name">${escHtml(displayName)}</span>
${isMe?'<span class="mdi mdi-microphone" style="font-size:11px;color:var(--subtext)"></span>':''}
<b class="ab-char-count">${n}</b>
</div>`;
}).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() { function buildScriptPage() {
const titleEl = $('reh-page-title'); const titleEl = $('reh-page-title');
if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script'; if (titleEl) titleEl.textContent = $('reh-script-title')?.value.trim() || 'Script';

View File

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