// ── 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. let _stuActive = 1; // 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(); } // 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) { _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 () => { 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 (_) {} } window._libCharsScrollToBook = title || (window.readerState && readerState.title) || 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'); _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; } 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 = '
' + '
Character sheets
' + '

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.

' + '' + '
'; 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); }); }); // 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; function _stuRelocateCastFoot() { _stuTryRelocateCastFoot(); if (_stuCastFootObserver) return; const slot = document.getElementById('stu-cast-slot'); if (!slot) return; _stuCastFootObserver = new MutationObserver(function () { _stuTryRelocateCastFoot(); }); _stuCastFootObserver.observe(slot, { childList: true, subtree: true }); } 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') : null; const alreadyRelocated = tabs.querySelector('#ab-cv-foot'); // 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. if (!freshFoot && !alreadyRelocated) { document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = false; }); return; } if (!freshFoot || freshFoot.parentElement === tabs) return; tabs.querySelectorAll('#ab-cv-foot').forEach(function (stale) { stale.remove(); }); freshFoot.style.borderTop = 'none'; freshFoot.style.padding = '0'; freshFoot.style.justifyContent = 'flex-start'; tabs.appendChild(freshFoot); document.querySelectorAll('#stu-cast-inner-tabs > .stu-inner-tab').forEach(function (t) { t.hidden = true; }); 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; } 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; 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; showStudioPhase(_stuActive); } window.studioOnShow = studioOnShow;