tts-voice-creator-clone-and.../static/js/library.js

320 lines
17 KiB
JavaScript

// ── 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 }, () =>
'<div class="reh-book lib-book-sk" style="pointer-events:none">'
+ '<div class="sk" style="height:14px;width:70%;margin-bottom:8px;border-radius:4px"></div>'
+ '<div class="sk" style="height:10px;width:45%;border-radius:4px;margin-bottom:6px"></div>'
+ '<div class="sk" style="height:6px;width:90%;border-radius:3px"></div>'
+ '</div>'
).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 = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>'
+ '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>';
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 '<div class="reh-book lib-book" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" ' + bg + ' title="' + escHtml(rec.title || '') + '">'
+ '<div class="reh-book-actions">'
+ '<button class="reh-book-act lib-act-rehearse" title="Rehearse this title"><span class="mdi mdi-theater"></span></button>'
+ '<button class="reh-book-act lib-act-del-book" title="Delete book"><span class="mdi mdi-delete-outline"></span></button>'
+ '</div>'
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
+ '<div class="reh-book-meta">'
+ total + ' sentences' + (rec.pageCount ? ' · ' + rec.pageCount + ' pg' : '')
+ '<div class="reh-book-progress" title="' + synthPct + '% audio"><div style="width:' + synthPct + '%"></div></div>'
+ '<div style="margin-top:4px;opacity:.8"><span class="mdi ' + (rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline') + '"></span> ' + readPct + '% read · ' + date + '</div>'
+ '</div></div>';
}).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 = '<div class="lib-empty"><span class="mdi mdi-theater"></span><p>No theater plays yet.</p>'
+ '<p class="lib-empty-hint">Open <b>Script Rehearsal</b> → Import / Export to add a script, or cast a book as an audiobook.</p></div>';
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 '<span style="background:' + (c.color || '#888') + '">' + (sp[0] || '?').toUpperCase() + '</span>';
}).join('');
return '<div class="reh-book lib-play" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '" title="' + escHtml(rec.title || '') + '">'
+ '<div class="reh-book-actions">'
+ '<button class="reh-book-act lib-act-readaloud" title="Read aloud"><span class="mdi mdi-book-open-page-variant-outline"></span></button>'
+ '<button class="reh-book-act lib-act-del-play" title="Delete rehearsal"><span class="mdi mdi-delete-outline"></span></button>'
+ '</div>'
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
+ '<div class="reh-book-meta">'
+ '<div class="reh-book-avatars">' + avatars + '</div>'
+ total + ' lines · ' + speakers.length + ' cast'
+ '<div class="reh-book-progress"><div style="width:' + pct + '%"></div></div>'
+ '<div style="margin-top:4px;opacity:.8">' + pct + '% · ' + date + '</div>'
+ '</div></div>';
}).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 = '<div style="font-weight:600;margin-bottom:8px;font-size:14px">' + heading + '</div>'
+ '<div style="font-size:11px;opacity:.8;margin-bottom:12px;line-height:1.4">' + sub + '</div>'
+ '<div style="display:flex;gap:8px">'
+ '<button class="btn-secondary btn-sm" data-lib-cancel style="background:rgba(255,255,255,.15);border:none;color:#fff;padding:6px 12px">Cancel</button>'
+ '<button class="btn-primary btn-sm" data-lib-ok style="background:var(--red,#ef4444);border:none;color:#fff;padding:6px 12px">Delete</button>'
+ '</div>';
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 = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px"></span>';
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;