feat(magazine): fix Edit boxes, add spread view, searchable narrator picker and Prepare audio

- fix: Edit boxes and page-nav controls were dead clicks (handlers never exported
  to root); resize from any edge/corner instead of only bottom-right
- feat: two-page spread in review (page images above, OCR lists beneath)
- feat: searchable voice picker for narrator and podcast hosts
- feat: Prepare audio synthesises the whole article ahead of playback; playback
  also stays three sentences ahead
- fix: sync index.html cache-busting version with VERSION (was stuck at 1.26.12)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-18 23:10:16 +02:00
parent 2dd5cea7b7
commit e3d6cb2cca
8 changed files with 494 additions and 129 deletions

View File

@ -5,6 +5,25 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
---
## [1.31.0] — 2026-09-16
### Fixed
- **"Edit boxes" (added in 1.30.0) silently did nothing — clicking it, or any of its move/resize/delete/save controls, was a dead click.** `magazine.js` wraps the whole module in an IIFE and only exposes functions the inline `onclick=` HTML attributes call by explicitly assigning them onto `root` at the bottom of the file; `magReviewToggleEdit`, `magReviewCancelEditPanel`, `magReviewSubmitEdit`, `magReviewDeleteRegion` and `magReviewClearCurrentPage` were never added to that list, so every one of those clicks threw an unhandled `ReferenceError` in the browser console with no visible feedback in the UI. Added the five missing `root.magReview* = ...` lines.
- **"Edit boxes" could only resize a region from its bottom-right corner**, so two regions couldn't be split correctly along a shared boundary — e.g. a paragraph OCR'd as one box that actually belongs to two, where the fix is dragging *just* the line between them. Replaced the single corner handle with one handle per edge and per corner (`static/js/magazine.js`, `static/style.css`): dragging a side handle (top/bottom/left/right) now moves only that edge, leaving the opposite edge and both other dimensions untouched, so a shared boundary can be corrected on each box independently and then re-OCR'd.
- The "Previous page"/"Next page" (article-scoped) buttons in the page navigator called `magReviewStepPage`, a function that did not exist anywhere in the file (also never exported) — they were also permanently disabled by a `state.reviewPageIdx` that was read but never set anywhere. Implemented `magReviewStepPage`, exported it, and derive the page index from `state.article.pages` at render time instead of the dead field.
### Added
- **Prepare audio in the Magazine reader.** A new button synthesises the whole article ahead of time (two requests in flight, current position first, cancellable, with "Preparing audio n / N" progress) so Listen then plays straight through without waiting sentence by sentence. Even without it, playback now stays three sentences ahead of the current one. Synthesised sentences are cached in memory keyed by voice + text (`magSynth`), so a changed narrator or edited text can never replay stale audio, a failed request is never cached, and switching article discards the cache. Not persisted: a reload loses it.
- **Searchable voice picker for the Narrator and podcast Host A/B/C selects** in the Magazine reader, replacing the plain dropdown with the app-wide picker (`voice-picker.js`). The `<select>`s were moved out of their wrapping `<label>`s: a label forwards clicks to its control, and the picker read that forwarded click as an outside click and closed itself instantly. The picker triggers get `role="button"` and an `aria-label`.
- **Two-page spread view in Magazine review.** A new "Two-page spread" toggle next to the page navigator shows the current page next to its facing page (odd/even print pairing, e.g. 22/23), each with its own OCR-results list directly beneath it — a 2×2 layout matching how an open magazine spread reads. Only one page is draggable/drawable at a time; "Edit this page" on the other half switches which one the toolbar's draw/edit/OCR-again actions apply to, without leaving spread mode. Checkboxes and click-to-highlight work on both pages regardless of which is active.
### Verified
- `node -c static/js/magazine.js` after each edit, plus a Python check that every `mag*` function named in an `onclick=`/`onchange=`/`onmouseenter=`/`onmouseleave=` attribute has a matching `root.mag* = ...` export (was non-empty before this fix, empty after).
- `tests/magazine.spec.js`: picker replaces the select, search filters (incl. the "No voices found" state) and selects; Prepare audio synthesises each of a 4-sentence article exactly once before playback and Listen then makes no further synthesis requests; Prepare audio with no narrator is refused with zero requests. 9/9 on the desktop project (the iPhone project cannot launch in this environment — it pairs a webkit profile with the snap Chromium binary — and fails before any test runs, unrelated to this change).
- Live via Playwright against the real ingested c't KI Wissen 2026 magazine, page 21 (the exact page/article from the bug report): clicked the real "Edit boxes" button (not a direct function call) and confirmed it now activates with no console errors; dragged region 7's bottom ("s") resize handle down ~40px with real mouse events and confirmed only its height changed (top/left/width untouched) and the Save/Save & re-OCR panel appeared; clicked Cancel so no test edit was written to the real article. Toggled "Two-page spread" and confirmed the 2×2 layout (page 20 + page 21 images on top, each page's own OCR-results list directly beneath it, "Edit this page" swapping the active/draggable page).
---
## [1.30.0] — 2026-09-15
### Added

View File

@ -64,14 +64,24 @@ turned into a podcast discussion between two or three voices you pick.
directly on the page image and add it as text (typed or OCRed) or as a
new figure. If a region was found but its box is misplaced, the wrong
size, or shouldn't exist at all, **"Edit boxes"** lets you drag an
existing box to move it, drag its corner handle to resize it, or press
its **×** to delete it outright. Moving or resizing opens a small confirm
panel — **Save** keeps the region's existing text at its new position;
for a text region, **Save & re-OCR** also reads the corrected area again
and replaces the text with that fresh reading (useful when the box was
too small or too large to read cleanly the first time). Deleting a
region removes it from the page entirely, not just from the article
currently under review. Confirm the boundaries, correct the title, then **Approve**.
existing box to move it, drag any of its eight edge/corner handles to
resize just that side, or press its **×** to delete it outright. The
per-edge handles matter most when two regions share a boundary — for
example a paragraph split wrongly between two boxes — since dragging just
the top or bottom handle moves that one shared line without disturbing
either box's width or its opposite edge. Moving or resizing opens a small
confirm panel — **Save** keeps the region's existing text at its new
position; for a text region, **Save & re-OCR** also reads the corrected
area again and replaces the text with that fresh reading (useful when the
box was too small or too large to read cleanly the first time, or after
moving a shared boundary). Deleting a region removes it from the page
entirely, not just from the article currently under review. Use
**"Two-page spread"** to see this page next to its facing page (e.g. 22
and 23), each with its own OCR-results list directly beneath it — useful
for lining up a figure or a jump that crosses the gutter. Only one page
is editable at a time; click **"Edit this page"** on the other half to
switch which one the drawing and editing tools apply to. Confirm the
boundaries, correct the title, then **Approve**.
Optionally, click **"Describe"** under a figure, chart, table or code block
to have the configured vision engine generate a one- or two-sentence
description for listeners who cannot see the image — e.g. what a chart
@ -81,7 +91,17 @@ turned into a podcast discussion between two or three voices you pick.
nothing about existing narration by itself — it is only spoken if you
also turn on "Read figure descriptions" in step 4.
4. **Listen.** An approved article can be narrated with the default voice, or
sent to podcast mode. Use **Play all articles** (on the contents screen) to
sent to podcast mode. Pick the narrator (and the podcast hosts) from the
same searchable voice picker used elsewhere in the app — open it and type
to filter the list. Playback synthesises one sentence at a time, staying a
few sentences ahead, so you only wait for the first. To avoid waiting at
all, press **Prepare audio** first: it synthesises the whole article now
(starting from where you are, then the part before it), shows progress as
"Preparing audio 12 / 24", and can be cancelled with the same button. Once
it says "Audio ready", **Listen** plays straight through from the prepared
audio. Prepared audio is held in the browser only — closing or reloading
the tab, opening another article, or changing the narrator discards it, and
Prepare audio must be run again. Use **Play all articles** (on the contents screen) to
listen through every approved article back to back without returning to
this screen between them. A figure from the page is shown automatically as
playback reaches it. **Word highlighting** can be turned off if you find it

View File

@ -1 +1 @@
1.30.0
1.31.0

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.26.12">
<meta name="app-version" content="1.31.0">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,12 +27,12 @@
<!-- ── 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.26.12">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.26.12">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.26.12">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.26.12">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.26.12">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.26.12">
<link rel="stylesheet" href="/static/style.css?v=1.31.0">
<link rel="stylesheet" href="/static/css/resource-diagnostics.css?v=1.31.0">
<link rel="stylesheet" href="/static/css/background-jobs.css?v=1.31.0">
<link rel="stylesheet" href="/static/css/voice-design.css?v=1.31.0">
<link rel="stylesheet" href="/static/css/rehearser.css?v=1.31.0">
<link rel="stylesheet" href="/static/css/audiobook.css?v=1.31.0">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->

View File

@ -36,6 +36,11 @@ const state = {
drawMode: false, // manually drawing a region OCR/layout missed, in review
reviewSpan: [],
reviewPage: null,
reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread
audioCache: new Map(), // voice+text -> Promise<Blob>, see magSynth
preparing: false, // "Prepare audio" is synthesising the whole article
prepCancel: false,
prepGen: 0, // bumped on article change so a stale prepare run stops touching state
};
MAG.magazine = state;
@ -687,15 +692,20 @@ function magBlockOwner(blockId) {
return null;
}
function renderReview() {
const host = $('mag-review');
// Builds one page's worth of review UI -- the page image with its numbered
// boxes, and the matching OCR-results list -- so the same markup can be used
// for the single active page or, in spread mode, for either half of a
// two-page spread. Only `interactive` (always exactly one page at a time,
// the one the toolbar's draw/edit/OCR-again actions apply to) gets the fixed
// 'mag-review-page-wrap' id that setupMagBoxEdit/setupMagDrawing target and
// the draw-rectangle placeholder; the other page in a spread is shown for
// context with working checkboxes and click-to-focus, but its box outlines
// are not draggable.
function magReviewPageColumn(page, wrapId, interactive) {
const a = state.article;
if (!host || !a) return;
const included = _magReviewChecked || new Set(a.blocks || []);
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
const ownImages = new Set(a.images || []);
const pageMeta = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null;
const pageBlocks = page == null ? [] : blocksForPages([page]);
const pageImages = page == null ? [] : (state.layout?.images || [])
@ -707,20 +717,137 @@ function renderReview() {
.concat(pageImages.map((img) => ({ kind: 'image', id: img.id, ro: img.reading_order ?? 0, data: img })))
.sort((x, y) => x.ro - y.ro);
const spanIdx = (state.reviewSpan || []).indexOf(page);
const prevPage = spanIdx > 0 ? state.reviewSpan[spanIdx - 1] : null;
const nextPage = spanIdx >= 0 && spanIdx < (state.reviewSpan || []).length - 1 ? state.reviewSpan[spanIdx + 1] : null;
const issuePages = (state.layout || {}).page_count || '—';
const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null;
let pageHtml = '';
if (page != null) {
pageHtml = '<div class="mag-review-page-wrap' + (interactive ? '' : ' is-context-page') + '" id="' + wrapId + '">' +
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
(interactive ? '<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>' : '');
regions.forEach((r, i) => {
if (!pageMeta) return;
const bbox = r.data.bbox;
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
const isImage = r.kind === 'image';
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
pageHtml += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') + (r.data.type === 'ad' ? ' is-advertisement' : '') +
'" data-block="' + esc(r.id) + '" data-kind="' + (isImage ? 'image' : 'text') +
'" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h +
'%" onclick="magReviewFocusBlock(\'' + esc(r.id) + '\')" title="' + esc(isImage ? 'figure' : r.data.type) + '">' +
'<span class="mag-review-box-num">' + (i + 1) + '</span>' +
'<span class="mag-review-box-icon mdi ' + icon + '"></span>' +
(owner ? '<span class="mag-review-box-owner">Art. ' + owner.index + '</span>' : '') +
'<button type="button" class="mag-review-box-delete" tabindex="-1" ' +
'aria-label="Delete this region" title="Delete this region" ' +
'onclick="event.stopPropagation(); magReviewDeleteRegion(\'' + esc(r.id) + '\')">' +
'<span class="mdi mdi-close" aria-hidden="true"></span></button>' +
MAG_RESIZE_HANDLES +
'</div>';
});
pageHtml += '</div>';
} else {
pageHtml = '<p class="muted">No page to show.</p>';
}
let listHtml = '<div class="mag-blocks">';
regions.forEach((r, i) => {
const isImage = r.kind === 'image';
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
if (isImage) {
const on = ownImages.has(r.id);
const imgFilename = r.data.filename || (r.id + '.png');
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename);
listHtml += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' +
'<div class="mag-block-thumb-wrap">' +
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
: on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
'</div>' +
magDescribeMarkup(r.id, r.data.description) +
'</div>';
return;
}
const b = r.data;
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
const on = included.has(b.id);
listHtml += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' +
'<span class="mag-block-text">' +
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + '</span></label>';
});
if (!regions.length) listHtml += '<p class="muted">Nothing detected on this page.</p>';
listHtml += '</div>';
return { pageHtml, listHtml };
}
// The natural print-spread partner of a page (odd/even pairing, e.g. 22-23),
// falling back to the other neighbour when the natural one is out of range
// (covers, single-page inserts) so a spread is still offered near either end
// of the issue.
function magSpreadPartner(page, pageCount) {
if (page == null || !pageCount) return null;
const natural = (page % 2 === 0) ? page + 1 : page - 1;
if (natural >= 1 && natural <= pageCount) return natural;
const alt = (page % 2 === 0) ? page - 1 : page + 1;
return (alt >= 1 && alt <= pageCount && alt !== page) ? alt : null;
}
function magReviewToggleSpread() {
state.reviewSpread = !state.reviewSpread;
renderReview();
}
// Switches which half of a two-page spread the toolbar's draw/edit/OCR-again
// actions apply to, without leaving spread mode.
function magReviewSetActivePage(page) {
if (page === state.reviewPage) return;
state.reviewPage = page;
renderReview();
}
function magReviewStepPage(dir) {
const pages = state.article?.pages || [];
const next = pages[pages.indexOf(state.reviewPage) + dir];
if (next == null) return;
state.reviewPage = next;
renderReview();
}
function renderReview() {
const host = $('mag-review');
const a = state.article;
if (!host || !a) return;
const conf = confidenceLabel(a.confidence);
const page = state.reviewPage;
const pageCount = Number(state.layout?.page_count || 0);
const pageIdx = (a?.pages || []).indexOf(page);
const secondaryPage = state.reviewSpread ? magSpreadPartner(page, pageCount) : null;
const spreadPages = secondaryPage != null ? [page, secondaryPage].sort((x, y) => x - y) : null;
const issuePages = pageCount || '—';
const nextIssuePage = page != null && page < pageCount ? page + 1 : null;
const prevIssuePage = page != null && page > 1 ? page - 1 : null;
let html = '';
html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (state.reviewPageIdx > 0 ? '' : 'disabled') +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (pageIdx > 0 ? '' : 'disabled') +
' onclick="magReviewStepPage(-1)"><span class="mdi mdi-chevron-left" aria-hidden="true"></span></button>' +
'<strong class="mag-review-page-number">Page ' + (page ?? '—') + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (state.reviewPageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
'<button type="button" class="btn-sm" aria-label="Next page" ' + (pageIdx >= 0 && pageIdx < (a?.pages || []).length - 1 ? '' : 'disabled') +
' onclick="magReviewStepPage(1)"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Whole magazine pages">' +
'<button type="button" class="btn-sm" aria-label="Previous magazine page" ' + (prevIssuePage ? '' : 'disabled') +
@ -728,6 +855,11 @@ function renderReview() {
'<strong class="mag-review-page-number">Magazine ' + (page ?? '—') + ' / ' + issuePages + '</strong>' +
'<button type="button" class="btn-sm" aria-label="Next magazine page" ' + (nextIssuePage ? '' : 'disabled') +
' onclick="magReviewGoMagazinePage(' + nextIssuePage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></button></div>' +
'<div class="mag-review-pagenav-group" role="group" aria-label="Spread view">' +
'<button type="button" class="btn-sm" id="mag-spread-toggle" aria-pressed="' + String(!!state.reviewSpread) + '" ' +
'title="Show this page next to its facing page, each with its own OCR results below" ' +
'onclick="magReviewToggleSpread()"><span class="mdi mdi-book-open-page-variant" aria-hidden="true"></span> ' +
(state.reviewSpread ? 'Single page' : 'Two-page spread') + '</button></div>' +
'<div class="mag-review-draw-controls" role="group" aria-label="Draw regions">' +
'<button type="button" class="btn-sm" onclick="magReviewToggleDraw()" id="mag-draw-toggle">' +
'<span class="mdi mdi-vector-square-edit" aria-hidden="true"></span> Draw text</button>' +
@ -774,84 +906,35 @@ function renderReview() {
html += '<p class="muted">Set a language model in Settings to recognise advertisements automatically.</p>';
}
html += '<div class="mag-review-split">';
html += '<div class="mag-review-page">';
if (page != null) {
html += '<div class="mag-review-page-wrap" id="mag-review-page-wrap">' +
'<img src="/api/magazine/' + state.doc.id + '/pages/' + page + '" alt="Page ' + page + '">' +
'<div id="mag-draw-rect" class="mag-review-box mag-draw-rect" hidden></div>';
regions.forEach((r, i) => {
if (!pageMeta) return;
const bbox = r.data.bbox;
const left = (bbox[0] / pageMeta.width * 100).toFixed(2);
const top = (bbox[1] / pageMeta.height * 100).toFixed(2);
const w = (bbox[2] / pageMeta.width * 100).toFixed(2);
const h = (bbox[3] / pageMeta.height * 100).toFixed(2);
const isImage = r.kind === 'image';
const on = isImage ? ownImages.has(r.id) : included.has(r.id);
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
html += '<div class="mag-review-box' + (on ? '' : ' is-excluded') + (owner ? ' has-owner' : '') + (r.data.type === 'ad' ? ' is-advertisement' : '') +
'" data-block="' + esc(r.id) + '" data-kind="' + (isImage ? 'image' : 'text') +
'" style="left:' + left + '%;top:' + top + '%;width:' + w + '%;height:' + h +
'%" onclick="magReviewFocusBlock(\'' + esc(r.id) + '\')" title="' + esc(isImage ? 'figure' : r.data.type) + '">' +
'<span class="mag-review-box-num">' + (i + 1) + '</span>' +
'<span class="mag-review-box-icon mdi ' + icon + '"></span>' +
(owner ? '<span class="mag-review-box-owner">Art. ' + owner.index + '</span>' : '') +
'<button type="button" class="mag-review-box-delete" tabindex="-1" ' +
'aria-label="Delete this region" title="Delete this region" ' +
'onclick="event.stopPropagation(); magReviewDeleteRegion(\'' + esc(r.id) + '\')">' +
'<span class="mdi mdi-close" aria-hidden="true"></span></button>' +
'<span class="mag-review-box-resize" aria-hidden="true"></span>' +
'</div>';
if (spreadPages) {
// 2x2 spread: both pages' images on top, each page's own OCR-results
// list directly underneath it -- only the toolbar-active page (state.
// reviewPage) is draggable/drawable; the other is shown for context,
// with "Edit this page" swapping which one is active.
const cols = spreadPages.map((p) => ({
page: p,
interactive: p === page,
...magReviewPageColumn(p, p === page ? 'mag-review-page-wrap' : 'mag-review-page-wrap-alt', p === page),
}));
html += '<div class="mag-review-spread">';
cols.forEach((c) => {
html += '<div class="mag-review-spread-page' + (c.interactive ? ' is-active-page' : '') + '">' +
'<div class="mag-review-spread-pagehead"><strong>Page ' + c.page + '</strong>' +
(c.interactive ? ' <span class="mag-pill ok">Editing</span>'
: ' <button type="button" class="btn-sm" onclick="magReviewSetActivePage(' + c.page + ')">Edit this page</button>') +
'</div>' + c.pageHtml + '</div>';
});
cols.forEach((c) => {
html += '<div class="mag-review-spread-list"><h4 class="mag-page-head">OCR results &mdash; page ' + c.page + '</h4>' + c.listHtml + '</div>';
});
html += '</div>';
} else {
html += '<p class="muted">No page to show.</p>';
const col = magReviewPageColumn(page, 'mag-review-page-wrap', true);
html += '<div class="mag-review-split">' +
'<div class="mag-review-page">' + col.pageHtml + '</div>' +
col.listHtml +
'</div>';
}
html += '</div>';
html += '<div class="mag-blocks">';
regions.forEach((r, i) => {
const isImage = r.kind === 'image';
const owner = magBlockOwner(r.id);
const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type);
if (isImage) {
const on = ownImages.has(r.id);
const imgFilename = r.data.filename || (r.id + '.png');
const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename);
html += '<div class="mag-block mag-block-image' + (on ? '' : ' is-aux') + '" data-block-row="' + esc(r.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(r.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" aria-label="Include image in playback" ' + (on ? 'checked' : '') + ' onchange="magReviewToggleImage(\'' + esc(r.id) + '\', this.checked)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(r.data.type || 'image') + '</span>' +
'<div class="mag-block-thumb-wrap">' +
'<img class="mag-block-thumb" src="' + imgUrl + '" alt="' + esc(r.data.type || 'figure') + '" onerror="this.style.display=\'none\'">' +
'<span class="mag-block-text">' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title)
: on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '</span>' +
'</div>' +
magDescribeMarkup(r.id, r.data.description) +
'</div>';
return;
}
const b = r.data;
const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type);
const on = included.has(b.id);
html += '<label class="mag-block' + (narratable ? '' : ' is-aux') + '" data-block-row="' + esc(b.id) + '" ' +
'onmouseenter="magReviewHighlightBox(\'' + esc(b.id) + '\')" onmouseleave="magReviewHighlightBox(null)">' +
'<span class="mag-block-num">' + (i + 1) + '</span>' +
'<input type="checkbox" ' + (on ? 'checked' : '') + ' data-block="' + esc(b.id) + '" onchange="magReviewToggleBox(this)">' +
'<span class="mag-block-type"><span class="mdi ' + icon + '"></span> ' + esc(b.region_label === 'page_number' ? 'page number' : b.type === 'body' ? 'paragraph' : b.type === 'title' || b.type === 'heading' ? 'headline' : b.type === 'byline' ? 'author' : b.type === 'sidebar' ? 'sidenote' : b.type === 'ad' ? 'advertisement' : b.type) + '</span>' +
'<span class="mag-block-text">' +
(owner ? '<span class="mag-block-owner">Article ' + owner.index + ': ' + esc(owner.title) + '</span> ' : '') +
(b.continues_from_previous_page ? '<span class="mag-block-owner">Continued from previous page</span> ' : '') +
esc(b.text.slice(0, 220)) + (b.text.length > 220 ? '…' : '') + '</span></label>';
});
if (!regions.length) html += '<p class="muted">Nothing detected on this page.</p>';
html += '</div>';
html += '</div>'; // /mag-review-split
html += '<div class="mag-review-foot">' +
'<button type="button" class="btn" onclick="magShow(\'contents\')">Back to contents</button>' +
@ -1034,7 +1117,13 @@ function magReviewToggleEdit() {
magReviewCancelEditPanel();
}
let _magEditState = null; // { blockId, mode: 'move'|'resize', box, startXPct, startYPct, origLeft, origTop, origW, origH }
// A handle per edge/corner so any one boundary (e.g. the line between two
// stacked paragraph blocks) can be dragged on its own, instead of only the
// bottom-right corner moving width and height together.
const MAG_RESIZE_HANDLES = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w'].map((dir) =>
'<span class="mag-review-box-resize rz-' + dir + '" data-dir="' + dir + '" aria-hidden="true"></span>').join('');
let _magEditState = null; // { blockId, mode: 'move'|'resize', dir, box, startXPct, startYPct, origLeft, origTop, origW, origH }
function setupMagBoxEdit() {
const wrap = $('mag-review-page-wrap');
@ -1054,6 +1143,7 @@ function setupMagBoxEdit() {
_magEditState = {
blockId: box.dataset.block,
mode: resizeHandle ? 'resize' : 'move',
dir: resizeHandle ? resizeHandle.dataset.dir : null,
box,
startXPct: (e.clientX - rect.left) / rect.width * 100,
startYPct: (e.clientY - rect.top) / rect.height * 100,
@ -1064,6 +1154,7 @@ function setupMagBoxEdit() {
};
e.preventDefault();
});
const MAG_RESIZE_MIN_PCT = 1;
wrap.addEventListener('mousemove', (e) => {
if (!_magEditState) return;
const rect = wrap.getBoundingClientRect();
@ -1071,14 +1162,29 @@ function setupMagBoxEdit() {
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
const dx = xPct - _magEditState.startXPct;
const dy = yPct - _magEditState.startYPct;
const { box, origLeft, origTop, origW, origH } = _magEditState;
const { box, origLeft, origTop, origW, origH, dir } = _magEditState;
if (_magEditState.mode === 'move') {
const left = Math.max(0, Math.min(100 - origW, origLeft + dx));
const top = Math.max(0, Math.min(100 - origH, origTop + dy));
box.style.left = left + '%'; box.style.top = top + '%';
} else {
const w = Math.max(1, Math.min(100 - origLeft, origW + dx));
const h = Math.max(1, Math.min(100 - origTop, origH + dy));
// Each handle only moves the edges named in its direction, so e.g. the
// "n" (top) handle can pull the boundary between two stacked regions
// up or down without disturbing either one's width or opposite edge.
let left = origLeft, top = origTop, w = origW, h = origH;
if (dir.includes('w')) {
const clampedDx = Math.max(-origLeft, Math.min(origW - MAG_RESIZE_MIN_PCT, dx));
left = origLeft + clampedDx; w = origW - clampedDx;
} else if (dir.includes('e')) {
w = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origLeft, origW + dx));
}
if (dir.includes('n')) {
const clampedDy = Math.max(-origTop, Math.min(origH - MAG_RESIZE_MIN_PCT, dy));
top = origTop + clampedDy; h = origH - clampedDy;
} else if (dir.includes('s')) {
h = Math.max(MAG_RESIZE_MIN_PCT, Math.min(100 - origTop, origH + dy));
}
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
}
});
@ -1590,6 +1696,11 @@ async function magRead(articleId, opts) {
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
state.article = a;
state.audioCache = new Map();
state.prepGen += 1;
state.preparing = false;
state.prepCancel = false;
syncPrepButton();
await ensureLayout();
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
@ -1742,11 +1853,83 @@ function magPeekPage(page) {
showPage(page, null);
}
// ── Audio preparation ────────────────────────────────────────────────────────
// Synthesised sentences are cached as promises keyed by voice + text, so a
// prefetch and playback asking for the same sentence share one request, and a
// changed voice or edited text can never be served stale audio. Held in memory
// only: closing the tab discards it.
const MAG_PREP_CONCURRENCY = 2;
const MAG_LOOKAHEAD = 3;
function magNarratorVoice() {
return ($('mag-voice')?.value || state.doc.default_voice || '').trim();
}
function magSynth(voice, text) {
const key = voice + '' + text;
let p = state.audioCache.get(key);
if (!p) {
p = fetchTtsPreviewBlob(voice, text, 'mp3');
const cache = state.audioCache;
cache.set(key, p);
// A failed request must not be cached, or a retry would replay the failure.
p.catch(() => { if (cache.get(key) === p) cache.delete(key); });
}
return p;
}
function syncPrepButton(done, total) {
const btn = $('mag-prepare');
if (btn) {
btn.innerHTML = state.preparing
? '<span class="mdi mdi-close"></span> Cancel'
: '<span class="mdi mdi-waveform"></span> Prepare audio';
}
const status = $('mag-prep-status');
if (status) status.textContent = state.preparing && total ? 'Preparing audio ' + done + ' / ' + total : '';
}
async function magPrepareAudio() {
if (state.preparing) { state.prepCancel = true; return; }
const voice = magNarratorVoice();
if (!voice) return say('Pick a narrator voice first', 'err');
const total = state.sentences.length;
if (!total) return;
const gen = state.prepGen;
state.preparing = true;
state.prepCancel = false;
syncPrepButton(0, total);
// Start at the current position so Listen is ready soonest, then wrap round
// to the part before it.
const order = Array.from({ length: total }, (_, k) => (state.idx + k) % total);
let next = 0, done = 0, failure = null;
const worker = async () => {
while (!failure && !state.prepCancel && gen === state.prepGen && next < order.length) {
const i = order[next++];
try { await magSynth(voice, state.sentences[i].text); }
catch (e) { failure = { i, message: e.message }; return; }
done += 1;
if (gen === state.prepGen) syncPrepButton(done, total);
}
};
await Promise.all(Array.from({ length: Math.min(MAG_PREP_CONCURRENCY, total) }, worker));
// An article change while this ran already reset the flags -- touching them
// now would clobber a newer run.
if (gen !== state.prepGen) return;
const cancelled = state.prepCancel;
state.preparing = false;
state.prepCancel = false;
syncPrepButton();
if (failure) say('Synthesis failed at sentence ' + (failure.i + 1) + ': ' + failure.message, 'err');
else if (cancelled) say('Audio preparation cancelled', '');
else say('Audio ready — ' + total + ' sentences', 'ok');
}
// ── Playback ─────────────────────────────────────────────────────────────────
async function magPlay() {
if (state.playing) return magPause();
const voice = ($('mag-voice')?.value || state.doc.default_voice || '').trim();
const voice = magNarratorVoice();
if (!voice) return say('Pick a narrator voice first', 'err');
state.playing = true;
state.cancel = false;
@ -1758,7 +1941,16 @@ async function magPlay() {
const sentence = state.sentences[state.idx];
let blob;
try {
blob = await fetchTtsPreviewBlob(voice, sentence.text, 'mp3');
const pending = magSynth(voice, sentence.text);
// Without a full "Prepare audio" run, still stay a few sentences ahead
// so the wait is only ever for the first one.
if (!state.preparing) {
for (let k = 1; k <= MAG_LOOKAHEAD; k++) {
const upcoming = state.sentences[state.idx + k];
if (upcoming) magSynth(voice, upcoming.text).catch(() => {});
}
}
blob = await pending;
} catch (e) {
say('Synthesis failed: ' + e.message, 'err');
break;
@ -1892,16 +2084,37 @@ async function magLoadVoices() {
const id = v.id || v.name || v;
return '<option value="' + esc(id) + '">' + esc(v.name || id) + '</option>';
}).join('');
['mag-voice', 'mag-host-a', 'mag-host-b', 'mag-host-c'].forEach((id) => {
// The app-wide searchable picker (voice-picker.js) hides the <select> but
// keeps it as the source of truth, so every existing `.value` read below
// keeps working. It must be told about programmatic changes -- a bare
// `.value =` does not refresh its trigger label.
const setVoice = (id, value) => {
const el = $(id);
if (el) {
const keep = el.value;
el.innerHTML = options;
if (keep) el.value = keep;
if (!el) return;
if (root.VoicePicker && el.dataset.vpUpgraded) root.VoicePicker.setValue(id, value);
else el.value = value;
};
const voiceLabels = { 'mag-voice': 'Narrator voice', 'mag-host-a': 'Host A voice', 'mag-host-b': 'Host B voice', 'mag-host-c': 'Host C voice' };
Object.keys(voiceLabels).forEach((id) => {
const el = $(id);
if (!el) return;
const keep = el.value;
el.innerHTML = options;
if (root.VoicePicker) {
root.VoicePicker.upgrade(id);
// The picker's trigger is a div, so it needs its own accessible name
// (the <label for> points at the hidden <select>).
const trigger = document.querySelector('.vp-root[data-vp-for="' + id + '"] .vp-trigger');
if (trigger) {
trigger.setAttribute('role', 'button');
trigger.setAttribute('aria-haspopup', 'listbox');
trigger.setAttribute('aria-label', voiceLabels[id]);
}
}
if (keep) setVoice(id, keep);
});
const def = state.doc && state.doc.default_voice;
if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def;
if (def && $('mag-voice') && !$('mag-voice').value) setVoice('mag-voice', def);
}
async function loadPodcastIfAny() {
@ -2108,9 +2321,13 @@ root.magIngest = magIngest;
root.magReview = magReview;
root.magReviewGoPage = magReviewGoPage;
root.magReviewGoMagazinePage = magReviewGoMagazinePage;
root.magReviewStepPage = magReviewStepPage;
root.magReviewToggleSpread = magReviewToggleSpread;
root.magReviewSetActivePage = magReviewSetActivePage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReviewClearCurrentPage = magReviewClearCurrentPage;
root.magReocrPage = magReocrPage;
root.magRefineFigures = magRefineFigures;
root.magReviewToggleImage = magReviewToggleImage;
@ -2119,12 +2336,17 @@ root.magSaveImageDescription = magSaveImageDescription;
root.magReviewToggleDraw = magReviewToggleDraw;
root.magReviewCancelDrawPanel = magReviewCancelDrawPanel;
root.magReviewSubmitDraw = magReviewSubmitDraw;
root.magReviewToggleEdit = magReviewToggleEdit;
root.magReviewCancelEditPanel = magReviewCancelEditPanel;
root.magReviewSubmitEdit = magReviewSubmitEdit;
root.magReviewDeleteRegion = magReviewDeleteRegion;
root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magSeek = magSeek;
root.magPlay = magPlay;
root.magPrepareAudio = magPrepareAudio;
root.magPause = magPause;
root.magSetPane = magSetPane;
root.magPeekPage = magPeekPage;

View File

@ -116,13 +116,19 @@
</div>
<div class="mag-controls">
<label for="mag-voice" class="mag-voice-pick">Narrator
<div class="mag-voice-pick">
<label for="mag-voice">Narrator</label>
<select id="mag-voice"></select>
</label>
</div>
<button type="button" class="btn primary" id="mag-play" onclick="magPlay()"
aria-label="Play narration">
<span class="mdi mdi-play"></span> Listen
</button>
<button type="button" class="btn" id="mag-prepare" onclick="magPrepareAudio()"
title="Synthesise the whole article now, so it plays straight through without waiting sentence by sentence">
<span class="mdi mdi-waveform"></span> Prepare audio
</button>
<span id="mag-prep-status" class="muted" role="status" aria-live="polite"></span>
<span id="mag-read-progress" class="muted" aria-live="polite"></span>
<span id="mag-queue-progress" class="muted" aria-live="polite"></span>
<label class="mag-karaoke-toggle" title="Highlight each word approximately as it is spoken">
@ -159,9 +165,9 @@
<summary><span class="mdi mdi-podcast"></span> Podcast mode &mdash; discuss this article</summary>
<p class="muted">Two or three hosts discuss the article for someone who has not read it. The script is written by the configured LLM from the article text; it will not invent facts, but it is a generated discussion, not the author&rsquo;s words.</p>
<div class="mag-hosts">
<label for="mag-host-a">Host A<select id="mag-host-a"></select></label>
<label for="mag-host-b">Host B<select id="mag-host-b"></select></label>
<label for="mag-host-c">Host C <span class="muted">(optional)</span><select id="mag-host-c"></select></label>
<div class="mag-voice-pick"><label for="mag-host-a">Host A</label><select id="mag-host-a"></select></div>
<div class="mag-voice-pick"><label for="mag-host-b">Host B</label><select id="mag-host-b"></select></div>
<div class="mag-voice-pick"><label for="mag-host-c">Host C <span class="muted">(optional)</span></label><select id="mag-host-c"></select></div>
</div>
<div class="mag-podcast-actions">
<button type="button" class="btn" id="mag-podcast-gen" onclick="magGeneratePodcast()">Generate script</button>

View File

@ -4968,8 +4968,8 @@ input[list="lang-codes"] {
missed entirely. Crosshair cursor cues that a click-drag starts a new
box; the box itself is styled distinctly (solid green, dashed outline)
so it never reads as an already-detected region while being drawn. */
#mag-review-page-wrap.is-drawing { cursor: crosshair; }
#mag-review-page-wrap.is-drawing .mag-review-box:not(.mag-draw-rect) { pointer-events: none; opacity: .18; }
.mag-review-page-wrap.is-drawing { cursor: crosshair; }
.mag-review-page-wrap.is-drawing .mag-review-box:not(.mag-draw-rect) { pointer-events: none; opacity: .18; }
.mag-draw-rect { border: 2px dashed var(--green); background: rgba(22,163,74,.14); cursor: crosshair;
transition: none; z-index: 3; }
#mag-draw-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
@ -4986,15 +4986,27 @@ input[list="lang-codes"] {
from its corner handle, delete with its ×. Handles and the delete button
only render usably in edit mode -- kept in the DOM at all times (rather
than added/removed) so toggling edit mode doesn't cost a re-render. */
#mag-review-page-wrap.is-editing { cursor: default; }
#mag-review-page-wrap.is-editing .mag-review-box { cursor: move; }
.mag-review-page-wrap.is-editing { cursor: default; }
.mag-review-page-wrap.is-editing .mag-review-box { cursor: move; }
.mag-review-box-delete { display: none; position: absolute; top: -9px; right: -9px; width: 18px; height: 18px;
border-radius: 50%; border: 1px solid var(--border); background: var(--red); color: #fff;
font-size: 11px; line-height: 1; align-items: center; justify-content: center; padding: 0; cursor: pointer; z-index: 3; }
#mag-review-page-wrap.is-editing .mag-review-box-delete { display: flex; }
.mag-review-box-resize { display: none; position: absolute; bottom: -5px; right: -5px; width: 12px; height: 12px;
border-radius: 3px; border: 1px solid var(--accent); background: var(--panel); cursor: nwse-resize; z-index: 3; }
#mag-review-page-wrap.is-editing .mag-review-box-resize { display: block; }
.mag-review-page-wrap.is-editing .mag-review-box-delete { display: flex; }
/* One handle per edge/corner -- lets a single boundary shared by two
stacked or side-by-side regions be dragged on its own (e.g. moving where
one paragraph block ends and the next begins) instead of only being able
to resize from the bottom-right corner. */
.mag-review-box-resize { display: none; position: absolute; width: 10px; height: 10px;
border-radius: 3px; border: 1px solid var(--accent); background: var(--panel); z-index: 3; }
.mag-review-page-wrap.is-editing .mag-review-box-resize { display: block; }
.mag-review-box-resize.rz-nw { top: -5px; left: -5px; cursor: nwse-resize; }
.mag-review-box-resize.rz-n { top: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
.mag-review-box-resize.rz-ne { top: -5px; right: -5px; cursor: nesw-resize; }
.mag-review-box-resize.rz-e { top: 50%; right: -5px; margin-top: -5px; cursor: ew-resize; }
.mag-review-box-resize.rz-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
.mag-review-box-resize.rz-s { bottom: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
.mag-review-box-resize.rz-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
.mag-review-box-resize.rz-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; }
#mag-edit-toggle.active { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
.mag-block { display: grid; grid-template-columns: auto auto 90px 1fr; gap: 10px; align-items: start;
@ -5025,6 +5037,21 @@ input[list="lang-codes"] {
.mag-review-page { position: static; }
}
/* ── Two-page spread: page images on top, each page's OCR results directly
underneath it -- a 2x2 grid read top-to-bottom-then-across, matching how
an open magazine spread is laid out. Only one of the two pages is
draggable/drawable at a time (see magReviewSetActivePage); the other is
shown for context with working checkboxes. ───────────────────────────── */
.mag-review-spread { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; align-items: start; }
.mag-review-spread-page { min-width: 0; }
.mag-review-spread-pagehead { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.mag-review-spread-page.is-active-page .mag-review-page-wrap { border-color: var(--accent); }
.mag-review-page-wrap.is-context-page { opacity: .94; }
.mag-review-spread-list { min-width: 0; }
@media (max-width: 980px) {
.mag-review-spread { grid-template-columns: 1fr; }
}
.mag-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 14px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.mag-voice-pick { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--subtext); }

View File

@ -28,6 +28,18 @@ const LAYOUT = {
contents_candidates: [],
};
// Minimal valid 8 kHz mono WAV of silence -- enough for the audio element to
// play and end, without needing a real TTS backend.
function silentWav(ms = 50) {
const rate = 8000, n = Math.floor(rate * ms / 1000);
const buf = Buffer.alloc(44 + n, 128);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n, 4); buf.write('WAVEfmt ', 8);
buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate, 28); buf.writeUInt16LE(1, 32); buf.writeUInt16LE(8, 34);
buf.write('data', 36); buf.writeUInt32LE(n, 40);
return buf;
}
async function navTo(page, section) {
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate((s) => window.navTo(s), section);
@ -138,6 +150,65 @@ test.describe('Magazine reader', () => {
await expect(page.locator('#mag-page-box')).toBeVisible();
});
test('narrator uses the searchable picker; Prepare audio synthesises every sentence once, ahead of playback', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const synthesised = [];
await page.route('**/api/tts-preview', route => {
synthesised.push(route.request().postDataJSON().text);
return route.fulfill({ body: silentWav(), contentType: 'audio/wav' });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
// The plain <select> is replaced by the app-wide searchable picker.
const picker = page.locator('.vp-root[data-vp-for="mag-voice"]');
await expect(picker).toBeVisible();
await expect(page.locator('#mag-voice')).toBeHidden();
await picker.locator('.vp-trigger').click();
await picker.locator('.vp-search').fill('zzz-no-such-voice');
await expect(picker.locator('.vp-empty')).toHaveText('No voices found');
await picker.locator('.vp-search').fill('smoke');
await picker.locator('.vp-item[data-value="smoke_voice"]').click();
await expect(page.locator('#mag-voice')).toHaveValue('smoke_voice');
// Preparing synthesises each of the 4 sentences exactly once, before any playback.
expect(synthesised).toHaveLength(0);
await page.getByRole('button', { name: 'Prepare audio' }).click();
await expect.poll(() => synthesised.length).toBe(4);
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
// Listening afterwards is served entirely from the prepared audio.
await page.locator('#mag-play').click();
await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4', { timeout: 15_000 });
expect(synthesised).toHaveLength(4);
expect(new Set(synthesised).size).toBe(4);
});
test('Prepare audio without a narrator voice is refused and synthesises nothing', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
let calls = 0;
await page.route('**/api/tts-preview', route => { calls += 1; return route.fulfill({ body: silentWav(), contentType: 'audio/wav' }); });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
await page.getByRole('button', { name: 'Prepare audio' }).click();
await page.waitForTimeout(300);
expect(calls).toBe(0);
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
});
test('podcast generation is refused before the article is approved', async ({ page }) => {
await mockBackend(page, { articleStatus: 'detected' });
let podcastCalled = false;