Script Rehearser: library with save / load / import / export
Storage: IndexedDB ('reh-library' DB, 'rehearsals' store).
Blobs (recorded mic audio) stored natively; no base64 overhead.
Library panel (Phase 1):
- List of saved rehearsals sorted by last update
- Each row: character-colour avatars, title, progress bar,
line count, recorded-clip count, date
- Active record highlighted in accent colour
- Open / Export .reh / Delete per-row buttons
- Import .reh button at the top
Transport bar (Phase 3):
- Save button (updates existing record if savedId set, else creates new)
- Export button (downloads current state as .reh JSON file)
Phase 4 (session complete):
- Save to library and Export .reh buttons
Data format (.reh file):
- JSON with version=1, title, script text, cast map, backend, lineIndex
- clips serialized with audio as base64 strings (mime + b64 fields)
- clipsFromJson restores Blob objects on import
Behaviour:
- Parsing a brand-new script resets savedId → safe to save as new record
- loadRecord() restores script, cast, lineIndex, clips and jumps to Phase 2
- renderLibraryList() called on init, after save, after delete, after import
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e8cb4d280a
commit
b64baf2d0b
@ -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 = '<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below to start a new one.</p></div>';
|
||||
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 `<span style="width:22px;height:22px;border-radius:50%;background:${c.color};color:#fff;font-size:10px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0">${initial}</span>`;
|
||||
}).join('');
|
||||
return `<div class="reh-lib-row${isCurrent ? ' reh-lib-current' : ''}" data-id="${rec.id}">
|
||||
<div class="reh-lib-row-main">
|
||||
<div class="reh-lib-avatars">${avatars}</div>
|
||||
<div class="reh-lib-info">
|
||||
<strong>${escHtml(rec.title || 'Untitled')}</strong>${isCurrent ? ' <span class="backend-tag good" style="font-size:10px">active</span>' : ''}
|
||||
<div class="note" style="margin-top:2px">${total} lines · ${meCount} me · ${clipCount} recorded · ${pct}% done · ${date}</div>
|
||||
<div class="reh-lib-progress-wrap"><div class="reh-lib-progress" style="width:${pct}%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reh-lib-row-actions">
|
||||
<button class="btn-primary btn-sm reh-lib-load" data-id="${rec.id}"><span class="mdi mdi-folder-open-outline"></span> Open</button>
|
||||
<button class="btn-secondary btn-sm reh-lib-export" data-id="${rec.id}" title="Export to .reh file"><span class="mdi mdi-export"></span></button>
|
||||
<button class="btn-secondary btn-sm reh-lib-delete" data-id="${rec.id}" title="Delete"><span class="mdi mdi-delete-outline"></span></button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).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 ────────────────────────────────────────────────────────────────
|
||||
@ -103,6 +339,9 @@ $('reh-parse-btn')?.addEventListener('click', () => {
|
||||
toast('No dialog lines found. Check format: "CHARACTER: text" or screenplay.', 'error'); return;
|
||||
}
|
||||
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(() => {});
|
||||
|
||||
@ -8,10 +8,26 @@
|
||||
|
||||
<div class="tab-content" id="tab-rehearser">
|
||||
|
||||
<!-- ── Phase 1: Script input ──────────────────────────────────── -->
|
||||
<!-- ── Phase 1: Library ───────────────────────────────────────── -->
|
||||
<div class="reh-phase" id="reh-phase-1">
|
||||
<div class="card">
|
||||
<h2><span class="mdi mdi-script-text-outline"></span> Step 1 — Paste or upload your script</h2>
|
||||
<div class="reh-lib-header">
|
||||
<h2 style="margin:0"><span class="mdi mdi-bookshelf"></span> Rehearsal library</h2>
|
||||
<div class="btn-row" style="gap:8px;margin:0">
|
||||
<label class="btn-secondary" style="cursor:pointer;display:inline-flex;align-items:center;gap:6px">
|
||||
<span class="mdi mdi-import"></span> Import .reh
|
||||
<input type="file" id="reh-lib-import-file" accept=".reh,.json" style="display:none">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reh-library-list" class="reh-lib-list">
|
||||
<div class="reh-lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No saved rehearsals yet.<br>Parse a script below to start a new one.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Script input -->
|
||||
<div class="card">
|
||||
<h2><span class="mdi mdi-script-text-outline"></span> New script</h2>
|
||||
<p class="card-subtitle">Format: <code>CHARACTER: dialog</code> on one line, or screenplay style (ALL-CAPS character name on its own line, dialog below). Lines starting with <code>#</code> are stage directions.</p>
|
||||
<div class="reh-input-row">
|
||||
<textarea id="reh-script-text" placeholder="ALICE Hello, how are you today? BOB I'm doing great, thank you for asking! ALICE That's wonderful to hear." spellcheck="false"></textarea>
|
||||
@ -73,7 +89,11 @@
|
||||
<div class="reh-tb-progress-wrap"><div class="reh-tb-progress" id="reh-tb-progress"></div></div>
|
||||
<span class="reh-tb-label" id="reh-tb-label">0 / 0</span>
|
||||
</div>
|
||||
<button class="btn-secondary btn-sm" id="reh-exit-btn" style="margin-left:auto">Exit</button>
|
||||
<div style="display:flex;gap:6px;margin-left:auto">
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-save" title="Save rehearsal to library"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||||
<button class="btn-secondary btn-sm" id="reh-tb-export" title="Export to .reh file"><span class="mdi mdi-export"></span></button>
|
||||
<button class="btn-secondary btn-sm" id="reh-exit-btn">Exit</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- A4 script page -->
|
||||
@ -125,8 +145,10 @@
|
||||
<p class="card-subtitle">All recorded and synthesized clips from this session.</p>
|
||||
<div id="reh-summary-list" class="reh-summary-list"></div>
|
||||
<div class="btn-row" style="margin-top:14px">
|
||||
<button class="btn-primary" id="reh-save-session-btn"><span class="mdi mdi-content-save"></span> Save to library</button>
|
||||
<button class="btn-secondary" id="reh-export-session-btn"><span class="mdi mdi-export"></span> Export .reh</button>
|
||||
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-theater"></span> Resume</button>
|
||||
<button class="btn-secondary" id="reh-new-session-btn"><span class="mdi mdi-refresh"></span> New script</button>
|
||||
<button class="btn-secondary" id="reh-resume-btn"><span class="mdi mdi-theater"></span> Resume rehearsal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user