feat: expandable LLM panel + page markers in casting feed; fix page numbering

Casting view improvements:
- "LLM Reading…" row now shows a preview of the passage being processed.
  A chevron button expands it to a full scrollable view of the passage
  text (up to 260px), so you can follow what the LLM is reading live.
- Page-break dividers ("Page N") appear in the casting feed whenever the
  source PDF page changes, giving a real-time view of page boundaries.

Bug fix:
- Page numbers in the Rehearser script were 0-indexed (PDF-internal) so
  the first page break showed "Page 1" for what was PDF page 2, etc.
  Now always emits 1-indexed page numbers (\f${page+1}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-26 22:23:19 +02:00
parent 868ce6a7bd
commit a978e39e49
5 changed files with 79 additions and 16 deletions

View File

@ -9,6 +9,17 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.9.5] — 2026-06-26
### Added
- **Casting view — page-break lines**: as the LLM processes a PDF audiobook, a **"Page N"** divider row now appears in the casting feed whenever the source PDF page changes. This gives a live view of where each page boundary falls within the script.
- **Casting view — expandable LLM passage panel**: the "LLM Reading…" indicator now shows a preview of the passage being processed. A **chevron button** (▾/▴) expands it into a full scrollable view of the passage text so you can follow exactly what the LLM is reading and thinking about.
### Fixed
- **Page numbers were off by one**: page-break markers emitted into the Rehearser script now use the correct 1-indexed PDF page number (was storing 0-indexed, so "Page 1" showed for what was actually the second PDF page).
---
## [1.9.4] — 2026-06-26
### Changed

View File

@ -1 +1 @@
1.9.4
1.9.5

View File

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

View File

@ -772,8 +772,25 @@ STRIKTE FORMAT- UND TEXTREGELN:
processing(text) {
if (this._procRow) this._procRow.remove();
this._procRow = document.createElement('div');
this._procRow.className = 'ab-cv-row is-processing';
this._procRow.innerHTML = `<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading…</span><span class="ab-cv-txt" style="opacity:0.6; font-style:italic;">${escHtml(text.slice(0, 200))}${text.length > 200 ? '…' : ''}</span>`;
this._procRow.className = 'ab-cv-row is-processing ab-cv-llm-row';
const isLong = text.length > 160;
const preview = escHtml(text.slice(0, 160)) + (isLong ? '…' : '');
this._procRow.innerHTML = `
<div class="ab-cv-llm-head">
<span class="ab-cv-spk" style="color:var(--blue)"><span class="mdi mdi-loading mdi-spin"></span> LLM Reading</span>
${isLong ? '<button class="ab-cv-expand-btn" title="Show full passage"><span class="mdi mdi-chevron-down"></span></button>' : ''}
</div>
<div class="ab-cv-llm-preview">${preview}</div>
${isLong ? `<div class="ab-cv-llm-full" hidden><pre class="ab-cv-llm-pre">${escHtml(text)}</pre></div>` : ''}
`;
if (isLong) {
this._procRow.querySelector('.ab-cv-expand-btn').addEventListener('click', function () {
const full = this.closest('.ab-cv-llm-row').querySelector('.ab-cv-llm-full');
full.hidden = !full.hidden;
const icon = this.querySelector('span');
if (icon) icon.className = full.hidden ? 'mdi mdi-chevron-down' : 'mdi mdi-chevron-up';
});
}
feed.appendChild(this._procRow);
trim();
},
@ -807,6 +824,13 @@ STRIKTE FORMAT- UND TEXTREGELN:
r.title = 'There is more text before and after this passage';
feed.appendChild(r); trim();
},
// Page-break marker in the casting feed (from source PDF page boundaries).
pagemark(pageNum) {
const r = document.createElement('div');
r.className = 'ab-cv-pagemark';
r.innerHTML = `<span class="mdi mdi-book-open-page-variant"></span> Page ${pageNum}`;
feed.appendChild(r); trim();
},
// Park the panel in a "done" state with a Review button instead of auto-popping
// the preview — so it waits for you if you wandered off to do something else.
complete(summary, onOpen, onRecast, onRecastUnknown) {
@ -1061,10 +1085,20 @@ async function audiobookCast(overrideUrl, overrideModel) {
const roster = [];
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
// Page-mark tracking for visual page-break rows in the casting feed.
const _pgMarks = (_audiobook.pageMarks || []).slice();
if (_pgMarks.length && _pgMarks[0].offset <= 2) _pgMarks.shift(); // skip page-1 mark at pos 0
let _pgMarkIdx = 0, _pgCharPos = 0;
try {
for (let i = 0; i < chunks.length; i++) {
if (_audiobook.cancel) break;
view.update(i);
// Insert page-break markers in the feed when the source PDF page changes.
while (_pgMarkIdx < _pgMarks.length && _pgCharPos >= _pgMarks[_pgMarkIdx].offset) {
view.pagemark(_pgMarks[_pgMarkIdx].page + 1);
_pgMarkIdx++;
}
_pgCharPos += chunks[i].length + 1; // +1 for joining space between chunks
// No quotation marks anywhere → pure narration; skip the LLM entirely (faster, not an error)
if (!audiobookHasDialogue(chunks[i])) {
const seg = { speaker: 'Narrator', type: 'narration', text: chunks[i], emotion: '' };
@ -1292,8 +1326,7 @@ function audiobookBuildScript(segments) {
if (at >= 0) searchPos = at + probe.length;
while (markIdx < marks.length && pos >= marks[markIdx].offset) {
if (script.trim()) {
const pn = marks[markIdx].page;
script += pn ? `\n\f${pn}\n` : '\n\f\n'; // page break with page number
script += `\n\f${marks[markIdx].page + 1}\n`; // page break — 1-indexed page number
}
markIdx++;
}

View File

@ -4202,11 +4202,10 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
}
.ab-cv-row.is-processing {
font-family: inherit;
display: flex;
align-items: center;
gap: 8px;
display: block;
padding: 4px 8px;
}
.ab-cv-llm-head { display: flex; align-items: center; gap: 6px; }
.ab-cv-row.is-processing .ab-cv-spk {
display: inline-block;
padding-left: 0;
@ -4215,13 +4214,33 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
font-size: 12px;
margin: 0;
}
.ab-cv-row.is-processing .ab-cv-txt {
display: inline-block;
padding-left: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.ab-cv-expand-btn {
border: none; background: none; cursor: pointer;
color: var(--subtext); padding: 0 2px; line-height: 1;
font-size: 15px; border-radius: 4px;
}
.ab-cv-expand-btn:hover { color: var(--text); background: var(--panel); }
.ab-cv-llm-preview {
font-size: 12px; color: var(--subtext); font-style: italic;
padding: 2px 0 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ab-cv-llm-full { margin-top: 6px; }
.ab-cv-llm-pre {
font-family: inherit; font-size: 12px; color: var(--text);
white-space: pre-wrap; word-break: break-word;
background: var(--bg); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; margin: 0;
max-height: 260px; overflow-y: auto;
}
.ab-cv-pagemark {
display: flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 700; letter-spacing: .06em;
color: var(--subtext); text-transform: uppercase;
padding: 6px 4px 2px;
border-top: 1px dashed var(--border);
margin: 4px 0 2px;
}
.ab-cv-pagemark .mdi { font-size: 13px; opacity: .7; }
.ab-cv-txt { color: var(--text); white-space: pre-wrap; word-wrap: break-word; }
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; min-height: 0; }