1233 lines
61 KiB
JavaScript
1233 lines
61 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') 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 ─────────────────────────────────────────────────────────
|
||
|
||
async function libraryRenderCharacters() {
|
||
const container = document.getElementById('lib-chars-list');
|
||
if (!container) return;
|
||
container.innerHTML = '<div class="lib-chars-loading"><span class="mdi mdi-loading mdi-spin"></span> Loading characters…</div>';
|
||
|
||
let all = [];
|
||
try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (_) { all = []; }
|
||
|
||
if (!all.length) {
|
||
container.innerHTML = '<div class="lib-chars-toolbar">'
|
||
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||
+ '</div>'
|
||
+ '<div class="lib-empty">'
|
||
+ '<span class="mdi mdi-account-box-multiple-outline"></span>'
|
||
+ '<p>No characters yet.</p>'
|
||
+ '<p class="lib-empty-hint">Open a book in <b>Read Aloud</b>, cast it as an audiobook, then click <b>Cast Characters</b> to generate character sheets — or import an existing cast from SillyTavern.</p>'
|
||
+ '</div>';
|
||
container.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Group by book (production)
|
||
const byBook = {};
|
||
all.forEach(function (rec) {
|
||
const bk = rec.book || 'Unsorted';
|
||
if (!byBook[bk]) byBook[bk] = [];
|
||
byBook[bk].push(rec);
|
||
});
|
||
|
||
container.innerHTML = '';
|
||
|
||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production
|
||
const bar = document.createElement('div');
|
||
bar.className = 'lib-chars-toolbar';
|
||
bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>';
|
||
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||
});
|
||
container.appendChild(bar);
|
||
|
||
Object.keys(byBook).sort().forEach(function (book) {
|
||
const chars = byBook[book].sort(function (a, b) {
|
||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||
const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||
const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||
return ta - tb || (a.name || '').localeCompare(b.name || '');
|
||
});
|
||
|
||
const cov = libBookCover(book);
|
||
const prod = document.createElement('div');
|
||
prod.className = 'lib-chars-production';
|
||
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
|
||
+ '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
|
||
+ '<div class="lib-chars-prod-actions">'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-cast-btn" data-book="' + escHtml(book) + '" title="Re-run character sheet generation"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-reh-btn" data-book="' + escHtml(book) + '" title="Open in Script Rehearsal"><span class="mdi mdi-theater"></span> Rehearse</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-read-btn" data-book="' + escHtml(book) + '" title="Open in Read Aloud"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</button>'
|
||
+ '<button class="btn-secondary btn-sm lib-chars-imp-btn" data-book="' + escHtml(book) + '" title="Import SillyTavern cards into this production"><span class="mdi mdi-import"></span></button>'
|
||
+ '</div></div>'
|
||
+ '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||
+ chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||
+ '</div>';
|
||
|
||
// Action buttons
|
||
prod.querySelector('.lib-chars-cast-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||
toast('Open the book in Read Aloud then click Cast Characters', 'info');
|
||
});
|
||
prod.querySelector('.lib-chars-reh-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInRehearser === 'function') productionOpenInRehearser(book);
|
||
});
|
||
prod.querySelector('.lib-chars-read-btn').addEventListener('click', function () {
|
||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||
});
|
||
prod.querySelector('.lib-chars-imp-btn').addEventListener('click', function () {
|
||
if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); });
|
||
});
|
||
|
||
// Wire voice selectors and auto-assign buttons
|
||
prod.querySelectorAll('.lib-char-card').forEach(function (card) {
|
||
const charId = card.dataset.charId;
|
||
const rec = all.find(function (r) { return r.id === charId; });
|
||
if (!rec) return;
|
||
|
||
// Click on card body → open detail page (not if clicking a button or avatar)
|
||
card.addEventListener('click', function (e) {
|
||
if (e.target.closest('button, .lib-char-avatar, .lib-voice-picker-popup')) return;
|
||
_charDetailPage(rec, chars);
|
||
});
|
||
|
||
// Avatar click → upload profile picture
|
||
card.querySelector('.lib-char-avatar')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*';
|
||
inp.onchange = async function () {
|
||
const file = inp.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = async function (ev) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||
toast('Profile picture saved', 'success');
|
||
libraryRenderCharacters();
|
||
};
|
||
fr.readAsDataURL(file);
|
||
};
|
||
inp.click();
|
||
});
|
||
|
||
card.querySelector('.lib-char-pick-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_openVoicePicker(card, rec, function () { libraryRenderCharacters(); });
|
||
});
|
||
|
||
card.querySelector('.lib-char-auto-voice')?.addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
await _autoAssignVoice(rec);
|
||
libraryRenderCharacters();
|
||
});
|
||
|
||
card.querySelector('.lib-char-export')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
if (typeof stExportRecord === 'function') stExportRecord(rec);
|
||
});
|
||
|
||
card.querySelector('.lib-char-online-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_charSearchOnline(rec);
|
||
});
|
||
|
||
card.querySelector('.lib-char-gen-voice')?.addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_charDesignVoice(rec);
|
||
});
|
||
});
|
||
|
||
container.appendChild(prod);
|
||
});
|
||
}
|
||
|
||
function _charHue(name) {
|
||
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
|
||
}
|
||
|
||
function _charAlignHtml(sh) {
|
||
const score = sh.moral_alignment_score;
|
||
if (score == null) return '';
|
||
const pct = Math.max(0, Math.min(100, score));
|
||
const arc = sh.arc_direction || 'neutral';
|
||
const arrowMap = {
|
||
'good-to-bad': { ch: '↘', color: '#ff7043', tip: 'Arc: Descends toward evil' },
|
||
'bad-to-good': { ch: '↗', color: '#66bb6a', tip: 'Arc: Redeems toward good' },
|
||
'complex': { ch: '↕', color: '#ab47bc', tip: 'Arc: Complex / unpredictable' },
|
||
'stable-good': { ch: '→', color: '#66bb6a', tip: 'Arc: Stable good' },
|
||
'stable-bad': { ch: '→', color: '#888', tip: 'Arc: Stable evil' },
|
||
'neutral': { ch: '→', color: '#aaa', tip: 'Arc: Neutral' },
|
||
};
|
||
const a = arrowMap[arc] || arrowMap['neutral'];
|
||
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Morally ambiguous';
|
||
return '<div class="lib-char-align">'
|
||
+ '<span class="lib-char-align-evil" title="Evil">●</span>'
|
||
+ '<div class="lib-char-align-bar" title="' + label + ' (' + pct + '/100)">'
|
||
+ '<div class="lib-char-align-dot" style="left:' + pct + '%"></div>'
|
||
+ '</div>'
|
||
+ '<span class="lib-char-align-good" title="Good">●</span>'
|
||
+ '<span class="lib-char-align-arrow" style="color:' + a.color + '" title="' + a.tip + '">' + a.ch + '</span>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _libStr(v) {
|
||
if (v == null) return '';
|
||
if (typeof v === 'string') return v;
|
||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||
return JSON.stringify(v);
|
||
}
|
||
|
||
function _charRelsHtml(rec, allChars) {
|
||
if (!allChars || allChars.length < 2) return '';
|
||
const relText = _libStr(rec.sheet?.relationships).toLowerCase();
|
||
if (!relText) return '';
|
||
const hits = allChars
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 5);
|
||
if (!hits.length) return '';
|
||
return '<div class="lib-char-rels">'
|
||
+ hits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lib-char-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>';
|
||
}
|
||
|
||
function _charCardHtml(rec, allChars) {
|
||
const sh = rec.sheet || {};
|
||
const hue = _charHue(rec.name);
|
||
const hue2 = (hue + 40) % 360;
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const voiceLabel = voiceId ? escHtml(voiceId) : '<span style="opacity:.45">Keine Stimme</span>';
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
||
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
|
||
const gender = String(sh.gender || '').toLowerCase();
|
||
const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 140);
|
||
|
||
const avatarInner = rec.image
|
||
? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
|
||
: escHtml((rec.name || '?')[0].toUpperCase());
|
||
|
||
return '<div class="lib-char-card" data-char-id="' + escHtml(rec.id) + '">'
|
||
+ '<div class="lib-char-card-banner" style="--ch1:hsl(' + hue + ',52%,35%);--ch2:hsl(' + hue2 + ',56%,26%)">'
|
||
+ '<div class="lib-char-avatar" data-char-id="' + escHtml(rec.id) + '" title="Bild hochladen">' + avatarInner + '</div>'
|
||
+ '<button class="lib-char-export" title="Als SillyTavern-Karte exportieren (.json)"><span class="mdi mdi-export-variant"></span></button>'
|
||
+ '</div>'
|
||
+ '<div class="lib-char-body">'
|
||
+ '<div class="lib-char-name">' + tierBadge + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:11px;opacity:.5"></span></div>'
|
||
+ (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||
+ (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
|
||
+ _charAlignHtml(sh)
|
||
+ _charRelsHtml(rec, allChars)
|
||
+ '<div class="lib-char-divider"></div>'
|
||
+ '<div class="lib-char-voice-row">'
|
||
+ '<span class="lib-char-voice-label">' + voiceLabel + '</span>'
|
||
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button>'
|
||
+ '<button class="lib-char-auto-voice btn-sm">Auto</button>'
|
||
+ '</div>'
|
||
+ '</div></div>';
|
||
}
|
||
|
||
// ── Character detail modal ────────────────────────────────────────────────────
|
||
|
||
function _lcdSourcesHtml(sources) {
|
||
const list = Array.isArray(sources) ? sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||
if (!list.length) return '';
|
||
return '<div class="lcd-sources">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text</div>'
|
||
+ '<div class="lcd-source-list">'
|
||
+ list.map(function (s) {
|
||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||
const hint = _libStr(s.line_hint || s.hint || '');
|
||
return '<div class="lcd-source-item">'
|
||
+ (page || hint ? '<div class="lcd-source-page">' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||
+ '</div>';
|
||
}).join('')
|
||
+ '</div></div>';
|
||
}
|
||
|
||
function _lcdField(label, value, multiline) {
|
||
const v = _libStr(value);
|
||
if (!v) return '';
|
||
return '<div class="lcd-field">'
|
||
+ '<div class="lcd-field-label">' + label + '</div>'
|
||
+ '<div class="lcd-field-value">' + (multiline ? escHtml(v) : escHtml(v)) + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _lcdSection(icon, label, fields) {
|
||
const body = fields.join('');
|
||
if (!body) return '';
|
||
return '<div class="lcd-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||
+ body
|
||
+ '</div>';
|
||
}
|
||
|
||
function _lcdSectionFull(icon, label, fields) {
|
||
const body = fields.join('');
|
||
if (!body) return '';
|
||
return '<div class="lcd-section-full">'
|
||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||
+ body
|
||
+ '</div>';
|
||
}
|
||
|
||
function _lcdFieldEdit(label, value, sheetKey) {
|
||
const v = _libStr(value);
|
||
return '<div class="lcd-field">'
|
||
+ (label ? '<div class="lcd-field-label">' + escHtml(label) + '</div>' : '')
|
||
+ '<div class="lcd-field-value lcd-field-editable" contenteditable="true" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(v) + '</div>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function _jumpToReaderPage(pageNum) {
|
||
// Always navigate to the reader section first
|
||
if (typeof navTo === 'function') navTo('s-reader');
|
||
setTimeout(function () {
|
||
// Try page-div scroll (PDF reader)
|
||
const pages = window.readerState?.pages;
|
||
if (pages && pages.length >= pageNum) {
|
||
const pg = pages[pageNum - 1];
|
||
if (pg?.pageDiv) { pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
|
||
}
|
||
// Try sentence-index jump — find first sentence on or after target page (0-indexed internally)
|
||
const sentences = window.readerState?.sentences;
|
||
if (sentences && sentences.length) {
|
||
const target0 = pageNum - 1;
|
||
const idx = sentences.findIndex(function (s) {
|
||
return (s.words || []).some(function (w) { return (w.page ?? w.para ?? 0) >= target0; });
|
||
});
|
||
if (idx >= 0 && typeof readerJumpTo === 'function') { readerJumpTo(idx); return; }
|
||
}
|
||
toast('Öffne das Buch in „Vorlesen" und klicke nochmal auf die Quelle', 'info');
|
||
}, 300);
|
||
}
|
||
|
||
// ── Character detail PAGE (full-page with inline editing, replaces the modal) ─
|
||
|
||
async function _charDetailPage(rec, allChars) {
|
||
const container = document.getElementById('lib-chars-list');
|
||
if (!container) return;
|
||
window._libDetailRec = rec;
|
||
|
||
const sh = rec.sheet || {};
|
||
const hue = _charHue(rec.name);
|
||
const hue2 = (hue + 40) % 360;
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||
const gender = _libStr(sh.gender);
|
||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female'
|
||
: gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
const score = sh.moral_alignment_score;
|
||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||
const arcMap = {
|
||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||
};
|
||
const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral'];
|
||
|
||
const avatarHtml = rec.image
|
||
? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
|
||
|
||
const alignHtml = pct != null ? (
|
||
'<div class="lcd-align-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||
+ '<div class="lcd-align-bar-wrap">'
|
||
+ '<span class="lcd-align-label">Böse</span>'
|
||
+ '<input type="range" class="lcd-align-slider" min="0" max="100" value="' + pct + '">'
|
||
+ '<span class="lcd-align-label">Gut</span>'
|
||
+ '<span class="lcd-align-slider-val">' + pct + '/100</span>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label
|
||
+ (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)')
|
||
+ '</div>'
|
||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const relText = _libStr(sh.relationships).toLowerCase();
|
||
const relHits = (allChars || [])
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 8);
|
||
const relDotsHtml = relHits.length ? (
|
||
'<div class="lcd-rels-dots">'
|
||
+ relHits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||
const sourcesHtml = sourcesList.length ? (
|
||
'<div class="lcd-sources">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text'
|
||
+ '<span class="lcd-sources-hint"> · Klicken zum Springen</span></div>'
|
||
+ '<div class="lcd-source-list">'
|
||
+ sourcesList.map(function (s) {
|
||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||
const hint = _libStr(s.line_hint || s.hint || '');
|
||
return '<div class="lcd-source-item lcd-source-clickable" data-page="' + (s.page != null ? s.page : '') + '">'
|
||
+ (page || hint ? '<div class="lcd-source-page"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||
+ '</div>';
|
||
}).join('')
|
||
+ '</div></div>'
|
||
) : '';
|
||
|
||
const sidebarChars = (allChars || []).slice().sort(function (a, b) {
|
||
return (b.sheet?.sources?.length || 0) - (a.sheet?.sources?.length || 0);
|
||
});
|
||
const sidebarHtml = sidebarChars.map(function (c) {
|
||
const h = _charHue(c.name);
|
||
const count = (c.sheet?.sources || []).length;
|
||
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
|
||
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
|
||
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
|
||
+ (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
|
||
+ '</div>';
|
||
}).join('');
|
||
|
||
container.innerHTML = '';
|
||
const pg = document.createElement('div');
|
||
pg.className = 'lib-char-page';
|
||
pg.innerHTML =
|
||
'<div class="lib-cpg-main">'
|
||
+ '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
|
||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
|
||
+ avatarHtml
|
||
+ '<div class="lcd-header-body">'
|
||
+ '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
|
||
+ '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
|
||
+ '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
|
||
+ '<div class="lcd-tier-gender">'
|
||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-body">'
|
||
+ '<div class="lcd-voice-top">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||
+ '</div>'
|
||
+ alignHtml
|
||
+ '<div class="lcd-sections">'
|
||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||
_lcdFieldEdit('Körperlich', sh.physical, 'physical'),
|
||
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'),
|
||
])
|
||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||
_lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms'),
|
||
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'),
|
||
])
|
||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||
_lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory'),
|
||
_lcdFieldEdit('Motivation', sh.motivation, 'motivation'),
|
||
_lcdFieldEdit('Ängste', sh.fears, 'fears'),
|
||
])
|
||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||
_lcdFieldEdit('Fertigkeiten', sh.skills, 'skills'),
|
||
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities'),
|
||
_lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'),
|
||
_lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'),
|
||
])
|
||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||
_lcdFieldEdit('', sh.relationships, 'relationships'),
|
||
relDotsHtml,
|
||
])
|
||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'),
|
||
_lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition'),
|
||
])
|
||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
|
||
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
|
||
])
|
||
+ '</div>'
|
||
+ sourcesHtml
|
||
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lib-cpg-sidebar">'
|
||
+ '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
|
||
+ sidebarHtml
|
||
+ '</div>';
|
||
|
||
container.appendChild(pg);
|
||
|
||
pg.querySelector('.lib-cpg-back').addEventListener('click', function () { libraryRenderCharacters(); });
|
||
|
||
pg.querySelector('.lcd-avatar-upload').addEventListener('click', function () {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*';
|
||
inp.onchange = async function () {
|
||
const file = inp.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = async function (ev) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||
toast('Profilbild gespeichert', 'success');
|
||
rec.image = ev.target.result;
|
||
const av = pg.querySelector('.lcd-avatar-upload');
|
||
if (av) av.innerHTML = '<img src="' + ev.target.result + '" alt="' + escHtml(rec.name) + '">';
|
||
};
|
||
fr.readAsDataURL(file);
|
||
};
|
||
inp.click();
|
||
});
|
||
|
||
pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
|
||
item.addEventListener('click', async function () {
|
||
const target = (allChars || []).find(function (c) { return c.id === item.dataset.charId; });
|
||
if (target) _charDetailPage(target, allChars);
|
||
});
|
||
});
|
||
|
||
pg.querySelectorAll('.lcd-source-clickable').forEach(function (item) {
|
||
item.addEventListener('click', function () {
|
||
const n = parseInt(item.dataset.page, 10);
|
||
if (!isNaN(n)) _jumpToReaderPage(n);
|
||
});
|
||
});
|
||
|
||
pg.querySelector('.lcd-pick-voice')?.addEventListener('click', async function () {
|
||
_openVoicePicker(pg.querySelector('.lcd-voice-top'), rec, async function () {
|
||
const all = await clGetAll().catch(() => allChars);
|
||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
|
||
});
|
||
});
|
||
pg.querySelector('.lcd-auto-voice')?.addEventListener('click', async function () {
|
||
await _autoAssignVoice(rec);
|
||
const all = await clGetAll().catch(() => allChars);
|
||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
|
||
});
|
||
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); });
|
||
|
||
const slider = pg.querySelector('.lcd-align-slider');
|
||
const sliderVal = pg.querySelector('.lcd-align-slider-val');
|
||
const arcEl = pg.querySelector('.lcd-align-arc');
|
||
if (slider) {
|
||
slider.addEventListener('input', async function () {
|
||
const val = parseInt(slider.value, 10);
|
||
if (sliderVal) sliderVal.textContent = val + '/100';
|
||
if (arcEl) arcEl.textContent = arcInfo.ch + ' ' + arcInfo.label
|
||
+ (val >= 70 ? ' · Rechtschaffen (' + val + '/100)' : val <= 30 ? ' · Böse (' + val + '/100)' : ' · Moralisch ambivalent (' + val + '/100)');
|
||
arcEl && (arcEl.style.color = arcInfo.color);
|
||
rec.sheet.moral_alignment_score = val;
|
||
rec.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
});
|
||
}
|
||
|
||
let _saveTimer = null;
|
||
function _schedSave(key, value, isRecKey) {
|
||
clearTimeout(_saveTimer);
|
||
_saveTimer = setTimeout(async function () {
|
||
if (isRecKey) { rec[key] = value; }
|
||
else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; }
|
||
rec.updated = new Date();
|
||
if (typeof clPut === 'function') await clPut(rec);
|
||
}, 900);
|
||
}
|
||
pg.querySelectorAll('[contenteditable][data-sheet-key]').forEach(function (el) {
|
||
el.addEventListener('input', function () { _schedSave(el.dataset.sheetKey, el.textContent.trim(), false); });
|
||
});
|
||
pg.querySelectorAll('[contenteditable][data-rec-key]').forEach(function (el) {
|
||
el.addEventListener('input', function () { _schedSave(el.dataset.recKey, el.textContent.trim(), true); });
|
||
});
|
||
}
|
||
|
||
window._charDetailPage = _charDetailPage;
|
||
|
||
function _charDetailModal(rec, allChars) {
|
||
const sh = rec.sheet || {};
|
||
const cov = libBookCover(rec.name);
|
||
const hue = _charHue(rec.name);
|
||
const tier = String(sh.tier || '').toLowerCase();
|
||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||
const gender = _libStr(sh.gender);
|
||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||
const score = sh.moral_alignment_score;
|
||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||
const arc = sh.arc_direction || 'neutral';
|
||
const arcMap = {
|
||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||
};
|
||
const arcInfo = arcMap[arc] || arcMap['neutral'];
|
||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||
|
||
const avatarHtml = rec.image
|
||
? '<div class="lcd-avatar"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||
: '<div class="lcd-avatar" style="background:hsl(' + hue + ',55%,38%)">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
|
||
|
||
const alignHtml = pct != null ? (
|
||
'<div class="lcd-align-section">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||
+ '<div class="lcd-align-bar-wrap">'
|
||
+ '<span class="lcd-align-label">Böse</span>'
|
||
+ '<div class="lcd-align-bar"><div class="lcd-align-dot" style="left:' + pct + '%"></div></div>'
|
||
+ '<span class="lcd-align-label">Gut</span>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '</div>'
|
||
+ (_libStr(sh.arc_note) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.arc_note)) + '</div>' : '')
|
||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note" style="margin-top:6px">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
// Relationship dots (same logic as card)
|
||
const relText = _libStr(sh.relationships).toLowerCase();
|
||
const relHits = (allChars || [])
|
||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||
.map(function (c) {
|
||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||
return { c: c, n: (relText.match(re) || []).length };
|
||
})
|
||
.filter(function (x) { return x.n > 0; })
|
||
.sort(function (a, b) { return b.n - a.n; })
|
||
.slice(0, 8);
|
||
|
||
const relDotsHtml = relHits.length ? (
|
||
'<div class="lcd-rels-dots">'
|
||
+ relHits.map(function (x) {
|
||
const h = _charHue(x.c.name);
|
||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||
}).join('')
|
||
+ '</div>'
|
||
) : '';
|
||
|
||
const ov = document.createElement('div');
|
||
ov.className = 'lib-char-detail-ov';
|
||
ov.innerHTML = '<div class="lib-char-detail-box">'
|
||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + ((hue+40)%360) + ',58%,25%)">'
|
||
+ avatarHtml
|
||
+ '<div class="lcd-header-body">'
|
||
+ '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
|
||
+ (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||
+ (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||
+ '<div class="lcd-tier-gender">'
|
||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-header-btns">'
|
||
+ '<button class="lcd-edit-btn"><span class="mdi mdi-pencil-outline"></span> Bearbeiten</button>'
|
||
+ '<button class="lcd-close-btn"><span class="mdi mdi-close"></span></button>'
|
||
+ '</div>'
|
||
+ '</div>'
|
||
+ '<div class="lcd-body">'
|
||
+ '<div class="lcd-voice-top">'
|
||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||
+ '</div>'
|
||
+ alignHtml
|
||
+ '<div class="lcd-sections">'
|
||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||
_lcdField('Körperlich', sh.physical, true),
|
||
_lcdField('Kleidung & Aussehen', sh.clothing, true),
|
||
])
|
||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||
_lcdField('Eigenheiten & Verhalten', sh.mannerisms, true),
|
||
_lcdField('Stimme & Sprache', sh.voice_pattern, true),
|
||
])
|
||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||
_lcdField('Hintergrund & Herkunft', sh.backstory, true),
|
||
_lcdField('Motivation', sh.motivation, true),
|
||
_lcdField('Ängste', sh.fears, true),
|
||
])
|
||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||
_lcdField('Fertigkeiten', sh.skills, true),
|
||
_lcdField('Besondere Fähigkeiten', sh.capabilities, true),
|
||
_lcdField('Stärkstes Attribut', sh.attribute_high, false),
|
||
_lcdField('Schwächstes Attribut', sh.attribute_low, false),
|
||
])
|
||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||
_lcdField('', sh.relationships, true),
|
||
relDotsHtml,
|
||
])
|
||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||
_lcdField('Konfliktstil', sh.conflict_style, true),
|
||
_lcdField('Siegbedingung', sh.win_condition, true),
|
||
])
|
||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||
_lcdField('Dunkles Geheimnis / fataler Fehler', sh.secret, true),
|
||
_lcdField('Charakterentwicklung', sh.arc_note, true),
|
||
])
|
||
+ '</div>'
|
||
+ _lcdSourcesHtml(sh.sources)
|
||
+ (rec.analysis ? '<div class="lcd-analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||
+ '</div>'
|
||
+ '</div>';
|
||
|
||
document.body.appendChild(ov);
|
||
|
||
const close = function () { ov.remove(); };
|
||
ov.querySelector('.lcd-close-btn').addEventListener('click', close);
|
||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||
|
||
ov.querySelector('.lcd-edit-btn').addEventListener('click', function () {
|
||
close();
|
||
if (typeof clEdit === 'function') clEdit(rec.id);
|
||
});
|
||
|
||
// Voice buttons inside detail modal
|
||
const box = ov.querySelector('.lib-char-detail-box');
|
||
ov.querySelector('.lcd-pick-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation();
|
||
_openVoicePicker(box, rec, function () { close(); libraryRenderCharacters(); });
|
||
});
|
||
ov.querySelector('.lcd-auto-voice').addEventListener('click', async function (e) {
|
||
e.stopPropagation();
|
||
await _autoAssignVoice(rec);
|
||
close();
|
||
libraryRenderCharacters();
|
||
});
|
||
ov.querySelector('.lcd-online-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation(); _charSearchOnline(rec);
|
||
});
|
||
ov.querySelector('.lcd-gen-voice').addEventListener('click', function (e) {
|
||
e.stopPropagation(); _charDesignVoice(rec);
|
||
});
|
||
|
||
// Avatar click to upload image
|
||
ov.querySelector('.lcd-avatar').addEventListener('click', function () {
|
||
const inp = document.createElement('input');
|
||
inp.type = 'file'; inp.accept = 'image/*';
|
||
inp.onchange = async function () {
|
||
const file = inp.files[0]; if (!file) return;
|
||
const fr = new FileReader();
|
||
fr.onload = async function (ev) {
|
||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||
toast('Profile picture saved', 'success');
|
||
close();
|
||
libraryRenderCharacters();
|
||
};
|
||
fr.readAsDataURL(file);
|
||
};
|
||
inp.click();
|
||
});
|
||
}
|
||
|
||
window._charDetailModal = _charDetailModal;
|
||
|
||
function _openVoicePicker(cardEl, rec, onDone) {
|
||
// Remove any existing picker
|
||
document.querySelectorAll('.lib-voice-picker-popup').forEach(function (p) { p.remove(); });
|
||
|
||
const voices = window._voices || [];
|
||
const gender = String(rec.sheet?.gender || '').toLowerCase();
|
||
const genderMatch = gender.startsWith('f') ? 'f' : gender.startsWith('m') ? 'm' : '';
|
||
|
||
const popup = document.createElement('div');
|
||
popup.className = 'lib-voice-picker-popup';
|
||
popup.innerHTML = '<div class="lib-vp-search"><input type="search" placeholder="Search voices…" class="lib-vp-input" autocomplete="off"></div>'
|
||
+ '<div class="lib-vp-list"></div>';
|
||
|
||
function renderList(filter) {
|
||
let list = voices.filter(function (v) { return v.enabled !== false; });
|
||
if (filter) {
|
||
const f = filter.toLowerCase();
|
||
list = list.filter(function (v) { return (v.id || '').toLowerCase().includes(f) || (v.name || '').toLowerCase().includes(f); });
|
||
} else if (genderMatch) {
|
||
list = list.filter(function (v) {
|
||
const vg = String(v.gender || '').toLowerCase();
|
||
return vg.startsWith(genderMatch) || !vg;
|
||
}).concat(list.filter(function (v) {
|
||
const vg = String(v.gender || '').toLowerCase();
|
||
return vg && !vg.startsWith(genderMatch);
|
||
}));
|
||
}
|
||
const ul = popup.querySelector('.lib-vp-list');
|
||
ul.innerHTML = list.slice(0, 60).map(function (v) {
|
||
const sel = v.id === rec.voice;
|
||
return '<div class="lib-vp-item' + (sel ? ' selected' : '') + '" data-vid="' + escHtml(v.id) + '">'
|
||
+ escHtml(v.id || v.name || '') + (v.gender ? ' <span style="opacity:.5;font-size:10px">· ' + escHtml(v.gender) + '</span>' : '')
|
||
+ '</div>';
|
||
}).join('') + (list.length === 0 ? '<div style="padding:12px;opacity:.5;font-size:12px">No voices found</div>' : '');
|
||
|
||
ul.querySelectorAll('.lib-vp-item').forEach(function (item) {
|
||
item.addEventListener('click', async function () {
|
||
const vid = item.dataset.vid;
|
||
await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: vid }));
|
||
popup.remove();
|
||
onDone();
|
||
});
|
||
});
|
||
}
|
||
|
||
renderList('');
|
||
popup.querySelector('.lib-vp-input').addEventListener('input', function (e) { renderList(e.target.value); });
|
||
|
||
// Position near the card
|
||
cardEl.style.position = 'relative';
|
||
cardEl.appendChild(popup);
|
||
|
||
// Close on outside click
|
||
setTimeout(function () {
|
||
function close(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); } }
|
||
document.addEventListener('click', close);
|
||
}, 0);
|
||
|
||
popup.querySelector('.lib-vp-input').focus();
|
||
}
|
||
|
||
async function _autoAssignVoice(rec) {
|
||
const voices = (window._voices || []).filter(function (v) { return v.enabled !== false; });
|
||
if (!voices.length) { toast('Voice library not loaded', 'error'); return; }
|
||
|
||
const gender = String(rec.sheet?.gender || '').toLowerCase().trim();
|
||
// Handle both English (female/male) and German (weiblich/männlich) gender terms
|
||
const isFemale = gender.startsWith('f') || gender.startsWith('w'); // female, weiblich
|
||
const isMale = !isFemale && gender.startsWith('m'); // male, männlich
|
||
const gMatch = isFemale ? 'f' : isMale ? 'm' : '';
|
||
let pool = gMatch ? voices.filter(function (v) {
|
||
const vg = String(v.gender || '').toLowerCase();
|
||
return gMatch === 'f' ? (vg.startsWith('f') || vg.startsWith('w')) : vg.startsWith('m');
|
||
}) : voices;
|
||
if (!pool.length) pool = voices;
|
||
|
||
// Prefer unassigned voices (not already used by another character in same book)
|
||
const usedInBook = new Set();
|
||
try {
|
||
const bookChars = await clGetAllByTagOrBook(rec.book);
|
||
bookChars.forEach(function (r) { if (r.voice && r.id !== rec.id) usedInBook.add(r.voice); });
|
||
} catch (_) {}
|
||
const fresh = pool.filter(function (v) { return !usedInBook.has(v.id); });
|
||
const candidate = (fresh.length ? fresh : pool).sort(function (a, b) { return (b.rating || 0) - (a.rating || 0); })[0];
|
||
|
||
if (!candidate) { toast('No matching voice found', 'error'); return; }
|
||
await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: candidate.id }));
|
||
toast('Assigned ' + candidate.id + ' → ' + rec.name, 'success');
|
||
}
|
||
|
||
// Detect the production language from a character's own (book-language) text.
|
||
function _charLang(rec) {
|
||
const sh = rec.sheet || {};
|
||
const text = [sh.backstory, sh.voice_pattern, sh.mannerisms, sh.relationships, sh.motivation, sh.archetype]
|
||
.filter(Boolean).join(' ');
|
||
return (typeof detectLang === 'function') ? detectLang(text) : '';
|
||
}
|
||
|
||
// Build a natural-language voice-design prompt from a character sheet.
|
||
function _buildVoicePrompt(rec) {
|
||
const sh = rec.sheet || {};
|
||
const g = String(sh.gender || '').toLowerCase();
|
||
const genderWord = g.startsWith('f') ? 'female' : g.startsWith('m') ? 'male' : '';
|
||
const bits = [];
|
||
bits.push('A ' + (genderWord ? genderWord + ' ' : '') + 'voice'
|
||
+ (sh.archetype ? ' for ' + sh.archetype.toLowerCase() : '') + '.');
|
||
if (sh.voice_pattern) bits.push(sh.voice_pattern);
|
||
if (sh.mannerisms) bits.push('Mannerisms: ' + sh.mannerisms);
|
||
if (sh.physical) bits.push(sh.physical);
|
||
if (sh.alignment) bits.push('Disposition: ' + sh.alignment);
|
||
return bits.join(' ').slice(0, 600);
|
||
}
|
||
|
||
function _selectLoose(sel, val) {
|
||
if (!sel || !val) return;
|
||
const v = String(val).toLowerCase();
|
||
const opt = [...sel.options].find(function (o) {
|
||
const ov = o.value.toLowerCase(), ot = o.textContent.toLowerCase();
|
||
return ov === v || ot === v || ov.startsWith(v) || ot.startsWith(v) || v.startsWith(ov);
|
||
});
|
||
if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change')); }
|
||
}
|
||
|
||
// Search a matching voice online — opens Get a Voice Online on the Fish.audio
|
||
// tab, pre-filled with the character name + detected language.
|
||
function _charSearchOnline(rec) {
|
||
if (typeof navTo === 'function') navTo('s-studio');
|
||
const lang = _charLang(rec);
|
||
setTimeout(function () {
|
||
const fishTab = document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');
|
||
if (fishTab) fishTab.click();
|
||
setTimeout(function () {
|
||
const langSel = document.getElementById('fa-lang');
|
||
if (langSel) _selectLoose(langSel, lang);
|
||
const search = document.getElementById('fa-search');
|
||
if (search) {
|
||
search.value = rec.name;
|
||
search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||
}
|
||
}, 120);
|
||
}, 120);
|
||
toast('Searching online voices for ' + rec.name + (lang ? ' (' + lang + ')' : ''), 'info');
|
||
}
|
||
|
||
// Generate a new voice — opens Design a Voice, pre-filled with gender, the
|
||
// detected language, a description built from the sheet, and the character name.
|
||
function _charDesignVoice(rec) {
|
||
if (typeof navTo === 'function') navTo('s-design');
|
||
const sh = rec.sheet || {};
|
||
const lang = _charLang(rec);
|
||
setTimeout(function () {
|
||
_selectLoose(document.getElementById('design-gender'), sh.gender);
|
||
_selectLoose(document.getElementById('design-language'), lang);
|
||
const instruct = document.getElementById('design-instruct');
|
||
if (instruct) instruct.value = _buildVoicePrompt(rec);
|
||
const nm = document.getElementById('design-preset-name');
|
||
if (nm) nm.value = rec.name;
|
||
}, 140);
|
||
toast('Voice design prepared for ' + rec.name + (lang ? ' · ' + lang : ''), 'info');
|
||
}
|
||
|
||
window._charSearchOnline = _charSearchOnline;
|
||
window._charDesignVoice = _charDesignVoice;
|
||
window.libraryRenderCharacters = libraryRenderCharacters;
|
||
window.libraryRenderBooks = libraryRenderBooks;
|
||
window.libraryRenderPlays = libraryRenderPlays;
|
||
window.productionOpenInRehearser = productionOpenInRehearser;
|
||
window.productionOpenInReader = productionOpenInReader;
|
||
window.prodKey = prodKey;
|