feat: autosave casting progress to localStorage (v1.12.5)

Saves accumulated segments after every chunk. On page refresh or crash,
reopening Cast as Audiobook for the same document restores the session
automatically — shows a banner with completion % and save age.

Manual speaker reassignments in the cast view are also autosaved so
review corrections survive a refresh. Draft clears when the script is
saved to Script Rehearsals or a fresh Recast All is triggered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-27 20:13:36 +02:00
parent 87cac1f5f5
commit 9c975a651c
3 changed files with 89 additions and 5 deletions

View File

@ -9,6 +9,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.5] — 2026-06-27
### Added
- **Autosave for audiobook casting**: progress is saved to localStorage after every passage. A page refresh, browser crash, or accidental close no longer loses hours of casting work — reopening "Cast as audiobook" for the same document restores the session automatically with a banner showing how far it was completed and when it was last saved. Manual speaker corrections made in the cast view are also autosaved immediately. The draft is cleared when the script is explicitly saved to Script Rehearsals.
---
## [1.12.4] — 2026-06-27 ## [1.12.4] — 2026-06-27
### Added ### Added

View File

@ -26,7 +26,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.12.4"> <link rel="stylesheet" href="/static/style.css?v=1.12.5">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -336,7 +336,7 @@
<script src="/static/vendor/wavesurfer-regions.min.js"></script> <script src="/static/vendor/wavesurfer-regions.min.js"></script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.12.4"></script> <script src="/static/loader.js?v=1.12.5"></script>
</body> </body>
</html> </html>

View File

@ -14,6 +14,50 @@
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
const _audiobook = { running: false, cancel: false }; const _audiobook = { running: false, cancel: false };
// ── Autosave / draft recovery ─────────────────────────────────────────────────
// Saves the accumulated segments to localStorage after every chunk so a page
// refresh or browser crash doesn't lose hours of casting work.
const _AB_DRAFT_KEY = 'ttsvc_ab_draft';
function _abTextId(text) {
// Cheap fingerprint — no need for a full hash; length + first/last chars is enough
// to detect doc changes without storing the source text itself.
const s = (text.slice(0, 300) + text.slice(-300)).replace(/\s+/g, '');
let h = 5381;
for (let i = 0; i < s.length; i++) h = (((h << 5) + h) ^ s.charCodeAt(i)) >>> 0;
return h.toString(36) + '_' + text.length;
}
function _abSaveDraft(segs, roster, text, done, total) {
try {
localStorage.setItem(_AB_DRAFT_KEY, JSON.stringify({
textId: _abTextId(text),
segments: segs,
roster: roster,
pageMarks: _audiobook.pageMarks || [],
done: done,
total: total,
savedAt: Date.now()
}));
} catch (_) {} // storage full / private mode — silently skip
}
function _abLoadDraft(text) {
try {
const raw = localStorage.getItem(_AB_DRAFT_KEY);
if (!raw) return null;
const d = JSON.parse(raw);
if (!d || !Array.isArray(d.segments) || !d.segments.length) return null;
if (d.textId !== _abTextId(text)) return null; // different document
return d;
} catch (_) { return null; }
}
function _abClearDraft() {
try { localStorage.removeItem(_AB_DRAFT_KEY); } catch (_) {}
}
// Opening/closing quote glyphs across book conventions: English "..."/“...”, // Opening/closing quote glyphs across book conventions: English "..."/“...”,
// German »...«/„...“, French «...», single .../..., CJK 「...」『...』, em-dash speech. // German »...«/„...“, French «...», single .../..., CJK 「...」『...』, em-dash speech.
const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/; const AB_DIALOGUE_RE = /[«»„“”"‟‚‘’›‹『「<]|(?:^|\n)\s*[—–]\s/;
@ -642,6 +686,10 @@ STRIKTE FORMAT- UND TEXTREGELN:
renderRoster(); renderRoster();
toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success'); toast(`Assigned to ${isNarrator ? 'Narrator' : name}`, 'success');
closeAssignPopup(); closeAssignPopup();
// Persist manual corrections immediately so a refresh doesn't lose them
if (_audiobook.lastText) {
setTimeout(() => _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1), 50);
}
} }
feed.addEventListener('click', e => { feed.addEventListener('click', e => {
@ -1122,10 +1170,9 @@ function audiobookOpenCastView() {
? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS) ? splitTextIntoChunks(text, AUDIOBOOK_CHUNK_CHARS)
: [text]; : [text];
// If the user navigates away and back, restore the view instead of clearing their work // 1. In-memory restore — user navigated away and came back in the same session
if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) { if (_audiobook.segments && _audiobook.segments.length > 0 && _audiobook.lastText === text) {
const view = audiobookCastView(chunks.length, llm_url, model, false); const view = audiobookCastView(chunks.length, llm_url, model, false);
// we need to set the roster manually so color generation matches
view.addSegments(_audiobook.segments); view.addSegments(_audiobook.segments);
const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const speakers = new Set(_audiobook.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`; const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_audiobook.segments.length} segments`;
@ -1133,7 +1180,33 @@ function audiobookOpenCastView() {
view.update(chunks.length); view.update(chunks.length);
return; return;
} }
// 2. localStorage draft restore — survived a page refresh or browser crash
const _draft = _abLoadDraft(text);
if (_draft && _draft.segments && _draft.segments.length > 0) {
_audiobook.segments = _draft.segments;
_audiobook.roster = _draft.roster || [];
_audiobook.lastText = text;
_audiobook.pageMarks = _draft.pageMarks || [];
const view = audiobookCastView(chunks.length, llm_url, model, false);
view.addSegments(_draft.segments);
const ageMs = Date.now() - (_draft.savedAt || 0);
const ageMins = Math.round(ageMs / 60000);
const ageStr = ageMins < 1 ? 'just now' : ageMins < 60 ? `${ageMins}m ago` : `${Math.round(ageMins / 60)}h ago`;
const pct = _draft.total > 0 ? Math.round((_draft.done / _draft.total) * 100) : 100;
const wasDone = _draft.done === _draft.total || _draft.done < 0;
view.note(`🔄 Session restored from autosave — ${pct}% complete, saved ${ageStr}.${!wasDone ? ' Casting was interrupted; click "Recast all" to fill in the rest, or "Review & cast" to work with what\'s here.' : ''}`);
const speakers = new Set(_draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_draft.segments.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
view.update(_draft.done > 0 ? _draft.done : chunks.length);
return;
}
// 3. Fresh start
audiobookCastView(chunks.length, llm_url, model, true); audiobookCastView(chunks.length, llm_url, model, true);
} }
@ -1148,6 +1221,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
: [text]; : [text];
_audiobook.running = true; _audiobook.cancel = false; _audiobook.running = true; _audiobook.cancel = false;
_abClearDraft(); // fresh cast — discard any previous draft for this document
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(true);
const ac = new AbortController(); const ac = new AbortController();
_audiobook.abort = () => ac.abort(); _audiobook.abort = () => ac.abort();
@ -1322,6 +1396,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); }); segs.forEach(s => { const speakerName = (s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator') ? 'Narrator' : s.speaker; if (!/^Unknown|Unbekannt/i.test(speakerName) && !roster.includes(speakerName)) roster.push(speakerName); });
segs.forEach(s => allSegments.push(s)); segs.forEach(s => allSegments.push(s));
view.addSegments(segs); view.addSegments(segs);
_abSaveDraft(allSegments, roster, text, i + 1, chunks.length);
} }
view.update(chunks.length); view.update(chunks.length);
} finally { } finally {
@ -1335,6 +1410,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
_audiobook.roster = roster; _audiobook.roster = roster;
_audiobook.narratedPassages = narrationOnly; _audiobook.narratedPassages = narrationOnly;
_audiobook.degraded = degraded; _audiobook.degraded = degraded;
_abSaveDraft(allSegments, roster, text, chunks.length, chunks.length); // mark 100% complete
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
@ -1592,6 +1668,7 @@ async function audiobookSaveAsRehearsal() {
if (typeof rehDbAdd === 'function') { if (typeof rehDbAdd === 'function') {
try { try {
await rehDbAdd(rec); await rehDbAdd(rec);
_abClearDraft(); // work is committed to Rehearser — draft no longer needed
toast('Saved as Script Rehearsal', 'success'); toast('Saved as Script Rehearsal', 'success');
if (typeof renderLibraryList === 'function') renderLibraryList(); if (typeof renderLibraryList === 'function') renderLibraryList();
} catch (e) { } catch (e) {