// ── 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 clRender === 'function') clRender();
};
// ── Books (server-side reader library) ────────────────────────────────────────
async function libraryRenderBooks() {
const list = document.getElementById('lib-books-list');
if (!list) return;
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') + '
'
+ '
';
}).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;
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;
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') + '
'
+ '
';
}).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 {
const db = await rehDbOpen();
const rec = await new Promise(function (res, rej) {
const r = db.transaction(REH_STORE, 'readonly').objectStore(REH_STORE).get(id);
r.onsuccess = function (e) { res(e.target.result); }; r.onerror = function (e) { rej(e.target.error); };
});
if (rec && typeof loadRecord === 'function') loadRecord(rec);
} 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 + '
'
+ ''
+ 'Cancel '
+ 'Delete '
+ '
';
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);
}
window.libraryRenderBooks = libraryRenderBooks;
window.libraryRenderPlays = libraryRenderPlays;
window.productionOpenInRehearser = productionOpenInRehearser;
window.productionOpenInReader = productionOpenInReader;
window.prodKey = prodKey;