Introduces the new Studio section (Source -> Characters -> Voices -> Perform & Export) that reuses the existing Read Aloud/Library/Script Rehearsal code via DOM reparenting instead of duplicating it, and rolls up a long tail of bugs found while producing a real audiobook through it: umlaut-eating name sanitizers, a voice picker that mispositioned itself and capped results at 60, PDF pagination silently breaking on trimmed \f markers, a race letting stale audio keep playing after a new line was clicked, an alias-overlap bug that could silently redirect a voice/image save onto the wrong character, voice design failing outright during brief TTS backend restarts instead of retrying, sparse cast entries defaulting to English/wrong gender, and a reassigned voice never reaching an already-open Stage session or invalidating its cached audio. Also adds a persistent per-line audio cache, audiobook export browsing/download, and an inline voice-design prompt editor. Full details in CHANGELOG.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
421 lines
22 KiB
JavaScript
421 lines
22 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.
|
|
|
|
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 = '<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); });
|
|
});
|
|
|
|
// 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;
|