Fix audiobook stop casting state

This commit is contained in:
mARTin-B78 2026-06-30 21:04:53 +02:00
parent 14d3e12ec8
commit 32a3c838fe
4 changed files with 133 additions and 15 deletions

View File

@ -9,6 +9,14 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
--- ---
## [1.12.57] — 2026-06-30
### Fixed
- **Audiobook stop-casting flow** — stopping a book-to-audiobook cast now keeps the casting panel open, aborts the active request cleanly, and preserves completed passages instead of dropping back to the empty Reader import view.
- **Interrupted cast drafts** — partial audiobook casts now save their real completed chunk count so recovery no longer treats an interrupted cast as 100% complete.
---
## [1.12.56] — 2026-06-30 ## [1.12.56] — 2026-06-30
### Fixed ### Fixed

View File

@ -1 +1 @@
1.12.56 1.12.57

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.12.56"> <meta name="app-version" content="1.12.57">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,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.56"> <link rel="stylesheet" href="/static/style.css?v=1.12.57">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -362,7 +362,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </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.56"></script> <script src="/static/loader.js?v=1.12.57"></script>
</body> </body>
</html> </html>

View File

@ -418,10 +418,26 @@ STRIKTE FORMAT- UND TEXTREGELN:
} }
}; };
const setStoppingState = () => {
const cancelBtn = panel.querySelector('#ab-cv-cancel');
if (cancelBtn) {
cancelBtn.disabled = true;
cancelBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Stopping...';
cancelBtn.title = 'Stopping the current casting request';
}
const status = panel.querySelector('#ab-cv-status-msg');
if (status) {
status.style.display = 'inline-block';
status.textContent = 'Stopping... completed passages will stay saved.';
}
};
panel.querySelector('#ab-cv-cancel').addEventListener('click', () => { panel.querySelector('#ab-cv-cancel').addEventListener('click', () => {
if (_audiobook.running) { if (_audiobook.running) {
_audiobook.cancel = true; _audiobook.cancel = true;
setStoppingState();
if (typeof _audiobook.abort === 'function') _audiobook.abort(); if (typeof _audiobook.abort === 'function') _audiobook.abort();
return;
} }
closePanel(); closePanel();
}); });
@ -1463,7 +1479,59 @@ ABSOLUTE REGELN:
panel.classList.add('ab-castpanel-done'); panel.classList.add('ab-castpanel-done');
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false); if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
}, },
done() { closePanel(); }, done(options = {}) {
const stopped = options.stopped || _audiobook.cancel;
if (!stopped) { closePanel(); return; }
this.clearProcessing();
if (count) count.hidden = true;
const castBar = panel.querySelector('.ab-castpanel-bar');
if (castBar) castBar.hidden = true;
feed.querySelector('.ab-skel-feed')?.remove();
chars.querySelector('.ab-skel-chars')?.remove();
const message = options.message || 'Casting stopped before any passage completed.';
if (!feed.querySelector('.ab-cv-row, .ab-cv-note, .ab-cv-divider, .ab-cv-pagemark')) {
feed.innerHTML = '';
const r = document.createElement('div');
r.className = 'ab-cv-note';
r.textContent = message;
feed.appendChild(r);
} else if (options.message) {
const r = document.createElement('div');
r.className = 'ab-cv-note';
r.textContent = message;
feed.appendChild(r);
}
if (!chars.querySelector('.ab-char-item')) {
chars.innerHTML = '<span class="ab-cv-empty">No completed characters yet.</span>';
}
const foot = panel.querySelector('#ab-cv-foot');
const hasSegments = Array.isArray(_audiobook.segments) && _audiobook.segments.length > 0;
foot.hidden = false;
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-stop-circle-outline"></span> ${escHtml(message)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-stopped-back" style="margin-right:8px;"><span class="mdi mdi-arrow-left"></span> Back to reader</button>${hasSegments ? '<button class="btn-secondary btn-sm" id="ab-cv-stopped-review" style="margin-right:8px;"><span class="mdi mdi-drama-masks"></span> Review cast</button>' : ''}<button class="btn-primary btn-sm" id="ab-cv-stopped-recast"><span class="mdi mdi-refresh"></span> Cast again</button>`;
foot.querySelector('#ab-cv-stopped-back')?.addEventListener('click', closePanel);
foot.querySelector('#ab-cv-stopped-review')?.addEventListener('click', audiobookShowPreview);
foot.querySelector('#ab-cv-stopped-recast')?.addEventListener('click', () => {
const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value;
const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim();
const currentModel = panel.querySelector('#ab-cv-llm-select')?.value;
if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt;
fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ audiobook_prompt: newPrompt || '' })
}).finally(() => {
closePanel();
audiobookCast(currentUrl, currentModel);
});
});
panel.classList.add('ab-castpanel-done');
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
},
// Show fresh "Ready to cast" state (used when server draft fetch returns empty) // Show fresh "Ready to cast" state (used when server draft fetch returns empty)
setFreshState() { setFreshState() {
feed.querySelector('.ab-skel-feed')?.remove(); feed.querySelector('.ab-skel-feed')?.remove();
@ -1523,7 +1591,14 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
}) })
}); });
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; } if (err.name === 'AbortError') {
_audiobook.cancel = true;
_audiobook.running = false;
_audiobook.abort = null;
view.done({ stopped: true, message: 'Recast stopped before any lines were updated.' });
toast('Recast stopped. Existing cast preserved.', 'info');
return;
}
} }
let done = 0; let done = 0;
@ -1567,7 +1642,7 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || e.error || r.statusText); }
data = await r.json(); data = await r.json();
} catch (err) { } catch (err) {
if (err.name === 'AbortError') break; if (err.name === 'AbortError') { _audiobook.cancel = true; break; }
view.note(`API Error: ${err.message}`); view.note(`API Error: ${err.message}`);
continue; continue;
} }
@ -1582,14 +1657,20 @@ async function audiobookRecastUnknown(overrideUrl, overrideModel) {
view.addSegments([targetSeg]); view.addSegments([targetSeg]);
} }
view.update(unknownIdxs.length); view.update(_audiobook.cancel ? done : unknownIdxs.length);
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message); if (e.name !== 'AbortError') view.note('Error recasting unknown: ' + e.message);
} finally { } finally {
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null;
} }
if (_audiobook.cancel) { view.done(); toast('Recast cancelled', 'error'); return; } if (_audiobook.cancel) {
if (_audiobook.lastText) _abSaveDraft(_audiobook.segments || [], _audiobook.roster || [], _audiobook.lastText, -1, -1);
view.done({ stopped: true, message: 'Recast stopped. Existing cast preserved.' });
toast('Recast stopped. Existing cast preserved.', 'info');
return;
}
const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const speakers = new Set(segs.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`; const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${segs.length} segments`;
@ -1718,7 +1799,14 @@ async function audiobookCast(overrideUrl, overrideModel) {
}) })
}); });
} catch (err) { } catch (err) {
if (err.name === 'AbortError') { _audiobook.running = false; view.done(); return; } if (err.name === 'AbortError') {
_audiobook.cancel = true;
_audiobook.running = false;
_audiobook.abort = null;
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
toast('Casting stopped before any passages were saved.', 'info');
return;
}
} }
const allSegments = []; const allSegments = [];
@ -1726,6 +1814,7 @@ async function audiobookCast(overrideUrl, overrideModel) {
const roster = []; const roster = [];
let narrationOnly = 0; // passages with no quotes at all — legitimately all narration let narrationOnly = 0; // passages with no quotes at all — legitimately all narration
let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted let degraded = 0; // passages with dialogue the LLM couldn't analyse → quotes auto-extracted
let completedChunks = 0;
// Page-mark tracking for visual page-break rows in the casting feed. // Page-mark tracking for visual page-break rows in the casting feed.
const _pgMarks = (_audiobook.pageMarks || []).slice(); const _pgMarks = (_audiobook.pageMarks || []).slice();
if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0 if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0
@ -1744,6 +1833,8 @@ async function audiobookCast(overrideUrl, overrideModel) {
if (!audiobookHasDialogue(chunks[i])) { if (!audiobookHasDialogue(chunks[i])) {
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' }; const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
allSegments.push(seg); narrationOnly++; view.addSegments([seg]); allSegments.push(seg); narrationOnly++; view.addSegments([seg]);
completedChunks = i + 1;
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
continue; continue;
} }
// recent attributed dialogue → lets the LLM continue turn-taking across the boundary // recent attributed dialogue → lets the LLM continue turn-taking across the boundary
@ -1872,28 +1963,47 @@ 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); completedChunks = i + 1;
_abSaveDraft(allSegments, roster, text, completedChunks, chunks.length);
}
view.update(_audiobook.cancel ? completedChunks : chunks.length);
} catch (err) {
if (err.name === 'AbortError') {
_audiobook.cancel = true;
view.note('Casting stopped. Completed passages were preserved.');
} else {
throw err;
} }
view.update(chunks.length);
} finally { } finally {
_audiobook.running = false; _audiobook.running = false;
_audiobook.abort = null;
} }
if (!allSegments.length) { view.done(); toast('No segments produced', 'error'); return; } if (!allSegments.length) {
if (_audiobook.cancel) {
view.done({ stopped: true, message: 'Casting stopped before any passage completed.' });
toast('Casting stopped before any passages were saved.', 'info');
return;
}
view.done();
toast('No segments produced', 'error');
return;
}
_audiobook.segments = allSegments; _audiobook.segments = allSegments;
_audiobook.lastText = text; _audiobook.lastText = text;
_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 _abSaveDraft(allSegments, roster, text, _audiobook.cancel ? completedChunks : chunks.length, chunks.length);
audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB audiobookSaveAsRehearsal({ silent: true }); // persist to Rehearser IndexedDB
if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info'); if (_audiobook.cancel) toast('Casting stopped early. Progress preserved.', 'info');
// Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off) // Park the panel with a "Review & cast" button (don't auto-pop, in case you wandered off)
const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker)); const speakers = new Set(allSegments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`; const stoppedEarly = _audiobook.cancel && completedChunks < chunks.length;
const summary = `${stoppedEarly ? 'Stopped early · ' : ''}${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${allSegments.length} segments`;
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown); view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
} }