diff --git a/static/js/rehearser.js b/static/js/rehearser.js index c56bd46..735de12 100644 --- a/static/js/rehearser.js +++ b/static/js/rehearser.js @@ -10,6 +10,7 @@ const rehState = { backend: '', playing: false, repeat: false, + savedId: null, // current library record ID (null = unsaved) // mic recStream: null, recAudioCtx: null, recAnalyser: null, recSourceNode: null, recGainNode: null, recDestStream: null, @@ -18,6 +19,241 @@ const rehState = { }; window.rehState = rehState; +// ── Library — IndexedDB ──────────────────────────────────────────────────── + +const REH_DB_NAME = 'reh-library'; +const REH_STORE = 'rehearsals'; + +function rehDbOpen() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(REH_DB_NAME, 1); + req.onupgradeneeded = e => { + const db = e.target.result; + if (!db.objectStoreNames.contains(REH_STORE)) { + const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true }); + store.createIndex('updated', 'updated', { unique: false }); + } + }; + req.onsuccess = e => resolve(e.target.result); + req.onerror = e => reject(e.target.error); + }); +} + +async function rehDbOp(mode, fn) { + const db = await rehDbOpen(); + return new Promise((resolve, reject) => { + const tx = db.transaction(REH_STORE, mode); + const store = tx.objectStore(REH_STORE); + const req = fn(store); + req.onsuccess = e => resolve(e.target.result); + req.onerror = e => reject(e.target.error); + }); +} + +async function rehDbAdd(record) { + return rehDbOp('readwrite', s => s.add(record)); +} + +async function rehDbPut(record) { + return rehDbOp('readwrite', s => s.put(record)); +} + +async function rehDbDelete(id) { + return rehDbOp('readwrite', s => s.delete(id)); +} + +async function rehDbGetAll() { + const db = await rehDbOpen(); + return new Promise((resolve, reject) => { + const tx = db.transaction(REH_STORE, 'readonly'); + const req = tx.objectStore(REH_STORE).getAll(); + req.onsuccess = e => resolve(e.target.result || []); + req.onerror = e => reject(e.target.error); + }); +} + +// ── Serialization (export / import) ────────────────────────────────────── + +async function clipsToJson(clips) { + return Promise.all(clips.map(async c => { + if (!c.blob) return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }; + const ab = await c.blob.arrayBuffer(); + const u8 = new Uint8Array(ab); + let bin = ''; + const CHUNK = 8192; + for (let i = 0; i < u8.length; i += CHUNK) + bin += String.fromCharCode(...u8.subarray(i, i + CHUNK)); + return { lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, mime: c.blob.type, b64: btoa(bin) }; + })); +} + +function clipsFromJson(clips) { + return clips.map(c => { + if (!c.b64) return c; + const bin = atob(c.b64); + const u8 = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i); + return { ...c, blob: new Blob([u8], { type: c.mime || 'audio/webm' }), b64: undefined, mime: undefined }; + }); +} + +function rehCurrentRecord() { + const cast = {}; + Object.entries(rehState.cast).forEach(([sp, c]) => { cast[sp] = { voice: c.voice, color: c.color }; }); + return { + title: $('reh-script-title')?.value.trim() || $('reh-page-title')?.textContent || 'Untitled', + script: $('reh-script-text')?.value.trim() || '', + cast, + backend: rehState.backend, + lineIndex: rehState.lineIndex, + clips: rehState.clips.map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type, blob: c.blob || null })), + updated: new Date(), + }; +} + +async function saveToLibrary() { + const rec = rehCurrentRecord(); + if (rehState.savedId) { + rec.id = rehState.savedId; + if (!rec.created) rec.created = new Date(); // preserve original + await rehDbPut(rec); + } else { + rec.created = new Date(); + const newId = await rehDbAdd(rec); + rehState.savedId = newId; + } + toast('Saved to library', 'success'); + await renderLibraryList(); +} + +async function exportToFile() { + const rec = rehCurrentRecord(); + rec.version = 1; + rec.created = rec.created || new Date(); + rec.clips = await clipsToJson(rec.clips); + const json = JSON.stringify(rec, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const a = document.createElement('a'); + const title = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40); + a.href = URL.createObjectURL(blob); + a.download = title + '.reh'; + a.click(); +} + +async function importFromFile(file) { + try { + const text = await file.text(); + const data = JSON.parse(text); + data.clips = clipsFromJson(data.clips || []); + data.created = data.created ? new Date(data.created) : new Date(); + data.updated = new Date(); + delete data.id; + const newId = await rehDbAdd(data); + toast('Imported: ' + (data.title || 'Untitled'), 'success'); + await renderLibraryList(); + loadRecord({ ...data, id: newId }); + } catch(e) { toast('Import failed: ' + e.message, 'error'); } +} + +function loadRecord(rec) { + if ($('reh-script-text')) $('reh-script-text').value = rec.script || ''; + if ($('reh-script-title')) $('reh-script-title').value = rec.title || ''; + rehState.savedId = rec.id || null; + rehState.backend = rec.backend || ''; + rehState.lineIndex = rec.lineIndex || 0; + rehState.clips = (rec.clips || []).map(c => ({ ...c })); + // Parse script and restore cast + const lines = parseScript(rec.script || ''); + rehState.lines = lines; + rehState.cast = {}; + const detected = detectCharacters(lines); + Object.entries(detected).forEach(([sp, def]) => { + const saved = rec.cast?.[sp]; + rehState.cast[sp] = { + voice: saved?.voice ?? def.voice, + color: saved?.color ?? def.color, + voiceData: saved?.voice && saved.voice !== 'me' ? getVoiceData(saved.voice) : null, + }; + }); + renderCastList(); + refreshRehBackends().then(() => { + if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend; + }); + showPhase(2); +} + +// ── Library UI ───────────────────────────────────────────────────────────── + +async function renderLibraryList() { + const list = $('reh-library-list'); if (!list) return; + let all; + try { all = await rehDbGetAll(); } catch(e) { all = []; } + all.sort((a, b) => new Date(b.updated || 0) - new Date(a.updated || 0)); + + if (!all.length) { + list.innerHTML = '

No saved rehearsals yet.
Parse a script below to start a new one.

'; + return; + } + + list.innerHTML = all.map(rec => { + const speakers = Object.keys(rec.cast || {}); + const meCount = speakers.filter(sp => rec.cast[sp]?.voice === 'me').length; + const clipCount = (rec.clips || []).filter(c => c.type === 'me' && c.blob).length; + const total = (parseScript(rec.script || '')).filter(l => !l.isDirection).length; + const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0; + const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—'; + const isCurrent = rec.id === rehState.savedId; + const avatars = speakers.slice(0, 4).map(sp => { + const c = rec.cast[sp]; + const initial = sp[0].toUpperCase(); + return `${initial}`; + }).join(''); + return `
+
+
${avatars}
+
+ ${escHtml(rec.title || 'Untitled')}${isCurrent ? ' active' : ''} +
${total} lines · ${meCount} me · ${clipCount} recorded · ${pct}% done · ${date}
+
+
+
+
+ + + +
+
`; + }).join(''); + + list.querySelectorAll('.reh-lib-load').forEach(btn => btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const db = await rehDbOpen(); + const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); }); + loadRecord(rec); + })); + + list.querySelectorAll('.reh-lib-export').forEach(btn => btn.addEventListener('click', async () => { + const id = parseInt(btn.dataset.id); + const db = await rehDbOpen(); + const rec = await new Promise((res, rej) => { const r = db.transaction(REH_STORE,'readonly').objectStore(REH_STORE).get(id); r.onsuccess=e=>res(e.target.result); r.onerror=e=>rej(e.target.error); }); + rec.version = 1; + rec.clips = await clipsToJson(rec.clips || []); + const json = JSON.stringify(rec, null, 2); + const a = document.createElement('a'); + const t = (rec.title || 'rehearsal').replace(/[^a-z0-9_\- ]/gi, '_').slice(0, 40); + a.href = URL.createObjectURL(new Blob([json], { type: 'application/json' })); + a.download = t + '.reh'; + a.click(); + })); + + list.querySelectorAll('.reh-lib-delete').forEach(btn => btn.addEventListener('click', async () => { + if (!confirm('Delete this rehearsal from the library?')) return; + await rehDbDelete(parseInt(btn.dataset.id)); + if (rehState.savedId === parseInt(btn.dataset.id)) rehState.savedId = null; + renderLibraryList(); + })); +} + const SPEAKER_COLORS = ['#89b4fa','#a6e3a1','#f38ba8','#fab387','#f9e2af','#cba6f7','#89dceb','#74c7ec']; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -102,7 +338,10 @@ $('reh-parse-btn')?.addEventListener('click', () => { if (!rehState.lines.filter(l => !l.isDirection).length) { toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return; } - rehState.cast = detectCharacters(rehState.lines); + rehState.cast = detectCharacters(rehState.lines); + rehState.savedId = null; // brand-new script → not yet saved + rehState.clips = []; + rehState.lineIndex = 0; renderCastList(); showPhase(2); refreshRehBackends(); @@ -585,11 +824,27 @@ function renderSummary() { $('reh-new-session-btn')?.addEventListener('click', () => { stopPlay(); stopRehMic(); - rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0; + rehState.lines=[]; rehState.cast={}; rehState.clips=[]; rehState.lineIndex=0; rehState.savedId=null; if ($('reh-script-text')) $('reh-script-text').value = ''; + if ($('reh-script-title')) $('reh-script-title').value = ''; + renderLibraryList(); showPhase(1); }); $('reh-resume-btn')?.addEventListener('click', () => { showPhase(3); highlightCurrentLine(); }); +$('reh-save-session-btn')?.addEventListener('click', saveToLibrary); +$('reh-export-session-btn')?.addEventListener('click', exportToFile); +$('reh-tb-save')?.addEventListener('click', saveToLibrary); +$('reh-tb-export')?.addEventListener('click', exportToFile); + +$('reh-lib-import-file')?.addEventListener('change', async function () { + const f = this.files?.[0]; if (!f) return; + await importFromFile(f); + this.value = ''; +}); + +// mark savedId on save so new-session resets it +$('reh-parse-btn')?.removeEventListener; // just guard // Init rehRenderMeter(); +renderLibraryList().catch(() => {}); diff --git a/static/sections/s-rehearser.html b/static/sections/s-rehearser.html index 24473bd..db213a9 100644 --- a/static/sections/s-rehearser.html +++ b/static/sections/s-rehearser.html @@ -8,10 +8,26 @@
- +
-

Step 1 — Paste or upload your script

+
+

Rehearsal library

+
+ +
+
+
+

No saved rehearsals yet.
Parse a script below to start a new one.

+
+
+ + +
+

New script

Format: CHARACTER: dialog on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with # are stage directions.

@@ -73,7 +89,11 @@
0 / 0
- +
+ + + +
@@ -125,8 +145,10 @@

All recorded and synthesized clips from this session.

+ + + -
diff --git a/static/style.css b/static/style.css index 814dc6c..553159d 100644 --- a/static/style.css +++ b/static/style.css @@ -2304,6 +2304,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami /* ── Script Rehearser ───────────────────────────────────────────────────── */ .reh-phase[hidden] { display: none; } +/* Library */ +.reh-lib-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } +.reh-lib-list { display: flex; flex-direction: column; gap: 8px; } +.reh-lib-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 28px 0; color: var(--subtext); } +.reh-lib-empty .mdi { font-size: 36px; opacity: .4; } +.reh-lib-empty p { text-align: center; font-size: 13px; line-height: 1.5; margin: 0; } +.reh-lib-row { display: flex; align-items: center; gap: 12px; padding: 10px 12px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; flex-wrap: wrap; } +.reh-lib-current { border-color: var(--accent); background: rgba(137,180,250,.06); } +.reh-lib-row-main { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; } +.reh-lib-avatars { display: flex; gap: 4px; flex-shrink: 0; } +.reh-lib-info { min-width: 0; flex: 1; } +.reh-lib-info strong { display: block; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.reh-lib-progress-wrap { height: 3px; background: var(--border); border-radius: 2px; overflow: hidden; margin-top: 5px; } +.reh-lib-progress { height: 100%; background: var(--accent); } +.reh-lib-row-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; } + /* Phase 1 */ .reh-input-row { display: grid; grid-template-columns: 1fr 200px; gap: 14px; align-items: start; } .reh-input-row textarea { min-height: 220px; font-family: monospace; font-size: 13px; width: 100%; resize: vertical; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; color: var(--text); }