tts-voice-creator-clone-and.../static/js/studio.js
2026-10-03 22:29:42 +02:00

662 lines
34 KiB
JavaScript

// ── Studio ────────────────────────────────────────────────────────────────
// A unified 4-phase view (Source → Characters → Voices → Perform & Export)
// over the same PDF-to-audiobook pipeline that Read Aloud (s-reader) and
// Script Rehearser (s-rehearser) already implement. Studio is a thin shell:
// it borrows the DOM subtrees those sections already render into (and that
// reader.js/audiobook.js/rehearser.js/character-sheets.js/library-characters.js
// already attach their listeners to) via runtime reparenting, rather than
// duplicating any business logic. See the Studio build plan for the full
// rationale — in short: every section's HTML is present in the live DOM at
// all times (loader.js injects all of them up front), so a second COPY of
// the same element ids would collide; physically relocating the existing
// node (same id, same listeners) does not.
(function (root) {
const STUDIO_RESUME_KEY = 'ttsvc_studio_resume';
let _stuActive = 1;
function _stuReadResume() {
try { return JSON.parse(localStorage.getItem(STUDIO_RESUME_KEY) || '{}') || {}; }
catch (_) { return {}; }
}
function _stuSaveResume(phase) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: phase,
bookId: window._audiobook?.bookId || window.readerState?.savedId || previous.bookId || '',
bookTitle: window.readerState?.title || previous.bookTitle || '',
characterId: previous.characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
}
window.studioRememberBook = function (bookId, title) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: _stuActive || previous.phase || 1,
bookId: bookId || previous.bookId || '',
bookTitle: title || previous.bookTitle || '',
characterId: previous.characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
};
window.studioRememberCharacter = function (characterId) {
try {
const previous = _stuReadResume();
localStorage.setItem(STUDIO_RESUME_KEY, JSON.stringify({
phase: previous.phase || _stuActive || 3,
bookId: previous.bookId || window._audiobook?.bookId || '',
bookTitle: previous.bookTitle || window.readerState?.title || '',
characterId: characterId || '',
savedAt: Date.now(),
}));
} catch (_) {}
};
// id -> { parent, next } — where a borrowed node came from, so it can go home.
const _stuHomes = new Map();
// Moves the real DOM node `id` into `slotId`. Safe to call repeatedly; only
// records the original location once (first borrow), so returning always
// goes back to the true original spot even after several borrow/re-borrows.
function _stuBorrow(id, slotId) {
const el = document.getElementById(id);
const slot = document.getElementById(slotId);
if (!el || !slot) return;
if (!_stuHomes.has(id)) _stuHomes.set(id, { parent: el.parentNode, next: el.nextSibling });
slot.appendChild(el);
}
// Returns every currently-borrowed node to its original parent/position.
// Called unconditionally whenever navigation leaves Studio (see nav.js),
// so any other section that owns one of these subtrees always finds it
// back in place before that section's own onShow logic runs.
function _stuReturnAll() {
_stuIsActive = false;
if (typeof _stuRestoreCastFoot === 'function') _stuRestoreCastFoot();
_stuHomes.forEach(function (home, id) {
const el = document.getElementById(id);
if (el && home.parent) home.parent.insertBefore(el, home.next);
});
_stuHomes.clear();
}
window._stuReturnAll = _stuReturnAll;
// Borrowed entry points scattered across reader.js/audiobook.js/rehearser.js
// call navTo('s-reader')/navTo('s-library')/navTo('s-rehearser') themselves
// as a "make sure the right section is showing" safety measure — reasonable
// when invoked from those sections directly, but while Studio is the one
// orchestrating the call it would snap the user away from Studio (and, via
// nav.js's return-on-leave hook, immediately un-borrow whatever was just
// relocated there). These calls come from all over — a click handler wired
// at panel-render time (library.js's book cards), deep inside a multi-second
// PDF parse (readerOpenLibraryDoc), or buried in a multi-passage LLM casting
// pipeline that can run for minutes (audiobookCast's "Continue casting") —
// confirmed live, each one surfaced only after chasing and patching the
// previous one individually, which doesn't scale to arbitrary future call
// sites the same way.
//
// Instead of chasing each function, gate on DESTINATION: while Studio is the
// user's current section, any navTo call targeting one of the three sections
// Studio borrows from is presumed to be one of these internal "stay on my
// home section" reflexes and is dropped — UNLESS the user just clicked that
// section's own OLD sidebar entry, which is unambiguous real intent to leave
// (tracked as a one-shot allow flag set by a capture-phase listener on those
// specific sidebar links, so it can never itself be the thing that needs
// suppressing). This covers every current and future delayed/nested
// call site uniformly, with no timing window to get wrong.
let _stuIsActive = false;
let _stuAllowNextNav = false;
const _STU_BORROWED_FROM = { 's-reader': 1, 's-library': 1, 's-rehearser': 1 };
// Installed lazily (on first entry to Studio) rather than at script-load time:
// nav.js loads AFTER studio.js in loader.js's batch order and unconditionally
// does `window.navTo = function (sectionId) {...}` — patching eagerly here
// got silently clobbered the instant nav.js finished loading, since that
// happens strictly later (confirmed live: the wrapper was installed, then
// overwritten before the user could ever click anything). By the time
// studioOnShow() first runs, the whole page (including nav.js) has finished
// loading, so window.navTo is guaranteed to already be its real, final self.
let _stuNavGuardInstalled = false;
function _stuInstallNavGuardOnce() {
if (_stuNavGuardInstalled) return;
_stuNavGuardInstalled = true;
const realNavTo = window.navTo;
window.navTo = function (id) {
if (_stuIsActive && !_stuAllowNextNav && _STU_BORROWED_FROM[id]) return; // internal reflex, ignored
_stuAllowNextNav = false;
return realNavTo(id);
};
// showReaderView('cast'|'chars') toggles .hidden directly on the real
// reader-audiobook-panel/reader-charsheets-panel elements — but Studio
// shows/hides those same (borrowed) elements one level up, via its OWN
// slot visibility (_stuShowCastView). Any code that calls showReaderView
// directly (csForReader's "New recast", cast-from-rehearser, the emotion
// dialogue's fallback nav, ...) — not just navTo — can flip the panel
// visible while Studio's slot around it is still hidden, or vice versa.
// Confirmed live: clicking "New recast (discard & rebuild all)" while
// Studio's Characters tab was on "Identify" left the ENTIRE tab blank —
// the panel showReaderView just unhid (charsheets, with the live
// progress UI) sat inside a slot Studio still had hidden, while the
// slot the user could actually see had just had its own content hidden.
// Keep Studio's own tab UI in lockstep whenever this happens, on top of
// whatever the real function already does.
const realShowReaderView = window.showReaderView;
let _stuInShowReaderView = false;
window.showReaderView = function (view) {
const result = typeof realShowReaderView === 'function' ? realShowReaderView(view) : undefined;
// INFINITE RECURSION, confirmed live (renderer pegged at steady high CPU,
// then crashing/going blank): audiobookCastView() itself calls
// navReaderView('cast') -> this wrapper -> _stuShowCastView('identify')
// -> audiobookOpenCastView() -> audiobookCastView() again -> forever.
// Two guards: never re-enter this wrapper while already inside a call it
// triggered, AND skip entirely if Studio's sub-tab already matches (the
// overwhelmingly common case — most showReaderView('cast') calls are
// just the underlying code re-asserting a view that's already showing).
if (_stuIsActive && !_stuInShowReaderView && (view === 'cast' || view === 'chars')) {
const target = view === 'chars' ? 'sheets' : 'identify';
if (_stuCastView !== target) {
_stuInShowReaderView = true;
try { _stuShowCastView(target); } finally { _stuInShowReaderView = false; }
}
}
return result;
};
// Sub-items (Casting, Stage, Summary, Books, Theater Plays, Cast, …) call
// navTo for the same section but don't carry data-nav-section themselves —
// only their parent head does — so cover each section's whole nav-tree,
// not just its head.
document.querySelectorAll(
'[data-nav-section="s-reader"], #nav-reader-tree, ' +
'[data-nav-section="s-library"], #nav-library-tree, ' +
'[data-nav-section="s-rehearser"], #nav-rehearser-tree'
).forEach(function (el) {
el.addEventListener('click', function () { _stuAllowNextNav = true; }, true);
});
}
// Kept as a thin no-op-friendly shim: callers elsewhere in this file already
// wrap borrowed function calls with this name; with the persistent guard
// above, no actual patch/restore is needed per call, so this now just runs
// the function.
function _stuCallSuppressingNav(fn) { return fn(); }
// Title of the book Studio is working on ('' when none is picked).
async function _stuResolveBookTitle() {
let title = null;
if (window._audiobook && window._audiobook.bookId) {
try {
const r = await fetch('/api/reader/docs/' + encodeURIComponent(window._audiobook.bookId));
if (r.ok) { const doc = await r.json(); title = doc.title || null; }
} catch (_) {}
}
return title || (window.readerState && window.readerState.title) || '';
}
// Header chip on the Characters step: which book this step works on.
async function _stuUpdateBookChip() {
const row = document.getElementById('stu-book-chip-row');
if (!row) return;
const title = await _stuResolveBookTitle();
row.textContent = '';
const chip = document.createElement('span');
chip.className = 'lib-chars-book-chip';
chip.id = 'stu-book-chip';
const icon = document.createElement('span');
icon.className = 'mdi mdi-book-open-page-variant-outline';
icon.setAttribute('aria-hidden', 'true');
chip.appendChild(icon);
chip.appendChild(document.createTextNode(title ? ' Book: ' : ' No book picked yet '));
if (title) { const b = document.createElement('b'); b.textContent = title; chip.appendChild(b); }
row.appendChild(chip);
const mk = function (id, label, fn) {
const btn = document.createElement('button');
btn.type = 'button'; btn.className = 'btn-secondary btn-sm'; btn.id = id; btn.textContent = label;
btn.addEventListener('click', fn); row.appendChild(btn);
};
if (!title) mk('stu-pick-book-btn', 'Pick a book', function () { showStudioPhase(1); });
mk('stu-show-all-chars-btn', 'Show characters of all books', function () { window.studioShowAllCharacters(); });
}
// Leave the per-book Studio view for the global cast view (Library > Characters / Cast).
// Studio's nav guard drops internal navTo('s-library') calls, so allow this one.
window.studioShowAllCharacters = function () {
_stuAllowNextNav = true;
if (typeof window.navLibraryView === 'function') window.navLibraryView('characters');
else if (typeof window.navTo === 'function') window.navTo('s-library');
};
// Per-phase borrow wiring — called by showStudioPhase whenever a phase
// becomes active. Each case borrows the real subtree that section already
// renders into/wires listeners on; no logic is duplicated here.
function _stuEnterPhase(n) {
if (n === 1) {
_stuBorrow('reader-main-view', 'stu-source-slot');
if (typeof window.readerOnShow === 'function') _stuCallSuppressingNav(window.readerOnShow);
_stuBorrow('lib-books-list', 'stu-books-slot');
_stuCallSuppressingNav(function () {
if (typeof window.libraryRenderBooks === 'function') window.libraryRenderBooks();
});
} else if (n === 2) {
_stuUpdateBookChip();
_stuShowCastView(_stuCastView);
} else if (n === 3) {
_stuBorrow('lib-chars-list', 'stu-voices-slot');
// Scope the grid to the current book, same mechanism the old "Assign
// Voices" WF_STEPS stop already uses (utils.js). readerState.title can
// go stale — it's left pointing at whichever book was last opened via
// readerOpenLibraryDoc, which isn't necessarily the book whose cast is
// actually active (confirmed live: navigating here from a book 2 "View
// cast" action expanded book 1's section instead, because readerState
// still pointed at book 1 from earlier in the session). _audiobook.bookId
// is the field audiobookOpenCastView() actually keeps in sync with
// whichever book's cast session is active, so resolve the title from
// that via the server instead of trusting readerState.title first.
(async () => {
// Studio works inside ONE book: the Voices step lists only that book's
// characters (library-characters.js reads _libCharsStudioBook while the
// list is borrowed into this slot; '' shows the "pick a book" prompt).
const title = await _stuResolveBookTitle();
window._libCharsStudioBook = title || '';
window._libCharsScrollToBook = null;
_stuCallSuppressingNav(function () {
if (typeof window.libraryRender === 'function') window.libraryRender('characters');
});
})();
} else if (n === 4) {
_stuBorrow('reh-phase-3', 'stu-stage-slot');
_stuBorrow('reh-cast-list', 'stu-mecast-slot');
// #reh-tone-warn is a descendant of #reh-phase-3, already moved above —
// pull just this one element back out into the toggle row's own spare
// width instead of leaving it to render as its own full-width banner
// further down. Order matters: this must run after the #reh-phase-3
// borrow above, since that's what puts #reh-tone-warn somewhere this
// second, more specific borrow can find and re-extract it from.
if (typeof _abInitPauseUI === 'function') _abInitPauseUI();
// A stacked-sticky "toggle above the transport bar" version of this was
// tried and reverted — confirmed live to break scrolling entirely on a
// real window (the wrapped multi-row toolbar plus a second sticky header
// above it could exceed viewport height, leaving nothing scrollable
// visible at all). Needs isolated testing with the actual wrapped
// toolbar height before trying again, not a same-turn redeploy.
_stuCallSuppressingNav(async function () {
if (window.rehState && rehState.lines && rehState.lines.length) {
if (typeof buildScriptPage === 'function') buildScriptPage();
if (typeof showPhase === 'function') showPhase(3);
if (typeof highlightCurrentLine === 'function') highlightCurrentLine();
return;
}
// _audiobook.segments is a purely in-memory cache — it's only ever
// populated by actually visiting the Characters phase first (which
// triggers audiobookOpenCastView()'s own draft-restore). Jumping
// straight to Perform & Export in a fresh session (confirmed live)
// left it empty even though a perfectly good, fully-cast draft was
// sitting on the server the whole time — Stage just rendered blank
// instead of loading it. Fetch it directly rather than requiring a
// detour through Characters first.
if (!(window._audiobook && window._audiobook.segments && window._audiobook.segments.length)
&& typeof _abLoadDraftServer === 'function' && typeof _abBookId === 'function') {
const bookId = _abBookId();
const draft = bookId ? await _abLoadDraftServer(bookId) : null;
if (draft) {
_audiobook.segments = draft.segments || [];
_audiobook.roster = draft.roster || [];
_audiobook.pageMarks = draft.pageMarks || [];
_audiobook.rehId = draft.rehId || _audiobook.rehId || null;
}
}
if (typeof window.audiobookOpenCurrentInRehearser === 'function' && (window._audiobook && window._audiobook.segments || []).length) {
return window.audiobookOpenCurrentInRehearser();
}
});
// audiobookOpenCurrentInRehearser's own navTo('s-rehearser') is now
// suppressed, but its DOM effects (rehLoadRecord -> renderCastList,
// buildScriptPage, showPhase(3)) still ran against the real elements —
// just re-assert reh-phase-3 isn't left hidden after all that.
const rp3 = document.getElementById('reh-phase-3');
if (rp3) rp3.hidden = false;
_stuSyncModeToggle();
}
}
// The Rehearse ⇄ Audiobook toggle — reads/writes the exact same rehState
// fields the old Script Rehearser toolbar already exposes (#reh-skip-desc-toggle,
// cast[sp].voice==='me'); no new synthesis/export codepath.
function _stuSyncModeToggle() {
const cb = document.getElementById('stu-mode-audiobook');
const details = document.getElementById('stu-mecast-details');
if (!cb || !window.rehState) return;
cb.checked = !rehState.skipDescriptions;
if (details) details.hidden = cb.checked;
}
document.getElementById('stu-mode-audiobook')?.addEventListener('change', function () {
const audiobookMode = this.checked;
if (window.rehState) {
rehState.skipDescriptions = !audiobookMode;
const t = document.getElementById('reh-skip-desc-toggle');
if (t) t.checked = rehState.skipDescriptions;
// The toggle only controls WHETHER narration is voiced, not with what —
// flipping it on with no narrator voice assigned looked identical to it
// doing nothing at all. Point at the fix instead of failing silently.
if (audiobookMode && !rehState.narratorVoice && typeof toast === 'function') {
toast('No narrator voice assigned yet — assign one in the Voices phase so narration actually plays', 'error');
}
}
const details = document.getElementById('stu-mecast-details');
if (details) details.hidden = audiobookMode;
});
// Phase 2 (Characters) has its own two sub-views, mirroring Read Aloud's
// existing 'cast' (speaker attribution) and 'chars' (character sheets)
// views — same panels, same entry points (audiobookOpenCastView/csForReader),
// just borrowed into Studio's slots instead of shown in place.
let _stuCastView = 'identify';
function _stuShowCastView(view) {
_stuCastView = view;
document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(function (t) {
t.classList.toggle('active', t.dataset.stuCastView === view);
});
const identifySlot = document.getElementById('stu-cast-slot');
const sheetsSlot = document.getElementById('stu-castchars-slot');
if (identifySlot) identifySlot.hidden = (view !== 'identify');
if (sheetsSlot) sheetsSlot.hidden = (view !== 'sheets');
if (view === 'identify') {
_stuBorrow('reader-audiobook-panel', 'stu-cast-slot');
_stuCallSuppressingNav(function () {
if (typeof window.audiobookOpenCastView === 'function') return window.audiobookOpenCastView();
else if (typeof window.showReaderView === 'function') window.showReaderView('cast');
});
_stuRelocateCastFoot();
} else if (view === 'sheets') {
_stuBorrow('reader-charsheets-panel', 'stu-castchars-slot');
const panel = document.getElementById('reader-charsheets-panel');
// This tab is the old page's WF_STEPS step 3 ("Cast Characters ·
// optional — let the AI fill out full character profiles"), i.e. the
// passage-by-passage PROFILE-generation pass (appearance/backstory/voice
// notes, live "Passage N / M…" progress) — a completely different thing
// from the dialogue SPEAKER-attribution pass on the Identify Characters
// tab. An earlier version of this empty-state wrongly pointed at that
// other pass's menu instead. The real entry point is `csForReader()`
// (character-sheets.js) — it locates its own render target via
// `csReaderPageHost()` (`$('reader-charsheets-panel')`), so calling it
// with no args renders inline into wherever this panel currently lives,
// borrowed or not.
if (panel && !panel.innerHTML.trim()) {
panel.innerHTML = '<div class="card" style="padding:24px; min-height:220px; display:flex; flex-direction:column; gap:12px; justify-content:center; align-items:flex-start;">'
+ '<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>'
+ '<p class="card-subtitle" style="margin:0;">Optional — let the AI fill out full character profiles (appearance, backstory, voice notes) for reference. Skip this if you just want to cast voices quickly.</p>'
+ '<button class="btn-primary btn-sm" id="stu-goto-cast-menu"><span class="mdi mdi-creation"></span> Generate character sheets</button>'
+ '</div>';
const goBtn = document.getElementById('stu-goto-cast-menu');
if (goBtn) goBtn.addEventListener('click', function () {
if (typeof window.csForReader === 'function') window.csForReader();
});
}
}
}
document.querySelectorAll('#stu-cast-inner-tabs .stu-inner-tab').forEach(function (tab) {
tab.addEventListener('click', function () { _stuShowCastView(tab.dataset.stuCastView); });
});
// Keep book context editing in the compact Studio toolbar. The context is
// deliberately available before identification, without consuming a full
// banner row inside the casting panel.
document.getElementById('stu-book-context-btn')?.addEventListener('click', async function () {
const bookTitle = window.readerState?.title || '';
if (!bookTitle || typeof window.openBookContextEditor !== 'function') return;
await window.openBookContextEditor(bookTitle);
});
document.getElementById('stu-change-book-btn')?.addEventListener('click', function () {
// The Source phase contains the existing uploaded-book cards. Keep the
// current production intact until the user explicitly chooses another.
showStudioPhase(1);
});
// Reader hides #reader-dropzone/#reader-import-area once a document loads
// (see reader.js's readerFinishLoadedDocument/readerOpenLibraryDoc) — correct
// for its own single-document flow, but leaves Studio with no way to add a
// book beyond whichever one auto-resumed. Reveal them again on demand; the
// actual import (readerImportFile/readerImportPastedText) resets the loaded
// document itself once the user picks a file or pastes text.
// Lives in the tab row so it's reachable from any phase — jump to Source
// first (showStudioPhase borrows #reader-main-view into the DOM if it
// hasn't been already) before revealing the controls inside it.
document.getElementById('stu-add-book-btn')?.addEventListener('click', function () {
showStudioPhase(1);
const dz = document.getElementById('reader-dropzone'); if (dz) dz.hidden = false;
const importArea = document.getElementById('reader-import-area'); if (importArea) importArea.hidden = false;
const card = document.getElementById('reader-config-card');
if (card) {
const body = card.querySelector('.card-col-body');
if (body && body.hidden) { body.hidden = false; card.classList.remove('card-col-closed'); }
card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
// The borrowed panel's own footer (#ab-cv-foot: "Identify Characters ⌄" /
// "Cast Characters ⌄" / "Cast ⌄") is a richer, real action bar (Scan/Verify,
// Cast all/Continue uncasted/Cast selected/recast, view/export) that Studio's
// plain Identify/Cast Characters tabs above just duplicated by label without
// offering any of that. Its flyout menus are DOM-position-independent
// (_abToggleFootMenu in audiobook.js appends to document.body and positions
// via the trigger button's own getBoundingClientRect, not any ancestor), so
// the buttons can be physically relocated up next to Studio's own tabs
// instead of living in a separate bar at the bottom. "Open Script
// Rehearser" is dropped — Studio already has its own Perform & Export tab.
//
// audiobookCastView() does a full `panel.innerHTML = ...` on EVERY call —
// not just the first time the view opens, but also after in-place edits
// (e.g. "Split text to Unknown Speaker") and casting-progress updates — so a
// fresh #ab-cv-foot (same id) reappears inside the panel each time. A single
// poll-and-move only caught the first rebuild; confirmed live that a later
// rebuild left a second, un-relocated, un-hidden footer (with "Open Script
// Rehearser" still showing) sitting back at the bottom. Watch for it instead
// of relocating once.
let _stuCastFootObserver = null;
let _stuCastFootHealthTimer = null;
// The Studio controls must never depend on the borrowed audiobook footer.
// audiobookCastView rebuilds that footer during several async transitions,
// which previously left this toolbar empty because the plain Studio buttons
// had already been hidden. The only time they may be hidden is while a cast
// job is actually running; in that state the borrowed footer exposes Cancel.
function _stuCastIsRunning() {
return typeof _audiobook !== 'undefined' && !!_audiobook.running;
}
function _stuSyncCastFallbackControls(tabs, innerTabs, hasActionFooter) {
const running = _stuCastIsRunning();
tabs.hidden = false;
tabs.style.display = 'flex';
innerTabs.forEach(function (tab) {
// The real three-menu action bar replaces these two simple navigation
// buttons only after its completed-cast actions exist. Until then they
// remain the reliable fallback, rather than leaving an empty toolbar.
const hideFallback = running || hasActionFooter;
tab.hidden = hideFallback;
tab.style.display = hideFallback ? 'none' : '';
});
}
function _stuRelocateCastFoot() {
_stuTryRelocateCastFoot();
if (_stuCastFootObserver) return;
// The audiobook panel is rebuilt asynchronously and can briefly be moved
// through its native reader container before Studio's borrow completes.
// Observe the document, not only the slot, so a freshly-created footer is
// never stranded outside Studio's visible action strip.
_stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); });
_stuCastFootObserver.observe(document.body, { childList: true, subtree: true });
[0, 100, 300, 800, 1500].forEach(ms => setTimeout(_stuTryRelocateCastFoot, ms));
// Async restore/recast paths can replace the footer without producing a
// useful childList mutation in the Studio slot. Keep a lightweight health
// check while Studio is active so the toolbar can never remain blank.
_stuCastFootHealthTimer = setInterval(function () {
if (_stuIsActive && _stuActive === 2) _stuTryRelocateCastFoot();
}, 500);
}
function _stuTryRelocateCastFoot() {
const slot = document.getElementById('stu-cast-slot');
const tabs = document.getElementById('stu-cast-inner-tabs');
if (!tabs) return;
const freshFoot = (slot && slot.querySelector('#ab-cv-foot')) || document.querySelector('#ab-cv-foot');
const innerTabs = Array.from(tabs.querySelectorAll(':scope > .stu-inner-tab'));
// Do not take the footer while audiobookCastView is still building it.
// Its initial Cancel/placeholder buttons are replaced asynchronously by
// the completed-cast Identify / Cast Characters / Cast menus. Relocating
// that early steals the node from view.complete(), so its submenu buttons
// can never be installed and Studio ends up with only the fallback tabs.
const footerHasActions = freshFoot && freshFoot.querySelector('#ab-cv-menu-identify, #ab-cv-menu-cast, #ab-cv-menu-viewcast');
_stuSyncCastFallbackControls(tabs, innerTabs, !!footerHasActions);
// Transient states (e.g. "Checking saved cast before starting a new
// one…") rebuild the panel WITHOUT a footer at all — confirmed live: the
// plain tabs stayed hidden from an earlier relocation with nothing having
// replaced them, leaving no navigation visible whatsoever. Fall back to
// showing the plain tabs any time there's genuinely no footer to relocate
// (neither a fresh one nor an already-relocated one), rather than only
// ever hiding them and never re-checking.
// The footer is intentionally empty during some async rebuild phases.
// Never hide the usable Studio buttons while that happens.
if (!freshFoot || !footerHasActions) {
return;
}
// A previous relocation can leave the footer in the toolbar but hidden
// after audiobookCastView rebuilds its own panel. Always restore its
// visibility when it is already in Studio's toolbar.
if (freshFoot.parentElement === tabs) {
freshFoot.hidden = false;
freshFoot.style.display = 'flex';
return;
}
tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); });
tabs.hidden = false;
tabs.style.display = 'flex';
freshFoot.style.borderTop = 'none';
freshFoot.style.padding = '0';
freshFoot.style.justifyContent = 'flex-start';
freshFoot.hidden = false;
freshFoot.style.display = 'flex';
tabs.appendChild(freshFoot);
const openReh = freshFoot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = true;
}
// The footer isn't a whole-panel borrow tracked by _stuHomes (only the
// bigger #reader-audiobook-panel is), so leaving Studio would otherwise
// carry the relocated node's origin nowhere — the old Read Aloud page would
// come back missing its own footer. Called from _stuReturnAll before the
// panel itself goes home, so the footer rides back inside it intact.
function _stuRestoreCastFoot() {
if (_stuCastFootObserver) { _stuCastFootObserver.disconnect(); _stuCastFootObserver = null; }
if (_stuCastFootHealthTimer) { clearInterval(_stuCastFootHealthTimer); _stuCastFootHealthTimer = null; }
const tabs = document.getElementById('stu-cast-inner-tabs');
const panel = document.getElementById('reader-audiobook-panel');
const foot = tabs ? tabs.querySelector('#ab-cv-foot') : null;
if (foot && panel) {
foot.style.borderTop = '';
foot.style.padding = '';
foot.style.justifyContent = '';
const openReh = foot.querySelector('#ab-cv-open-reh');
if (openReh) openReh.hidden = false;
panel.appendChild(foot);
}
document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; });
}
function showStudioPhase(n) {
_stuActive = n;
_stuSaveResume(n);
for (let i = 1; i <= 4; i++) {
const el = document.getElementById('stu-phase-' + i);
if (el) el.hidden = (i !== n);
}
document.querySelectorAll('.stu-subtab').forEach(function (tab) {
tab.classList.toggle('active', parseInt(tab.dataset.stuPhase, 10) === n);
});
document.querySelectorAll('#nav-caststudio-tree [data-stu-phase]').forEach(function (item) {
item.classList.toggle('is-active', parseInt(item.dataset.stuPhase, 10) === n);
});
const prevBtn = document.getElementById('stu-phase-prev'), nextBtn = document.getElementById('stu-phase-next');
if (prevBtn) prevBtn.disabled = (n <= 1);
if (nextBtn) nextBtn.disabled = (n >= 4);
if (typeof _stuEnterPhase === 'function') _stuEnterPhase(n);
}
window.showStudioPhase = showStudioPhase;
document.querySelectorAll('.stu-subtab').forEach(function (tab) {
tab.addEventListener('click', function () {
showStudioPhase(parseInt(tab.dataset.stuPhase, 10));
});
});
document.getElementById('stu-phase-prev')?.addEventListener('click', function () { if (_stuActive > 1) showStudioPhase(_stuActive - 1); });
document.getElementById('stu-phase-next')?.addEventListener('click', function () { if (_stuActive < 4) showStudioPhase(_stuActive + 1); });
// Called once when Studio becomes the active section (see nav.js showSection).
function studioOnShow() {
// We're here because nav.js's navTo()/showSection() called us — meaning
// window.navTo is already nav.js's real, final function no matter what
// order scripts loaded in, so it's safe to capture it now.
_stuInstallNavGuardOnce();
// Set BEFORE showStudioPhase/_stuEnterPhase run — those synchronously
// borrow subtrees and call into reader.js/audiobook.js code that may
// itself call navTo, and the guard above needs _stuIsActive true for
// that first entry too, not just on subsequent clicks once Studio's own
// .is-active class has actually been applied by nav.js (which happens
// AFTER this hook runs).
_stuIsActive = true;
const resume = _stuReadResume();
if (resume.bookId && window._audiobook) window._audiobook.bookId = resume.bookId;
if (resume.phase >= 1 && resume.phase <= 4) {
_stuActive = resume.phase;
if (resume.characterId) window._libResumeCharacterId = resume.characterId;
}
// A hard refresh clears the in-memory reader document. Reopen the book
// remembered by Studio before entering Characters, otherwise the cast view
// incorrectly reports “Import a document first”.
const resumeBookId = resume.bookId;
const hasCurrentBook = !!(window.readerState?.sentences?.length && window.readerState?.savedId);
if (resumeBookId && !hasCurrentBook && typeof window.readerOpenLibraryDoc === 'function') {
// Show the Source tab (paste/dropzone + the reload's own progress pill)
// right away instead of leaving it blank while the resumed book reloads
// in the background — #reader-main-view only lives in the visible DOM
// once borrowed, and large PDFs (with OCR headings) can take a while
// (confirmed live: a 297-page book left the tab empty for 6+ seconds).
showStudioPhase(_stuActive);
window.readerOpenLibraryDoc(resumeBookId).then(function () {
showStudioPhase(_stuActive);
});
return;
}
showStudioPhase(_stuActive);
}
window.studioOnShow = studioOnShow;
Object.assign(root, {
STUDIO_RESUME_KEY, _stuActive, _stuReadResume, _stuSaveResume, _stuHomes,
_stuBorrow, _stuReturnAll, _stuIsActive, _stuAllowNextNav, _STU_BORROWED_FROM,
_stuNavGuardInstalled, _stuInstallNavGuardOnce, _stuCallSuppressingNav,
_stuEnterPhase, _stuSyncModeToggle, _stuCastView, _stuShowCastView,
_stuCastFootObserver, _stuCastFootHealthTimer, _stuCastIsRunning,
_stuSyncCastFallbackControls, _stuRelocateCastFoot, _stuTryRelocateCastFoot,
_stuRestoreCastFoot, showStudioPhase, studioOnShow,
});
root.TTVC = root.TTVC || {};
root.TTVC.studio = root.TTVC.studio || {};
Object.assign(root.TTVC.studio, {
state: { get activePhase() { return _stuActive; }, get active() { return _stuIsActive; } },
showPhase: showStudioPhase,
onShow: studioOnShow,
});
})(window);