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]
### 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

View File

@ -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;

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:
- ✅ `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.

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.21">
<meta name="app-version" content="1.24.25">
<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.21">
<link rel="stylesheet" href="/static/style.css?v=1.24.25">
<!-- ── 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.21"></script>
<script src="/static/loader.js?v=1.24.25"></script>
</body>
</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();
});
// ── 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() {
const titleEl = $('reh-page-title');
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"
],
"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"]
}
}