// ── Combined Library — Books · Theater Plays · Characters/Cast ──────────────── // // One home for every production. Books live server-side (reader_library), // plays live client-side (IndexedDB reh-library), characters live client-side // (IndexedDB character-library). They are joined by their normalized title — // see prodKey() — so a book and a play with the same title are one production // and share the character cast. This module owns the s-library section and // provides the cross-links that open a production in Read Aloud or the Rehearser. const LIB_READER_API = '/api/reader/docs'; // The universal join key across books, plays and characters (mirrors clKey). function prodKey(title) { return String(title || '').trim().toLowerCase(); } window._libraryView = (function () { try { return localStorage.getItem('ttsvc_library_view') || 'books'; } catch (_) { return 'books'; } })(); window.navLibraryView = function (view) { if (typeof navTo === 'function') navTo('s-library'); window._libraryView = view; try { localStorage.setItem('ttsvc_library_view', view); } catch (_) {} document.querySelectorAll('[data-library-view]').forEach(function (el) { el.classList.toggle('is-active', el.dataset.libraryView === view); }); document.querySelectorAll('[data-library-panel]').forEach(function (el) { el.classList.toggle('is-active', el.dataset.libraryPanel === view); }); libraryRender(view); }; window.libraryRender = function (view) { view = view || window._libraryView || 'books'; // Reflect the active tab/panel even when entered via the section hook document.querySelectorAll('[data-library-view]').forEach(function (el) { el.classList.toggle('is-active', el.dataset.libraryView === view); }); document.querySelectorAll('[data-library-panel]').forEach(function (el) { el.classList.toggle('is-active', el.dataset.libraryPanel === view); }); if (view === 'books') libraryRenderBooks(); else if (view === 'plays') libraryRenderPlays(); else if (view === 'characters' && typeof window.libraryRenderCharacters === 'function') window.libraryRenderCharacters(); }; // ── Books (server-side reader library) ──────────────────────────────────────── function _libSkeleton(n) { return Array.from({ length: n }, () => '
' + '
' + '
' + '
' + '
' ).join(''); } async function libraryRenderBooks() { const list = document.getElementById('lib-books-list'); if (!list) return; list.innerHTML = _libSkeleton(4); let all = []; try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; } if (!all.length) { list.innerHTML = '

No books yet.

' + '

Open Read Aloud, import a PDF or text, and save it to your library.

'; return; } all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); }); list.innerHTML = all.map(function (rec) { const total = rec.sentenceCount || 0; const synthPct = total ? Math.round(((rec.synthCount || 0) / total) * 100) : 0; const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0; const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : ''; const cov = libBookCover(rec.title || 'Untitled'); const coverUrl = LIB_READER_API + '/' + rec.id + '/cover?t=' + new Date(rec.updated || Date.now()).getTime(); const bg = rec.hasCover ? 'style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url(\'' + coverUrl + '\');background-size:cover;background-position:center;color:#fff"' : 'style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '"'; return '
' + '
' + '' + '' + '
' + '
' + escHtml(rec.title || 'Untitled') + '
' + '
' + total + ' sentences' + (rec.pageCount ? ' · ' + rec.pageCount + ' pg' : '') + '
' + '
' + readPct + '% read · ' + date + '
' + '
'; }).join(''); list.querySelectorAll('.lib-book').forEach(function (el) { const id = el.dataset.id, title = el.dataset.title; el.addEventListener('click', function (e) { if (e.target.closest('.reh-book-act')) return; // Show the reader on the PDF view immediately and suppress the stale-cast // restore so a freshly-opened book never lands in an empty casting panel. window._readerStartView = 'main'; window._readerSuppressCastRestore = true; if (typeof navTo === 'function') navTo('s-reader'); if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id); }); const reh = el.querySelector('.lib-act-rehearse'); if (reh) reh.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInRehearser(title); }); const del = el.querySelector('.lib-act-del-book'); if (del) del.addEventListener('click', function (e) { e.stopPropagation(); libConfirmDelete(el, 'Delete book?', 'Audio files will be removed.', async function () { try { const r = await fetch(LIB_READER_API + '/' + id, { method: 'DELETE' }); if (!r.ok) throw new Error('delete failed'); toast('Book deleted', 'success'); libraryRenderBooks(); } catch (err) { toast(err.message || 'Delete failed', 'error'); } }); }); }); } // ── Theater Plays (client-side rehearser library) ───────────────────────────── async function libraryRenderPlays() { const list = document.getElementById('lib-plays-list'); if (!list) return; list.innerHTML = _libSkeleton(3); let all = []; try { all = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; } catch (_) { all = []; } if (!all.length) { list.innerHTML = '

No theater plays yet.

' + '

Open Script Rehearsal → Import / Export to add a script, or cast a book as an audiobook.

'; return; } all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); }); list.innerHTML = all.map(function (rec) { const speakers = Object.keys(rec.cast || {}); const total = (typeof parseScript === 'function') ? parseScript(rec.script || '').filter(function (l) { return l.type === 'dialog'; }).length : 0; const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0; const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—'; const cov = libBookCover(rec.title || 'Untitled'); const avatars = speakers.slice(0, 5).map(function (sp) { const c = rec.cast[sp] || {}; return '' + (sp[0] || '?').toUpperCase() + ''; }).join(''); return '
' + '
' + '' + '' + '
' + '
' + escHtml(rec.title || 'Untitled') + '
' + '
' + '
' + avatars + '
' + total + ' lines · ' + speakers.length + ' cast' + '
' + '
' + pct + '% · ' + date + '
' + '
'; }).join(''); list.querySelectorAll('.lib-play').forEach(function (el) { const id = parseInt(el.dataset.id, 10), title = el.dataset.title; el.addEventListener('click', function (e) { if (e.target.closest('.reh-book-act')) return; openPlayInRehearser(id); }); const ra = el.querySelector('.lib-act-readaloud'); if (ra) ra.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInReader(title); }); const del = el.querySelector('.lib-act-del-play'); if (del) del.addEventListener('click', function (e) { e.stopPropagation(); libConfirmDelete(el, 'Delete rehearsal?', 'This cannot be undone.', async function () { try { if (typeof rehDbDelete === 'function') await rehDbDelete(id); toast('Rehearsal deleted', 'success'); libraryRenderPlays(); } catch (err) { toast(err.message || 'Delete failed', 'error'); } }); }); }); } // ── Cross-links between Read Aloud and the Rehearser ────────────────────────── async function openPlayInRehearser(id) { try { // rehDbOpen()/REH_STORE were the pre-migration raw-IndexedDB API and no // longer exist — rehearsals are server-backed now (rehDbGetById below). // This call used to throw a ReferenceError immediately, caught silently // by this try/catch, which is why clicking a card looked like it did // nothing (or just flashed an error toast). const rec = (typeof rehDbGetById === 'function') ? await rehDbGetById(id) : null; if (rec && typeof loadRecord === 'function') loadRecord(rec); else toast('Rehearsal not found', 'error'); } catch (err) { toast('Could not open rehearsal', 'error'); } } // Open a production in the Rehearser: reuse an existing play of the same title, // else seed one from the book's source text. async function productionOpenInRehearser(title) { const key = prodKey(title); try { const plays = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; const match = plays.find(function (p) { return prodKey(p.title) === key; }); if (match) { openPlayInRehearser(match.id); return; } } catch (_) {} // No play yet — try to seed from the matching book's source. let books = []; try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {} const book = books.find(function (b) { return prodKey(b.title) === key; }); if (book && book.kind !== 'pdf') { try { const sr = await fetch(LIB_READER_API + '/' + book.id + '/source'); const text = sr.ok ? await sr.text() : ''; if (text && typeof audiobookOpenInRehearser === 'function') { audiobookOpenInRehearser(text, title, []); return; } } catch (_) {} } if (book && book.kind === 'pdf') { if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(book.id); toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal', 'info'); return; } toast('No source to rehearse for this title yet', 'error'); } // Open a production in Read Aloud: reuse the matching saved book. async function productionOpenInReader(title) { const key = prodKey(title); let books = []; try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {} const book = books.find(function (b) { return prodKey(b.title) === key; }); if (book && typeof readerOpenLibraryDoc === 'function') { readerOpenLibraryDoc(book.id); return; } if (typeof navTo === 'function') navTo('s-reader'); toast('No audiobook for this title yet — import its source in Read Aloud', 'info'); } // ── Shared cast — the character roster a production resolves against ────────── // // Returns name(lowercased) → { name, voice, gender, soul, tags } gathered from // the character library by origin book OR tag membership. The rehearser and the // audiobook caster both fill empty speaker slots from this, so a character cast // once is reused everywhere the same production is opened. async function castForProduction(title) { const out = {}; if (typeof clGetAllByTagOrBook !== 'function') return out; let recs = []; try { recs = await clGetAllByTagOrBook(title); } catch (_) { recs = []; } recs.forEach(function (r) { const name = (r.name || '').trim(); if (!name) return; const voice = (r.voice && r.voice.id) ? r.voice.id : (typeof r.voice === 'string' ? r.voice : ''); out[name.toLowerCase()] = { name: name, voice: voice || '', gender: (r.sheet && r.sheet.gender) || '', soul: (r.sheet && (r.sheet.voice_pattern || r.sheet.motivation)) || '', tags: r.tags || '', }; }); return out; } window.castForProduction = castForProduction; // Persist cast voice choices back to the shared roster. Guard: only updates // characters that ALREADY exist for this production (by name) — never creates // records, so throwaway scripts and narrators don't pollute the library. async function castWriteBack(title, castMap) { if (!title || !castMap || typeof clGetAllByTagOrBook !== 'function' || typeof clPut !== 'function') return; let recs = []; try { recs = await clGetAllByTagOrBook(title); } catch (_) { return; } if (!recs.length) return; const byName = {}; recs.forEach(function (r) { byName[(r.name || '').trim().toLowerCase()] = r; }); let n = 0; for (const sp of Object.keys(castMap)) { if (String(sp).includes('NARRATOR')) continue; const slot = castMap[sp] || {}; const voice = slot.voice; if (!voice || voice === 'me') continue; const rec = byName[String(sp).trim().toLowerCase()]; if (!rec) continue; const curId = (rec.voice && rec.voice.id) ? rec.voice.id : (typeof rec.voice === 'string' ? rec.voice : ''); if (curId === voice) continue; rec.voice = { id: voice }; rec.updated = new Date(); try { await clPut(rec); n++; } catch (_) {} } return n; } window.castWriteBack = castWriteBack; // ── Shared helpers ──────────────────────────────────────────────────────────── // Deterministic two-tone cover from a title (mirrors bookCover in rehearser.js, // kept local so the Library renders even if that module loads later). function libBookCover(title) { let h = 0; const s = String(title || 'Untitled'); for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360; return { c1: 'hsl(' + h + ',55%,42%)', c2: 'hsl(' + ((h + 40) % 360) + ',58%,30%)' }; } function libConfirmDelete(cardEl, heading, sub, onConfirm) { if (cardEl.querySelector('.reh-book-del-confirm')) return; const o = document.createElement('div'); o.className = 'reh-book-del-confirm'; o.style.cssText = 'position:absolute;inset:0;background:rgba(0,0,0,.85);color:#fff;display:flex;flex-direction:column;justify-content:center;align-items:center;border-radius:inherit;z-index:10;padding:12px;text-align:center;box-sizing:border-box'; o.innerHTML = '
' + heading + '
' + '
' + sub + '
' + '
' + '' + '' + '
'; o.addEventListener('click', function (e) { e.stopPropagation(); }); o.querySelector('[data-lib-cancel]').addEventListener('click', function (e) { e.stopPropagation(); o.remove(); }); o.querySelector('[data-lib-ok]').addEventListener('click', async function (e) { e.stopPropagation(); o.innerHTML = ''; await onConfirm(); }); cardEl.appendChild(o); } // Characters/Cast rendering lives in library-characters.js. Keeping it separate // makes the production overview code smaller and keeps the character workspace isolated. window.libraryRenderBooks = libraryRenderBooks; window.libraryRenderPlays = libraryRenderPlays; window.productionOpenInRehearser = productionOpenInRehearser; window.productionOpenInReader = productionOpenInReader; window.prodKey = prodKey;