refactor: extract rehearser stage controls
This commit is contained in:
parent
2712843322
commit
e98c5ffe33
10
CHANGELOG.md
10
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
|
||||
|
||||
1
TODO.md
1
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;
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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>
|
||||
|
||||
101
static/js/rehearser-stage.js
Normal file
101
static/js/rehearser-stage.js
Normal 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);
|
||||
@ -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';
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user