tts-voice-creator-clone-and.../static/js/magazine.js
mARTin-B78 c1d86ec13d
Some checks are pending
Test and SonarQube / quality (push) Waiting to run
fix: return All magazines to library
2026-09-09 17:14:27 +02:00

1590 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Magazine reader — contents tree, article review, dual reading views,
// sentence-highlighted playback and podcast mode.
//
// The canonical object is the ARTICLE, not the page (see
// architecture/magazine-ingestion.md). Nothing is narrated until a human has
// approved the detected boundaries, so the review screen is on the critical
// path rather than an optional extra.
//
// Two views stay linked: the reflowed article (comfortable reading, sentence
// highlighting) and the original PDF page (the visual source of truth). Every
// block carries page + bbox, so either view can jump to the other.
(function (root) {
'use strict';
const MAG = root.TTVC = root.TTVC || {};
const state = {
docs: [],
doc: null, // { id, title, issue, ingest, ... }
articles: [],
layout: null, // { page_count, pages[], blocks[] }
article: null, // the article being read or reviewed
sentences: [], // [{ text, blockId, page, bbox }]
images: [], // current article's figures, in reading order
view: 'library', // library | contents | review | read
pane: 'reflow', // reflow | pdf
playing: false,
cancel: false,
idx: 0, // current sentence
audio: null,
voices: [],
podcast: null,
queue: [], // article ids for "play all" (empty = not queued)
queueIndex: -1,
driving: false, // large-control, minimal-interaction view
karaoke: true, // word-level highlighting (approximate, see estimateWordTimings)
drawMode: false, // manually drawing a region OCR/layout missed, in review
reviewSpan: [],
reviewPage: null,
};
MAG.magazine = state;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g,
(c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
function say(msg, kind) { if (typeof toast === 'function') toast(msg, kind || ''); }
async function api(path, opts) {
const r = await fetch(path, opts);
if (!r.ok) {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (_) {}
throw new Error(detail);
}
const type = r.headers.get('content-type') || '';
return type.includes('json') ? r.json() : r;
}
function fmtDuration(seconds) {
const s = Math.max(0, Math.round(seconds || 0));
if (s < 60) return s + ' sec';
const m = Math.round(s / 60);
return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
}
function fmtPages(pages) {
if (!pages || !pages.length) return '—';
if (pages.length === 1) return 'p. ' + pages[0];
// Non-contiguous page lists are the norm once a feature jumps, and showing
// "18–88" for pages [18, 88] would misdescribe it as a 70-page article.
const runs = [];
let start = pages[0], prev = pages[0];
for (let i = 1; i <= pages.length; i++) {
if (pages[i] === prev + 1) { prev = pages[i]; continue; }
runs.push(start === prev ? String(start) : start + '–' + prev);
start = prev = pages[i];
}
return 'pp. ' + runs.join(', ');
}
// The live OCR view is intentionally split: a portrait source page needs more
// horizontal room than a dense recognised-text pane on some screens, while on
// others the text is the thing being inspected. Keep a per-browser choice so
// the user does not have to re-adjust it for every magazine/page.
function setupMagIngestPreviewResize() {
const preview = $('mag-ingest-preview');
const grip = $('mag-ingest-preview-resize');
if (!preview || !grip || preview.dataset.resizeWired) return;
preview.dataset.resizeWired = '1';
const storageKey = 'ttsvc_mag_ingest_page_pane_pct';
const apply = (value) => {
const pct = Math.max(30, Math.min(70, Number(value) || 50));
preview.style.gridTemplateColumns = `${pct}% 8px calc(${100 - pct}% - 8px)`;
return pct;
};
try {
const saved = localStorage.getItem(storageKey);
if (saved != null) apply(saved);
} catch (_) {}
grip.addEventListener('pointerdown', (event) => {
if (window.matchMedia('(max-width: 860px)').matches) return;
event.preventDefault();
preview.classList.add('is-resizing');
const move = (e) => {
const rect = preview.getBoundingClientRect();
apply(((e.clientX - rect.left) / rect.width) * 100);
};
const stop = (e) => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
preview.classList.remove('is-resizing');
const rect = preview.getBoundingClientRect();
const pct = apply(((e.clientX - rect.left) / rect.width) * 100);
try { localStorage.setItem(storageKey, String(pct)); } catch (_) {}
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop, { once: true });
});
}
// ── Library ──────────────────────────────────────────────────────────────────
async function magLoadLibrary() {
const data = await api('/api/reader/docs');
state.docs = (data.docs || []).filter((d) => d.kind === 'magazine');
renderLibrary();
}
function renderLibrary() {
const host = $('mag-library-list');
if (!host) return;
if (!state.docs.length) {
host.innerHTML =
'<p class="muted" style="padding:18px 0">No magazines yet. Add one to get started — ' +
'upload a PDF and the articles are detected for you.</p>';
return;
}
host.innerHTML = state.docs.map((d) => {
const ing = d.ingest || {};
const badge = ing.status === 'done'
? '<span class="mag-pill ok">' + (ing.articles || 0) + ' articles</span>'
: ing.status === 'running' ? '<span class="mag-pill warn">ingesting…</span>'
: ing.status === 'failed' ? '<span class="mag-pill bad">failed</span>'
: '<span class="mag-pill">not ingested</span>';
const thumbnail = '<img class="mag-card-thumb" src="/api/magazine/' + encodeURIComponent(d.id) +
'/pages/1" alt="First page of ' + esc(d.title || 'magazine') +
'" loading="lazy" onerror="this.hidden=true">';
return '<button type="button" class="mag-card" onclick="magOpen(\'' + d.id + '\')">' +
thumbnail + '<span class="mag-card-title">' + esc(d.title || 'Untitled') + '</span>' +
'<span class="mag-card-sub">' + esc(d.issue || '') + '</span>' + badge + '</button>';
}).join('');
}
// ── Add-magazine dropzone (mirrors reader.js's setupReaderDropzone) ─────────
const MAG_DROPZONE_DEFAULT_NOTE = 'Scanned issues work too — pages without selectable text are read with OCR.';
function magShowDroppedFile(file) {
const noteEl = $('mag-dropzone-note');
if (noteEl) noteEl.textContent = file ? file.name : MAG_DROPZONE_DEFAULT_NOTE;
}
let _magDropGuardInstalled = false;
function setupMagDropzone() {
const zone = $('mag-dropzone');
const input = $('mag-new-file');
if (!zone || !input) return;
// A page-wide safety net: if a drop ever lands outside the zone (or the
// zone's own handler below fails to intercept it for any reason), the
// browser's native behaviour is to navigate to/open the dropped file --
// confirmed live as exactly what happened before this existed. Installed
// once regardless of which section is active, since a stray drop can land
// anywhere on the page.
if (!_magDropGuardInstalled) {
_magDropGuardInstalled = true;
window.addEventListener('dragover', (e) => { e.preventDefault(); });
window.addEventListener('drop', (e) => { e.preventDefault(); });
}
if (zone.dataset.magDropzoneWired) return;
zone.dataset.magDropzoneWired = '1';
// dragenter AND dragover both need preventDefault() -- some browsers only
// arm the element as a valid drop target once dragenter itself has been
// cancelled, not just the dragover events that follow it. Not gated on
// dataTransfer.types: during a real OS file drag, Chromium does not
// always populate that list synchronously on every one of these events,
// and gating on it here (confirmed live) silently skipped preventDefault
// often enough that neither the hover highlight nor the drop interception
// ever fired -- the browser fell through to opening the file itself.
const armDropTarget = (e) => { e.preventDefault(); zone.classList.add('dragover'); };
zone.addEventListener('dragenter', armDropTarget);
zone.addEventListener('dragover', armDropTarget);
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('dragover');
const file = e.dataTransfer?.files?.[0];
if (!file) return;
if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) {
return say('Only PDF files are supported', 'err');
}
// Route the dropped file through the same hidden <input> magCreate()
// already reads, so drag-and-drop and "Choose file" share one code path.
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
magShowDroppedFile(file);
});
input.addEventListener('change', () => magShowDroppedFile(input.files?.[0]));
}
async function magCreate() {
const title = ($('mag-new-title')?.value || '').trim();
const issue = ($('mag-new-issue')?.value || '').trim();
const file = $('mag-new-file')?.files?.[0];
if (!title) return say('Give the magazine a title', 'err');
if (!file) return say('Choose or drop the PDF', 'err');
try {
const { id } = await api('/api/magazine/docs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, issue }),
});
await api('/api/reader/docs/' + id + '/source?ext=pdf', { method: 'PUT', body: file });
say('Magazine added — starting ingestion', 'ok');
if ($('mag-new-title')) $('mag-new-title').value = '';
if ($('mag-new-issue')) $('mag-new-issue').value = '';
if ($('mag-new-file')) $('mag-new-file').value = '';
magShowDroppedFile(null);
await magOpen(id);
await magIngest();
} catch (e) {
say('Could not add magazine: ' + e.message, 'err');
}
}
// ── Ingestion ────────────────────────────────────────────────────────────────
// Remembers which magazine was open so a full page reload or the browser's
// own Back button (which, unlike in-app navigation, tears down all JS state)
// lands back on it instead of the empty library — confirmed live as the
// cause of a user re-uploading the same PDF, creating a duplicate under a
// completely different kind ("book", via Studio/Reader's own import) because
// the magazine they had been working on appeared to have vanished.
const MAG_LAST_DOC_KEY = 'ttsvc_mag_last_doc';
async function magOpen(docId) {
try {
state.doc = await api('/api/reader/docs/' + docId);
state.doc.id = docId;
await magRefreshArticles();
magShow('contents');
try { localStorage.setItem(MAG_LAST_DOC_KEY, docId); } catch (_) {}
if ((state.doc.ingest || {}).status === 'running') pollIngest();
} catch (e) {
say('Could not open magazine: ' + e.message, 'err');
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
}
}
function magForgetLastDoc() {
try { localStorage.removeItem(MAG_LAST_DOC_KEY); } catch (_) {}
}
async function magIngest() {
if (!state.doc) return;
try {
await api('/api/magazine/' + state.doc.id + '/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
pollIngest();
} catch (e) {
say('Ingestion could not start: ' + e.message, 'err');
}
}
// "OCR again" for the whole issue: forces every page through the vision
// engine regardless of its current quality, then re-detects articles --
// which is why this asks for confirmation. A single page's "OCR again" in
// review does not touch article grouping; re-reading the whole document
// does, since detection runs again over the freshly re-read text.
async function magReocrMagazine() {
if (!state.doc) return;
const hasReviewed = state.articles.some((a) => a.status !== 'detected');
if (hasReviewed && !confirm(
'Re-reading the whole magazine runs article detection again. ' +
'Any articles you already approved or rejected will need reviewing again. Continue?'
)) return;
try {
await api('/api/magazine/' + state.doc.id + '/ingest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force_vision_ocr: true }),
});
pollIngest();
} catch (e) {
say('Could not start OCR: ' + e.message, 'err');
}
}
// "Analysing page 28 of 204" -> a real percentage, not just spinner text.
function magParsePageProgress(detail) {
const m = /page (\d+) of (\d+)/i.exec(detail || '');
if (!m) return null;
const page = parseInt(m[1], 10), total = parseInt(m[2], 10);
if (!total) return null;
return { page, total, pct: Math.min(100, Math.round((page / total) * 100)) };
}
async function pollIngest() {
const bar = $('mag-ingest-status');
const preview = $('mag-ingest-preview');
if (bar) { bar.hidden = false; bar.className = 'mag-status is-running'; }
let lastPreviewPage = null;
for (;;) {
let data;
try {
data = await api('/api/magazine/' + state.doc.id + '/ingest/status');
} catch (_) { break; }
const ing = data.ingest || {};
const detail = (data.job || {}).detail || '';
if (bar) {
if (ing.status === 'running') {
bar.className = 'mag-status is-running';
const p = magParsePageProgress(detail);
bar.innerHTML =
'<div class="mag-ingest-row">' +
'<span class="mdi mdi-progress-clock mag-ingest-icon"></span>' +
'<div class="mag-ingest-text">' +
'<strong>' + esc(detail || 'Analysing…') + '</strong>' +
(p ? '<div class="mag-ingest-track"><div class="mag-ingest-fill" style="width:' + p.pct + '%"></div></div>' : '') +
'</div>' +
(p ? '<span class="mag-ingest-pct">' + p.pct + '%</span>' : '') +
'</div>';
} else if (ing.status === 'failed') {
bar.className = 'mag-status is-failed';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-alert mag-ingest-icon"></span>' +
'<span>Ingestion failed: ' + esc(ing.error || '') + '</span></div>';
} else {
bar.className = 'mag-status is-done';
bar.innerHTML = '<div class="mag-ingest-row"><span class="mdi mdi-check-circle mag-ingest-icon"></span>' +
'<span>' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected</span></div>';
}
}
// Live "source page / recognised text" preview, side by side -- same
// idea as ocr-baidu-unlimited-ocr's own demo UI. Only swaps the image
// when the page actually changed, so it doesn't flicker on every poll.
if (preview) {
if (ing.status === 'running' && data.preview && data.preview.page) {
preview.hidden = false;
if (data.preview.page !== lastPreviewPage) {
lastPreviewPage = data.preview.page;
const img = $('mag-ingest-preview-img');
if (img) img.src = '/api/magazine/' + state.doc.id + '/pages/' + data.preview.page;
}
const textEl = $('mag-ingest-preview-text');
if (textEl) textEl.textContent = data.preview.text || '';
} else if (ing.status !== 'running') {
preview.hidden = true;
}
}
if (ing.status !== 'running') {
if (ing.status === 'done') await magRefreshArticles();
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
}
async function magRefreshArticles() {
if (!state.doc) return;
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles');
state.articles = data.articles || [];
state.doc.progress = data.progress || {};
state.doc.podcast = data.podcast || { hosts: [] };
state.doc.default_voice = data.default_voice || '';
} catch (_) {
state.articles = [];
}
renderContents();
}
// ── Contents tree ────────────────────────────────────────────────────────────
function confidenceLabel(c) {
if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents' };
if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries' };
return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article' };
}
function renderContents() {
const host = $('mag-contents');
if (!host) return;
const title = $('mag-doc-title');
if (title && state.doc) {
title.textContent = (state.doc.title || 'Magazine') +
(state.doc.issue ? ' — ' + state.doc.issue : '');
}
if (!state.articles.length) {
host.innerHTML = '<p class="muted">No articles yet. Run ingestion to detect them.</p>';
return;
}
const bySection = new Map();
state.articles.forEach((a) => {
const key = a.section || 'Articles';
if (!bySection.has(key)) bySection.set(key, []);
bySection.get(key).push(a);
});
let html = '';
bySection.forEach((arts, section) => {
html += '<div class="mag-section"><h4>' + esc(section) + '</h4><ul class="mag-toc">';
arts.forEach((a) => {
const conf = confidenceLabel(a.confidence);
const done = a.status === 'approved';
const progress = (state.doc.progress || {})[a.id];
html += '<li class="mag-toc-row' + (done ? ' is-approved' : '') + '">' +
'<div class="mag-toc-main">' +
'<span class="mag-toc-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
'</div>' +
'<div class="mag-toc-meta">' +
'<span>' + fmtPages(a.pages) + '</span>' +
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
Math.round(a.confidence * 100) + '%</span>' +
'<span class="mag-pill ' + (done ? 'ok' : '') + '">' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '</span>' +
'</div>' +
'<div class="mag-toc-actions">' +
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
(done
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
(progress ? 'Continue' : 'Listen') + '</button>'
: '') +
'</div></li>';
});
html += '</ul></div>';
});
host.innerHTML = html;
}
// ── Review (Stage D) ─────────────────────────────────────────────────────────
async function magReview(articleId) {
const article = state.articles.find((a) => a.id === articleId);
if (!article) return;
state.article = article;
await ensureLayout();
// The whole review is one page at a time -- showing every page of a
// 10-page feature stacked at once was unreadable and made the boxes tiny.
state.reviewSpan = magReviewSpan(article);
state.reviewPage = article.pages[0] || state.reviewSpan[0] || null;
_magReviewChecked = null;
magShow('review');
renderReview();
}
async function ensureLayout() {
if (state.layout) return;
try {
state.layout = await api('/api/magazine/' + state.doc.id + '/layout');
} catch (e) {
say('Could not load layout: ' + e.message, 'err');
state.layout = { blocks: [], pages: [], page_count: 0 };
}
}
function blocksForPages(pages) {
const want = new Set(pages || []);
return (state.layout.blocks || [])
.filter((b) => want.has(b.page))
.sort((a, b) => a.page - b.page || a.reading_order - b.reading_order);
}
// Offer neighbouring pages too, so a boundary that started a page late (or a
// jump tail that should have been included) can be corrected without
// leaving the screen.
function magReviewSpan(article) {
if (!article.pages.length) return [];
return Array.from(new Set(article.pages.concat(
[article.pages[0] - 1, article.pages[article.pages.length - 1] + 1])))
.filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y);
}
function magReviewGoPage(page) {
if (!state.reviewSpan.includes(page)) return;
state.reviewPage = page;
renderReview();
}
// One glyph per region kind, shown centred in its box -- the fastest way to
// tell "a paragraph of text" from "a photo" from "furniture that will not be
// read" without reading the label. No table detection exists yet (P2), so a
// table currently reads as its own text region like any other paragraph.
function magBlockIcon(type) {
if (type === 'image' || type === 'figure') return 'mdi-image-outline';
if (type === 'pullquote') return 'mdi-format-quote-close';
if (type === 'chart') return 'mdi-chart-bar';
if (type === 'table') return 'mdi-table';
if (type === 'code') return 'mdi-code-tags';
if (type === 'page_number') return 'mdi-numeric';
if (type === 'furniture' || type === 'ad') return 'mdi-close-box-outline';
if (type === 'caption') return 'mdi-image-text';
return 'mdi-text-box-outline';
}
// A page a feature spans can share space with a neighbouring piece (the
// tail of the previous article, the start of the next). Blocks belonging to
// the article being reviewed need no extra label -- their checkbox already
// says so -- but a block claimed by a DIFFERENT already-detected article is
// otherwise easy to mistake for stray, unclaimed text. Returns null for the
// current article or for a block no article has claimed.
function magBlockOwner(blockId) {
const current = state.article;
for (let i = 0; i < state.articles.length; i++) {
const art = state.articles[i];
if (art.id === current.id) continue;
if ((art.blocks || []).includes(blockId) || (art.images || []).includes(blockId)) {
return { index: i + 1, title: art.title };
}
}
return null;
}
function renderReview() {
const host = $('mag-review');
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 = (state.layout.pages || []).find((p) => p.page === page);
const pageBlocks = page == null ? [] : blocksForPages([page]);
const pageImages = page == null ? [] : (state.layout.images || [])
.filter((img) => img.page === page).sort((x, y) => (x.reading_order || 0) - (y.reading_order || 0));
const ownImages = new Set(a.images || []);
// One merged, reading-order-sorted list so numbering on both sides matches
// regardless of whether an item is text or a figure.
const visualBlockIds = new Set(pageImages.map(i => i.source_block).filter(Boolean));
const regions = pageBlocks.filter(b => !visualBlockIds.has(b.id)).map((b) => ({ kind: 'block', id: b.id, ro: b.reading_order, data: b }))
.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;
let html =
'<div class="mag-review-head">' +
'<label>Title<input id="mag-rev-title" type="text" value="' + esc(a.title) + '"></label>' +
'<label>Author<input id="mag-rev-author" type="text" value="' + esc(a.author) + '"></label>' +
'<div class="mag-pill ' + conf.cls + '">' + esc(conf.text) + '</div>' +
'</div>' +
'<p class="muted">Untick anything that is not part of this article — a sidebar, an advert, ' +
'or a neighbouring piece. Only ticked text is read aloud. Click a numbered region on the page, ' +
'or its matching entry on the right, to line the two up. A region labelled with another ' +
'article belongs to a different piece sharing this page. Draw a missing text or image region below.</p>';
html += '<div class="mag-review-pagenav">' +
'<div class="mag-review-page-controls" role="group" aria-label="Review pages">' +
'<button type="button" class="btn-sm" aria-label="Previous page" ' + (prevPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + prevPage + ')"><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" ' + (nextPage ? '' : 'disabled') +
' onclick="magReviewGoPage(' + nextPage + ')"><span class="mdi mdi-chevron-right" aria-hidden="true"></span></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>' +
'<button type="button" class="btn-sm" id="mag-draw-image-toggle" onclick="magReviewToggleDraw(\'image\')">' +
'<span class="mdi mdi-image-outline" aria-hidden="true"></span> Draw image</button></div>' +
'<span class="mag-review-pagenav-spacer"></span>' +
'<button type="button" class="btn-sm" id="mag-reocr-btn" onclick="magReocrPage()">' +
'<span class="mdi mdi-text-recognition"></span> OCR again (this page)</button>' +
'<span id="mag-reocr-status" class="muted"></span>' +
'</div><div id="mag-draw-hint" role="status"></div>';
if (state.layout.semantic_review === 'partial_failure') {
html += '<p class="muted" role="status">Some regions could not be grouped automatically. Check paragraph boundaries before approving.</p>';
}
if (state.layout.advertisement_review === 'partial_failure') {
html += '<p class="muted" role="status">Advertisement detection could not finish. Check this page for adverts before approving.</p>';
} else if (state.layout.advertisement_review === 'not_configured') {
html += '<p class="muted">Set a language model in Settings to recognise advertisements automatically.</p>';
}
html += '<div class="mag-review-split">';
// Left: the rendered page with a numbered box per region.
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) + '" 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>' : '') +
'</div>';
});
html += '</div>';
} else {
html += '<p class="muted">No page to show.</p>';
}
html += '</div>';
// Right: the same regions as text/figures, numbered to match.
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);
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>' +
'<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>';
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>' +
'<button type="button" class="btn" onclick="magSetStatus(\'rejected\')">Not an article</button>' +
'<button type="button" class="btn primary" onclick="magApprove()">Approve</button>' +
'</div>';
host.innerHTML = html;
setupMagDrawing();
}
// Tracks every ticked block across ALL reviewed pages of this article, not
// just the one currently on screen -- switching pages must not forget a
// change made on a previous one.
let _magReviewChecked = null;
function magReviewToggleBox(input) {
if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []);
if (input.checked) _magReviewChecked.add(input.dataset.block);
else _magReviewChecked.delete(input.dataset.block);
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(input.dataset.block) + '"]');
if (box) box.classList.toggle('is-excluded', !input.checked);
}
function magReviewToggleImage(id, checked) {
const selected = new Set(state.article.images || []);
if (checked) selected.add(id); else selected.delete(id);
state.article.images = Array.from(selected);
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(id) + '"]');
if (box) box.classList.toggle('is-excluded', !checked);
const row = document.querySelector('[data-block-row="' + CSS.escape(id) + '"]');
if (row) {
row.classList.toggle('is-aux', !checked);
row.querySelector('.mag-block-text').textContent = checked ? 'Shown during playback for this article.' : 'Not included in playback for this article.';
}
}
function magReviewFocusBlock(blockId) {
const cb = document.querySelector('#mag-review input[data-block="' + CSS.escape(blockId) + '"]');
if (state.drawMode) return;
if (cb) { cb.checked = !cb.checked; magReviewToggleBox(cb); }
const row = document.querySelector('#mag-review [data-block-row="' + CSS.escape(blockId) + '"]');
if (row) row.scrollIntoView({ block: 'nearest', behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}
function magReviewHighlightBox(blockId) {
document.querySelectorAll('.mag-review-box.is-linked').forEach((el) => el.classList.remove('is-linked'));
if (!blockId) return;
const box = document.querySelector('.mag-review-box[data-block="' + CSS.escape(blockId) + '"]');
if (box) box.classList.add('is-linked');
}
// ── Draw a region by hand ────────────────────────────────────────────────
// Escape hatch for when the OCR/layout pipeline missed a region entirely --
// not for correcting one it already found (tick/untick or "OCR again"
// cover that). Percent-of-image coordinates while dragging, converted to
// PDF-point page coordinates (matching every other bbox in the layout) only
// once the user commits the region.
function magReviewToggleDraw(kind = 'text') {
state.drawMode = state.drawMode && state.drawKind === kind ? false : true;
state.drawKind = kind;
const hint = $('mag-draw-hint');
if (hint) hint.textContent = state.drawMode ? ' Drag around the whole ' + (kind === 'image' ? 'image or chart' : 'text region') + '. Press Escape to cancel.' : '';
const wrap = $('mag-review-page-wrap');
if (wrap) wrap.classList.toggle('is-drawing', state.drawMode);
const btn = $('mag-draw-toggle');
if (btn) { btn.classList.toggle('active', state.drawMode && kind === 'text'); btn.setAttribute('aria-pressed', String(state.drawMode && kind === 'text')); }
const imageBtn = $('mag-draw-image-toggle');
if (imageBtn) { imageBtn.classList.toggle('active', state.drawMode && kind === 'image'); imageBtn.setAttribute('aria-pressed', String(state.drawMode && kind === 'image')); }
magReviewCancelDrawPanel();
}
let _magDrawState = null; // { startXPct, startYPct } while dragging
let _magDrawMouseupInstalled = false;
function setupMagDrawing() {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
if (state.drawMode) wrap.classList.add('is-drawing');
// wrap itself is rebuilt fresh by every renderReview() (host.innerHTML
// replaces the whole subtree), so listeners on it never accumulate --
// only the window-level one below needs the one-time guard, since window
// is never replaced.
wrap.addEventListener('mousedown', (e) => {
if (!state.drawMode || e.button !== 0 || e.target.closest('.mag-draw-panel')) return;
magReviewCancelDrawPanel();
const rect = wrap.getBoundingClientRect();
_magDrawState = {
startXPct: (e.clientX - rect.left) / rect.width * 100,
startYPct: (e.clientY - rect.top) / rect.height * 100,
};
e.preventDefault();
});
wrap.addEventListener('mousemove', (e) => {
if (!_magDrawState) return;
const rect = wrap.getBoundingClientRect();
const xPct = Math.max(0, Math.min(100, (e.clientX - rect.left) / rect.width * 100));
const yPct = Math.max(0, Math.min(100, (e.clientY - rect.top) / rect.height * 100));
const box = $('mag-draw-rect');
if (!box) return;
const left = Math.max(0, Math.min(_magDrawState.startXPct, xPct));
const top = Math.max(0, Math.min(_magDrawState.startYPct, yPct));
const w = Math.abs(xPct - _magDrawState.startXPct);
const h = Math.abs(yPct - _magDrawState.startYPct);
box.hidden = false;
box.style.left = left + '%'; box.style.top = top + '%';
box.style.width = w + '%'; box.style.height = h + '%';
});
if (!_magDrawMouseupInstalled) {
_magDrawMouseupInstalled = true;
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.drawMode) magReviewToggleDraw(state.drawKind);
});
// $('mag-draw-rect') is looked up fresh on every mouseup, so this one
// listener keeps working correctly across every renderReview() rebuild
// without needing to be re-attached.
window.addEventListener('mouseup', (e) => {
if (!_magDrawState) return;
// Capture the release position even when the pointer left the page.
$('mag-review-page-wrap')?.dispatchEvent(new MouseEvent('mousemove', { clientX: e.clientX, clientY: e.clientY }));
const box = $('mag-draw-rect');
_magDrawState = null;
if (!box || box.hidden) return;
const w = parseFloat(box.style.width), h = parseFloat(box.style.height);
if (w < 1 || h < 1) { box.hidden = true; return; } // too small to be intentional
magReviewOpenDrawPanel(box);
});
}
}
function magReviewCancelDrawPanel() {
_magDrawState = null;
const box = $('mag-draw-rect');
if (box) box.hidden = true;
const panel = document.querySelector('.mag-draw-panel');
if (panel) panel.remove();
}
function magReviewOpenDrawPanel(box) {
const wrap = $('mag-review-page-wrap');
if (!wrap) return;
box.hidden = false;
const panel = document.createElement('div');
panel.className = 'mag-draw-panel';
panel.innerHTML =
'<div class="mag-draw-panel-row">' +
'<label><input type="radio" name="mag-draw-kind" value="text" ' + (state.drawKind !== 'image' ? 'checked' : '') + '> Text</label>' +
'<label><input type="radio" name="mag-draw-kind" value="image" ' + (state.drawKind === 'image' ? 'checked' : '') + '> Image</label>' +
'</div>' +
'<label id="mag-draw-exclude"' + (state.drawKind === 'image' ? '' : ' hidden') + '><input id="mag-draw-exclude-check" type="checkbox" checked> Exclude enclosed text from narration</label>' +
'<textarea id="mag-draw-text"' + (state.drawKind === 'image' ? ' hidden' : '') + ' rows="2" placeholder="Type the text, or leave blank and use OCR"></textarea>' +
'<div class="mag-draw-panel-row">' +
'<button type="button" class="btn-sm" onclick="magReviewCancelDrawPanel()">Cancel</button>' +
'<button type="button" class="btn-sm primary" onclick="magReviewSubmitDraw()">' +
'<span class="mdi mdi-plus" aria-hidden="true"></span> Add region</button>' +
'</div>';
wrap.appendChild(panel);
const top = parseFloat(box.style.top), left = parseFloat(box.style.left);
panel.style.top = Math.min(85, top + parseFloat(box.style.height) + 1) + '%';
panel.style.left = Math.min(60, left) + '%';
wrap.querySelectorAll('input[name="mag-draw-kind"]').forEach((r) => {
r.addEventListener('change', () => {
const ta = $('mag-draw-text');
if (ta) ta.hidden = r.value === 'image' && r.checked;
$('mag-draw-exclude').hidden = r.value !== 'image';
});
});
}
async function magReviewSubmitDraw() {
const box = $('mag-draw-rect');
const pageMeta = (state.layout.pages || []).find((p) => p.page === state.reviewPage);
if (!box || !pageMeta) return;
const kind = document.querySelector('input[name="mag-draw-kind"]:checked')?.value || 'text';
const text = ($('mag-draw-text')?.value || '').trim();
const bbox = [
parseFloat(box.style.left) / 100 * pageMeta.width,
parseFloat(box.style.top) / 100 * pageMeta.height,
parseFloat(box.style.width) / 100 * pageMeta.width,
parseFloat(box.style.height) / 100 * pageMeta.height,
];
const excludeText = kind === 'image' && $('mag-draw-exclude-check')?.checked;
const article = state.article;
const page = state.reviewPage;
const oldIds = new Set((kind === 'image' ? state.layout.images || [] : state.layout.blocks || []).map(r => r.id));
article.title = $('mag-rev-title').value;
article.author = $('mag-rev-author').value;
if (!_magReviewChecked) _magReviewChecked = new Set(article.blocks || []);
magReviewCancelDrawPanel();
state.drawMode = false;
say('Adding region…', '');
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/regions', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ bbox, kind, text, ocr: kind === 'text' && !text }),
});
jobId = r.job_id;
} catch (e) {
return say('Could not add region: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (e) { return say('Could not check region: ' + e.message, 'err'); }
if (job.done) {
if (job.error) return say('Could not add region: ' + job.error, 'err');
else say('Region added', 'ok');
break;
}
await new Promise((r) => setTimeout(r, 1500));
}
state.layout = null;
await ensureLayout();
if (state.article !== article) return;
const added = (kind === 'image' ? state.layout.images || [] : state.layout.blocks || [])
.filter(r => r.page === page && !oldIds.has(r.id));
if (kind === 'image') {
article.images = [...new Set((article.images || []).concat(added.map(r => r.id)))];
if (excludeText) {
for (const b of state.layout.blocks || []) {
const [x, y, w, h] = b.bbox;
if (b.page === page && x >= bbox[0] && y >= bbox[1] &&
x + w <= bbox[0] + bbox[2] && y + h <= bbox[1] + bbox[3]) _magReviewChecked.delete(b.id);
}
}
} else added.forEach(r => _magReviewChecked.add(r.id));
renderReview();
}
function selectedBlockIds() {
if (_magReviewChecked) return Array.from(_magReviewChecked);
return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked'))
.map((el) => el.dataset.block);
}
async function magReocrPage() {
const page = state.reviewPage;
if (page == null) return;
const btn = $('mag-reocr-btn');
const statusEl = $('mag-reocr-status');
if (btn) btn.disabled = true;
if (statusEl) statusEl.textContent = 'Reading page ' + page + '…';
let jobId;
try {
const r = await api('/api/magazine/' + state.doc.id + '/pages/' + page + '/reocr', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
jobId = r.job_id;
} catch (e) {
if (statusEl) statusEl.textContent = '';
if (btn) btn.disabled = false;
return say('Could not start OCR: ' + e.message, 'err');
}
for (;;) {
let job;
try { job = await api('/api/jobs/' + jobId); } catch (_) { break; }
if (job.done) {
if (job.error) {
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
return say('OCR failed: ' + job.error, 'err');
}
else say('Page ' + page + ' re-read', 'ok');
break;
}
await new Promise((r) => setTimeout(r, 2000));
}
if (btn) btn.disabled = false;
if (statusEl) statusEl.textContent = '';
state.layout = null;
await ensureLayout();
const articleId = state.article.id;
await magRefreshArticles();
state.article = state.articles.find(a => a.id === articleId) || state.article;
_magReviewChecked = null;
renderReview();
}
async function magApprove() {
const a = state.article;
if (!a) return;
const blocks = selectedBlockIds();
if (!blocks.length) return say('Tick at least one block before approving', 'err');
try {
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
blocks,
images: a.images || [],
status: 'approved',
title: ($('mag-rev-title')?.value || a.title).trim(),
author: ($('mag-rev-author')?.value || a.author).trim(),
}),
});
Object.assign(a, updated);
say('Approved — ready to listen', 'ok');
await magRefreshArticles();
magShow('contents');
} catch (e) {
say('Could not approve: ' + e.message, 'err');
}
}
async function magSetStatus(status) {
const a = state.article;
if (!a) return;
try {
const updated = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
});
Object.assign(a, updated);
await magRefreshArticles();
magShow('contents');
} catch (e) {
say('Could not update: ' + e.message, 'err');
}
}
// ── Reading: sentences, both views ───────────────────────────────────────────
// Sentence splitting keeps each piece tied to its source block, so highlighting
// in the reflowed view can drive the page shown in the PDF view.
function splitSentences(blocks) {
const out = [];
blocks.forEach((b) => {
const parts = String(b.text || '')
.split(/(?<=[.!?…])\s+(?=[A-ZÀ-Ü»„"'‘])/)
.map((s) => s.trim()).filter(Boolean);
(parts.length ? parts : [b.text]).forEach((text) => {
if (text && text.trim()) {
out.push({
text: text.trim(), blockId: b.id, page: b.page, bbox: b.bbox, type: b.type,
// Used to pick "the image reached so far" during playback (see
// currentImageIndex) -- the backend already computes this per
// block relative to the whole page, not just this article.
readingOrder: b.reading_order || 0,
words: text.trim().split(/\s+/).filter(Boolean),
});
}
});
});
return out;
}
// The current article's figures, in reading order, resolved from the full
// layout (fetched once) filtered to this article's own image ids. Kept
// separate from state.sentences because an image has no sentence of its
// own -- it is matched to "the most recent sentence at or before it".
function articleImages(article) {
const ids = new Set(article.images || []);
return (state.layout.images || [])
.filter((img) => ids.has(img.id))
.sort((a, b) => (a.reading_order || 0) - (b.reading_order || 0));
}
// The image to show once sentence `i` has been reached: the last image
// whose position in the page's reading order is at or before that
// sentence's own block. Falls back to the first image before any text has
// caught up to it, so a lead photo is not left blank at the very start.
function currentImageIndex(i) {
if (!state.images.length) return -1;
const order = (state.sentences[i] || {}).readingOrder || 0;
let found = -1;
for (let k = 0; k < state.images.length; k++) {
if ((state.images[k].reading_order || 0) <= order) found = k;
else break;
}
return found === -1 ? 0 : found;
}
function renderCurrentImage(i) {
const host = $('mag-read-image');
if (!host) return;
const idx = currentImageIndex(i);
if (idx < 0) { host.hidden = true; return; }
const img = state.images[idx];
if (state._shownImageId === img.id) { host.hidden = false; return; }
state._shownImageId = img.id;
host.hidden = false;
host.innerHTML = '<img src="/api/magazine/' + state.doc.id + '/images/' + encodeURIComponent(img.filename) +
'" alt="Figure from page ' + img.page + '" loading="lazy">';
}
async function magRead(articleId, opts) {
const a = state.articles.find((x) => x.id === articleId);
if (!a) return;
state.article = a;
await ensureLayout();
try {
const data = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/text?captions=' +
($('mag-read-captions')?.checked ? 1 : 0));
state.sentences = splitSentences(data.blocks || []);
} catch (e) {
return say('Could not load article text: ' + e.message, 'err');
}
state.images = articleImages(a);
state._shownImageId = null;
const forceStart = opts && opts.fromStart;
const saved = !forceStart && (state.doc.progress || {})[a.id];
state.idx = saved && saved.sentence < state.sentences.length ? saved.sentence : 0;
magShow('read');
renderArticle();
loadPodcastIfAny();
}
function renderArticle() {
const host = $('mag-article');
const a = state.article;
if (!host || !a) return;
$('mag-read-title').textContent = a.title || 'Untitled';
$('mag-read-by').textContent = a.author ? 'By ' + a.author : '';
$('mag-read-pages').textContent = fmtPages(a.pages);
host.innerHTML = state.sentences.map((s, i) => {
const tag = s.type === 'title' ? 'h2' : s.type === 'deck' ? 'p class="mag-deck"'
: s.type === 'byline' ? 'p class="mag-byline"' : s.type === 'caption'
? 'p class="mag-caption"' : 'span';
const open = tag.split(' ')[0];
// Each word gets its own span so playback can highlight it individually
// (see estimateWordTimings) -- sentence-level highlighting alone doesn't
// need this, but word-level does and this markup serves both.
const wordsHtml = s.words.map((w, j) =>
'<span class="mag-w" data-w="' + j + '">' + esc(w) + '</span>').join(' ');
return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' +
'onclick="magSeek(' + i + ')">' + wordsHtml + '</' + open + '> ';
}).join('');
highlight(state.idx);
}
function highlight(i) {
document.querySelectorAll('#mag-article [data-i]').forEach((el) => {
el.classList.toggle('is-current', Number(el.dataset.i) === i);
});
clearWordHighlight();
const el = document.querySelector('#mag-article [data-i="' + i + '"]');
if (el && !prefersReducedMotion()) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
else if (el) el.scrollIntoView({ block: 'center' });
const s = state.sentences[i];
if (s && state.pane === 'pdf') showPage(s.page, s.bbox);
renderCurrentImage(i);
const counter = $('mag-read-progress');
if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length;
const qText = state.queue.length ? 'Article ' + (state.queueIndex + 1) + ' / ' + state.queue.length : '';
['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => {
const el = $(id);
if (el) el.textContent = qText;
});
}
function clearWordHighlight() {
document.querySelectorAll('#mag-article .mag-w.is-current').forEach((el) => el.classList.remove('is-current'));
}
// ── Word-level highlighting (approximate) ────────────────────────────────
// No forced-alignment or TTS timestamp source exists in this pipeline, so
// per-word timing is ESTIMATED, not measured: each word's share of the
// sentence's real (measured) audio duration is proportional to its own
// character length, with a small fixed share added per word so short words
// are not given an unrealistically instant duration. This is a common
// karaoke approximation, not true alignment -- it can drift on sentences
// with heavy punctuation-driven pauses.
function estimateWordTimings(words, totalSeconds) {
if (!words.length || !totalSeconds) return [];
const FIXED = 0.6; // arbitrary units, shifts weight toward evening out short words
const weights = words.map((w) => Math.max(1, w.replace(/[.,!?;:"'()«»„"]/g, '').length) + FIXED);
const total = weights.reduce((a, b) => a + b, 0);
let t = 0;
return weights.map((w) => {
const dur = totalSeconds * (w / total);
const start = t;
t += dur;
return { start, end: t };
});
}
function highlightWord(sentenceIdx, wordIdx) {
const sentenceEl = document.querySelector('#mag-article [data-i="' + sentenceIdx + '"]');
if (!sentenceEl) return;
sentenceEl.querySelectorAll('.mag-w').forEach((el) => {
el.classList.toggle('is-current', Number(el.dataset.w) === wordIdx);
});
}
function prefersReducedMotion() {
try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
catch (_) { return false; }
}
function magSeek(i) {
state.idx = Math.max(0, Math.min(state.sentences.length - 1, i));
highlight(state.idx);
saveProgress();
}
function magSetPane(pane) {
state.pane = pane;
const reflow = $('mag-pane-reflow'), pdf = $('mag-pane-pdf');
if (reflow) reflow.hidden = pane !== 'reflow';
if (pdf) pdf.hidden = pane !== 'pdf';
document.querySelectorAll('[data-mag-pane]').forEach((b) => {
const on = b.dataset.magPane === pane;
b.classList.toggle('active', on);
b.setAttribute('aria-pressed', on ? 'true' : 'false');
});
if (pane === 'pdf') {
const s = state.sentences[state.idx];
if (s) showPage(s.page, s.bbox);
}
}
function showPage(page, bbox) {
const img = $('mag-page-img');
const box = $('mag-page-box');
if (!img) return;
const src = '/api/magazine/' + state.doc.id + '/pages/' + page;
if (img.getAttribute('src') !== src) img.setAttribute('src', src);
img.setAttribute('alt', 'Page ' + page + ' of ' + (state.doc.title || 'the magazine'));
const label = $('mag-page-label');
if (label) label.textContent = 'Page ' + page;
if (!box || !bbox) return;
// The layout stores PDF user-space units; the rendered PNG is a different
// size, so scale by the page dimensions rather than assuming they match.
const meta = (state.layout.pages || []).find((p) => p.page === page);
if (!meta) { box.hidden = true; return; }
box.hidden = false;
box.style.left = (bbox[0] / meta.width * 100) + '%';
box.style.top = (bbox[1] / meta.height * 100) + '%';
box.style.width = (bbox[2] / meta.width * 100) + '%';
box.style.height = (bbox[3] / meta.height * 100) + '%';
}
function magPeekPage(page) {
magShow('read');
magSetPane('pdf');
showPage(page, null);
}
// ── Playback ─────────────────────────────────────────────────────────────────
async function magPlay() {
if (state.playing) return magPause();
const voice = ($('mag-voice')?.value || state.doc.default_voice || '').trim();
if (!voice) return say('Pick a narrator voice first', 'err');
state.playing = true;
state.cancel = false;
syncPlayButton();
let finishedArticle = false;
try {
while (state.idx < state.sentences.length && !state.cancel) {
highlight(state.idx);
const sentence = state.sentences[state.idx];
let blob;
try {
blob = await fetchTtsPreviewBlob(voice, sentence.text, 'mp3');
} catch (e) {
say('Synthesis failed: ' + e.message, 'err');
break;
}
if (state.cancel) break;
await playBlob(blob, sentence);
if (state.cancel) break;
state.idx += 1;
saveProgress();
}
finishedArticle = state.idx >= state.sentences.length && !state.cancel;
} finally {
state.playing = false;
state.cancel = false;
syncPlayButton();
}
if (finishedArticle) {
state.idx = state.sentences.length - 1;
if (state.queue.length && state.queueIndex < state.queue.length - 1) {
// Play-all: move straight to the next approved article rather than
// stopping -- the whole point of a queue is not touching the screen
// between articles.
state.queueIndex += 1;
await magRead(state.queue[state.queueIndex], { fromStart: true });
magPlay();
} else {
state.queue = [];
state.queueIndex = -1;
say('Article finished', 'ok');
}
}
}
function playBlob(blob, sentence) {
return new Promise((resolve) => {
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
state.audio = audio;
let timings = null;
const onTimeUpdate = () => {
if (!state.karaoke || !sentence || !sentence.words.length) return;
if (!timings) {
if (!audio.duration || !isFinite(audio.duration)) return;
timings = estimateWordTimings(sentence.words, audio.duration);
}
const t = audio.currentTime;
const wi = timings.findIndex((w) => t >= w.start && t < w.end);
if (wi !== -1) highlightWord(state.idx, wi);
};
if (state.karaoke) audio.addEventListener('timeupdate', onTimeUpdate);
const done = () => {
audio.removeEventListener('timeupdate', onTimeUpdate);
URL.revokeObjectURL(url);
state.audio = null;
resolve();
};
audio.onended = done;
audio.onerror = done;
audio.play().catch(done);
});
}
// ── Play all approved articles in sequence ──────────────────────────────
async function magPlayAll() {
const ids = state.articles.filter((a) => a.status === 'approved').map((a) => a.id);
if (!ids.length) return say('Approve at least one article first', 'err');
state.queue = ids;
state.queueIndex = 0;
await magRead(ids[0], { fromStart: true });
magPlay();
}
function magStopQueue() {
state.queue = [];
state.queueIndex = -1;
['mag-queue-progress', 'mag-queue-progress-2'].forEach((id) => {
const el = $(id);
if (el) el.textContent = '';
});
}
async function magQueueNext() {
if (!state.queue.length || state.queueIndex >= state.queue.length - 1) return;
const wasPlaying = state.playing;
if (wasPlaying) magPause();
state.queueIndex += 1;
await magRead(state.queue[state.queueIndex], { fromStart: true });
if (wasPlaying) magPlay();
}
function magPause() {
state.cancel = true;
if (state.audio) { try { state.audio.pause(); } catch (_) {} }
state.playing = false;
syncPlayButton();
}
function syncPlayButton() {
const btn = $('mag-play');
if (btn) {
btn.innerHTML = state.playing
? '<span class="mdi mdi-pause"></span> Pause'
: '<span class="mdi mdi-play"></span> Listen';
btn.setAttribute('aria-label', state.playing ? 'Pause narration' : 'Play narration');
}
if (state.driving) syncDrivingPlayButton();
}
let _saveTimer = null;
function saveProgress() {
if (!state.doc || !state.article) return;
clearTimeout(_saveTimer);
_saveTimer = setTimeout(() => {
fetch('/api/magazine/' + state.doc.id + '/progress', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ article: state.article.id, sentence: state.idx }),
}).catch(() => {});
}, 800);
}
// ── Podcast mode ─────────────────────────────────────────────────────────────
async function magLoadVoices() {
try {
const data = await api('/api/voices');
state.voices = data.voices || data || [];
} catch (_) { state.voices = []; }
const options = '<option value="">— pick a voice —</option>' +
state.voices.map((v) => {
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) => {
const el = $(id);
if (el) {
const keep = el.value;
el.innerHTML = options;
if (keep) el.value = keep;
}
});
const def = state.doc && state.doc.default_voice;
if (def && $('mag-voice') && !$('mag-voice').value) $('mag-voice').value = def;
}
async function loadPodcastIfAny() {
state.podcast = null;
renderPodcast();
// has_podcast comes from the articles listing so an article that never had
// a script generated skips the request entirely, rather than a 404 on
// every open.
if (!state.article || !state.article.has_podcast) return;
try {
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' +
state.article.id + '/podcast');
} catch (_) { /* generated flag was stale — treat as none */ }
renderPodcast();
}
async function magGeneratePodcast() {
const a = state.article;
if (!a) return;
if (a.status !== 'approved') {
return say('Approve the article first — unreviewed boundaries may include an advert', 'err');
}
const hosts = ['mag-host-a', 'mag-host-b', 'mag-host-c']
.map((id) => ($(id)?.value || '').trim())
.filter(Boolean)
.map((voice, i) => ({ voice, name: ($('mag-host-name-' + 'abc'[i])?.value || '').trim() }));
if (hosts.length < 2) return say('Pick at least two host voices', 'err');
const btn = $('mag-podcast-gen');
if (btn) { btn.disabled = true; btn.textContent = 'Writing script…'; }
try {
state.podcast = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/podcast', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hosts }),
});
a.has_podcast = true;
say('Podcast script ready', 'ok');
renderPodcast();
} catch (e) {
say('Could not generate: ' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Generate script'; }
}
}
function renderPodcast() {
const host = $('mag-podcast-script');
if (!host) return;
if (!state.podcast) {
host.innerHTML = '<p class="muted">No podcast script yet. Pick two or three voices and ' +
'generate one — the hosts discuss this article for someone who has not read it.</p>';
const play = $('mag-podcast-play');
if (play) play.hidden = true;
return;
}
const names = {};
(state.podcast.hosts || []).forEach((h) => { names[h.label] = h.name || ('Host ' + h.label); });
host.innerHTML = '<ol class="mag-turns">' + (state.podcast.turns || []).map((t, i) =>
'<li data-turn="' + i + '"><b>' + esc(names[t.host] || t.host) + '</b> ' +
esc(t.text) + '</li>').join('') + '</ol>';
const play = $('mag-podcast-play');
if (play) play.hidden = false;
}
async function magPlayPodcast() {
if (state.playing) return magPause();
if (!state.podcast) return;
const voiceOf = {};
(state.podcast.hosts || []).forEach((h) => { voiceOf[h.label] = h.voice; });
state.playing = true;
state.cancel = false;
const btn = $('mag-podcast-play');
if (btn) btn.textContent = 'Pause';
try {
const turns = state.podcast.turns || [];
for (let i = 0; i < turns.length && !state.cancel; i++) {
document.querySelectorAll('#mag-podcast-script [data-turn]').forEach((el) => {
el.classList.toggle('is-current', Number(el.dataset.turn) === i);
});
const voice = voiceOf[turns[i].host];
if (!voice) continue;
let blob;
try { blob = await fetchTtsPreviewBlob(voice, turns[i].text, 'mp3'); }
catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; }
if (state.cancel) break;
await playBlob(blob);
}
} finally {
state.playing = false;
state.cancel = false;
if (btn) btn.textContent = 'Play podcast';
}
}
// ── View switching ───────────────────────────────────────────────────────────
function magShow(view) {
state.view = view;
['library', 'contents', 'review', 'read'].forEach((v) => {
const el = $('mag-view-' + v);
if (el) el.hidden = v !== view;
});
if (view !== 'read') magPause();
}
function magBackToContents() {
magPause();
magStopQueue();
state.driving = false;
magShow('contents');
magRefreshArticles();
}
async function magBackToLibrary() {
// This button was previously labeled "All magazines" but only switched the
// current view; after a reload magInit could immediately reopen the saved
// document. Clear that remembered document and refresh the actual library.
magPause();
magStopQueue();
state.driving = false;
magForgetLastDoc();
state.doc = null;
state.article = null;
magShow('library');
try { await magLoadLibrary(); }
catch (e) { say('Could not refresh magazines: ' + e.message, 'err'); }
}
// ── Driving mode ──────────────────────────────────────────────────────────
// Large, minimal-interaction controls for listening without looking at the
// screen (see Manual.md's planned driving-mode behaviour). Reuses whatever
// is already playing/queued rather than starting its own playback path --
// entering driving mode does not interrupt an article already in progress.
function magToggleDriving() {
state.driving = !state.driving;
const overlay = $('mag-driving');
if (overlay) overlay.hidden = !state.driving;
if (state.driving) renderDriving();
}
function renderDriving() {
const a = state.article;
if (!a) return;
const titleEl = $('mag-driving-title');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
const byEl = $('mag-driving-by');
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : '';
syncDrivingPlayButton();
const qEl = $('mag-driving-queue');
if (qEl) qEl.hidden = !state.queue.length;
}
function syncDrivingPlayButton() {
const btn = $('mag-driving-play');
if (!btn) return;
btn.innerHTML = state.playing
? '<span class="mdi mdi-pause"></span>'
: '<span class="mdi mdi-play"></span>';
btn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play');
}
// ── Boot ─────────────────────────────────────────────────────────────────────
async function magInit() {
if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary();
magLoadVoices();
setupMagDropzone();
setupMagIngestPreviewResize();
let lastId = null;
try { lastId = localStorage.getItem(MAG_LAST_DOC_KEY); } catch (_) {}
if (lastId) {
// Confirmed live: without this, a full reload or the browser's own Back
// button (as opposed to in-app navigation) always dropped back to the
// empty library, making an in-progress magazine look like it had been
// lost. If it genuinely no longer exists (deleted elsewhere), fall back
// to the library rather than getting stuck on a permanent error toast.
try {
await api('/api/reader/docs/' + lastId);
await magOpen(lastId);
return;
} catch (_) {
magForgetLastDoc();
}
}
magShow('library');
}
root.magOpen = magOpen;
root.magCreate = magCreate;
root.magIngest = magIngest;
root.magReview = magReview;
root.magReviewGoPage = magReviewGoPage;
root.magReviewFocusBlock = magReviewFocusBlock;
root.magReviewHighlightBox = magReviewHighlightBox;
root.magReviewToggleBox = magReviewToggleBox;
root.magReocrPage = magReocrPage;
root.magReviewToggleImage = magReviewToggleImage;
root.magReviewToggleDraw = magReviewToggleDraw;
root.magReviewCancelDrawPanel = magReviewCancelDrawPanel;
root.magReviewSubmitDraw = magReviewSubmitDraw;
root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove;
root.magSetStatus = magSetStatus;
root.magRead = magRead;
root.magSeek = magSeek;
root.magPlay = magPlay;
root.magPause = magPause;
root.magSetPane = magSetPane;
root.magPeekPage = magPeekPage;
root.magShow = magShow;
root.magBackToContents = magBackToContents;
root.magBackToLibrary = magBackToLibrary;
root.magGeneratePodcast = magGeneratePodcast;
root.magPlayPodcast = magPlayPodcast;
root.magPlayAll = magPlayAll;
root.magQueueNext = magQueueNext;
root.magStopQueue = magStopQueue;
root.magToggleDriving = magToggleDriving;
root.magInit = magInit;
MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages };
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', magInit);
} else {
magInit();
}
})(window);