1590 lines
67 KiB
JavaScript
1590 lines
67 KiB
JavaScript
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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);
|