// 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 = '

No magazines yet. Add one to get started — ' + 'upload a PDF and the articles are detected for you.

'; return; } host.innerHTML = state.docs.map((d) => { const ing = d.ingest || {}; const badge = ing.status === 'done' ? '' + (ing.articles || 0) + ' articles' : ing.status === 'running' ? 'ingesting…' : ing.status === 'failed' ? 'failed' : 'not ingested'; const thumbnail = 'First page of ' + esc(d.title || 'magazine') +
      ''; return '
' + '
'; }).join(''); } async function magDelete(docId) { const doc = state.docs.find((item) => item.id === docId); const title = doc?.title || 'this magazine'; if (!window.confirm(`Delete “${title}”? This permanently removes its PDF, OCR, articles, and generated files.`)) return; try { await api('/api/reader/docs/' + encodeURIComponent(docId), { method: 'DELETE' }); if (state.doc?.id === docId) { magForgetLastDoc(); state.doc = null; state.article = null; } state.docs = state.docs.filter((item) => item.id !== docId); renderLibrary(); say(`Deleted “${title}”`, 'ok'); } catch (e) { say('Could not delete magazine: ' + e.message, 'err'); } } // ── 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 magFormatFilenameAsTitle(filename) { if (!filename) return ''; const base = String(filename).replace(/\.[^.]+$/, ''); return base .replace(/[_-]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function magShowDroppedFile(file) { const noteEl = $('mag-dropzone-note'); const titleEl = $('mag-dropzone-title'); const iconEl = $('mag-dropzone-icon'); const titleInput = $('mag-new-title'); const dropzone = $('mag-dropzone'); if (file) { const sizeMb = (file.size / (1024 * 1024)).toFixed(1); if (dropzone) dropzone.classList.add('is-selected'); if (noteEl) noteEl.textContent = `Selected: ${file.name} (${sizeMb} MB)`; if (titleEl) titleEl.textContent = 'PDF file selected — uploading...'; if (iconEl) iconEl.className = 'mdi mdi-file-check-outline reader-dropzone-icon'; // Auto-fill title from filename if title input is empty if (titleInput && !titleInput.value.trim()) { titleInput.value = magFormatFilenameAsTitle(file.name); } } else { if (dropzone) dropzone.classList.remove('is-selected', 'is-uploading'); if (noteEl) noteEl.textContent = MAG_DROPZONE_DEFAULT_NOTE; if (titleEl) titleEl.textContent = 'Drop a PDF document'; if (iconEl) iconEl.className = 'mdi mdi-cloud-upload-outline reader-dropzone-icon'; } } function installGlobalMagDropGuard() { if (window._magDropGuardInstalled) return; window._magDropGuardInstalled = true; const guard = (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) { e.preventDefault(); if (e.type === 'dragover' && e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; } }; window.addEventListener('dragenter', guard, true); window.addEventListener('dragover', guard, true); window.addEventListener('drop', guard, true); } installGlobalMagDropGuard(); function setupMagDropzone() { installGlobalMagDropGuard(); const zone = $('mag-dropzone'); const input = $('mag-new-file'); if (!zone || !input) return; if (zone.dataset.magDropzoneWired) return; zone.dataset.magDropzoneWired = '1'; // Clicking anywhere in the dropzone triggers the hidden file picker zone.addEventListener('click', (e) => { if (!e.target.closest('input')) { input.click(); } }); let dragCounter = 0; zone.addEventListener('dragenter', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter++; if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; zone.classList.add('dragover'); }); zone.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; zone.classList.add('dragover'); }); zone.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter = Math.max(0, dragCounter - 1); if (dragCounter === 0) { zone.classList.remove('dragover'); } }); zone.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); dragCounter = 0; zone.classList.remove('dragover'); let file = e.dataTransfer?.files?.[0]; if (!file && e.dataTransfer?.items?.[0]) { file = e.dataTransfer.items[0].getAsFile(); } if (!file) return; if (file.type !== 'application/pdf' && !/\.pdf$/i.test(file.name)) { return say('Only PDF files are supported', 'err'); } magShowDroppedFile(file); magCreate(file); }); input.addEventListener('change', () => { const file = input.files?.[0]; if (file) { magShowDroppedFile(file); magCreate(file); } }); } async function magCreate(droppedFile = null) { const file = droppedFile || $('mag-new-file')?.files?.[0]; if (!file) return say('Choose or drop a PDF file first', 'err'); let title = ($('mag-new-title')?.value || '').trim(); if (!title) { title = magFormatFilenameAsTitle(file.name) || 'Untitled Magazine'; if ($('mag-new-title')) $('mag-new-title').value = title; } const issue = ($('mag-new-issue')?.value || '').trim(); const addBtn = $('mag-add-btn'); const browseBtn = $('mag-browse-btn'); const dropzone = $('mag-dropzone'); const titleInp = $('mag-new-title'); const issueInp = $('mag-new-issue'); const progress = $('mag-dropzone-progress'); const pickBtn = $('mag-dropzone-pick-btn'); const titleEl = $('mag-dropzone-title'); const noteEl = $('mag-dropzone-note'); const iconEl = $('mag-dropzone-icon'); const origBtnText = addBtn ? addBtn.innerHTML : ''; const setUploadingState = (uploading) => { const sizeMb = file ? (file.size / (1024 * 1024)).toFixed(1) : 0; if (uploading) { if (dropzone) { dropzone.classList.add('is-uploading'); dropzone.style.pointerEvents = 'none'; } if (progress) progress.hidden = false; if (pickBtn) pickBtn.hidden = true; if (browseBtn) browseBtn.hidden = true; if (iconEl) iconEl.className = 'mdi mdi-loading mdi-spin reader-dropzone-icon'; if (titleEl) titleEl.textContent = 'Uploading & Ingesting PDF...'; if (noteEl) noteEl.textContent = `Processing "${file.name}" (${sizeMb} MB). Transferring source file and extracting layout...`; if (addBtn) { addBtn.disabled = true; addBtn.innerHTML = ' Uploading PDF...'; } if (titleInp) titleInp.disabled = true; if (issueInp) issueInp.disabled = true; } else { if (dropzone) { dropzone.classList.remove('is-uploading'); dropzone.style.pointerEvents = 'auto'; } if (progress) progress.hidden = true; if (pickBtn) pickBtn.hidden = false; if (browseBtn) browseBtn.hidden = false; if (addBtn) { addBtn.disabled = false; addBtn.innerHTML = origBtnText; } if (titleInp) titleInp.disabled = false; if (issueInp) issueInp.disabled = false; } }; try { setUploadingState(true); say(`Uploading ${file.name} (${(file.size / (1024 * 1024)).toFixed(1)} MB)...`, 'ok'); 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 uploaded — 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); setUploadingState(false); await magOpen(id); await magIngest(); } catch (e) { setUploadingState(false); 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 = '
' + '' + '
' + '' + esc(detail || 'Analysing…') + '' + (p ? '
' : '') + '
' + (p ? '' + p.pct + '%' : '') + '
'; } else if (ing.status === 'failed') { bar.className = 'mag-status is-failed'; bar.innerHTML = '
' + 'Ingestion failed: ' + esc(ing.error || '') + '
'; } else { bar.className = 'mag-status is-done'; bar.innerHTML = '
' + '' + (ing.articles || 0) + ' article' + (ing.articles === 1 ? '' : 's') + ' detected
'; } } // 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'); // Server-built HTML (see _render_ingest_preview_html in // routes/magazine.py) -- every text node in it is already escaped, // so this is the one place that inner-HTMLs a live server value. if (textEl) textEl.innerHTML = 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) { // 0.55 is the backend's explicit heuristic marker: no reliable contents // entry was available, so an article was started from a page headline. It // is not a calibrated probability and a column of identical “55%” badges // falsely suggests a measured OCR score. if (Math.abs(Number(c) - 0.55) < 0.001) { return { cls: 'warn', text: 'Headline detected — check boundaries', badge: 'headline detected' }; } if (c >= 0.85) return { cls: 'ok', text: Math.round(c * 100) + '% · listed in contents', badge: Math.round(c * 100) + '%' }; if (c >= 0.5) return { cls: 'warn', text: Math.round(c * 100) + '% · from headline — check boundaries', badge: Math.round(c * 100) + '%' }; return { cls: 'bad', text: Math.round(c * 100) + '% · little body copy — likely not an article', badge: Math.round(c * 100) + '%' }; } 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 = '

No articles yet. Run ingestion to detect them.

'; 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 += '

' + esc(section) + '

'; }); 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(); } function magReviewGoMagazinePage(page) { const lastPage = Number(state.layout?.page_count || 0); if (!Number.isInteger(page) || page < 1 || page > lastPage) 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 = page != null ? (state.layout?.pages || []).find((p) => p.page === page) : null; const ownImages = new Set(a.images || []); 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)); // 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 ?? 0, 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; const issuePages = (state.layout || {}).page_count || '—'; const nextIssuePage = page != null && page < (state.layout || {}).page_count ? page + 1 : null; const prevIssuePage = page != null && page > 1 ? page - 1 : null; let html = ''; html += '
' + '
' + '' + 'Page ' + (page ?? '—') + '' + '
' + '
' + '' + 'Magazine ' + (page ?? '—') + ' / ' + issuePages + '' + '
' + '
' + '' + '' + '' + '' + '' + '' + '
' + '' + '' + '' + '' + '
'; html += '
'; html += '
' + '' + '' + '
' + ''; html += '
'; html += '' + esc(conf.text) + ''; html += '

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.

'; if (state.layout.semantic_review === 'partial_failure') { html += '

Some regions could not be grouped automatically. Check paragraph boundaries before approving.

'; } if (state.layout.advertisement_review === 'partial_failure') { html += '

Advertisement detection could not finish. Check this page for adverts before approving.

'; } else if (state.layout.advertisement_review === 'not_configured') { html += '

Set a language model in Settings to recognise advertisements automatically.

'; } html += '
'; html += '
'; if (page != null) { html += '
' + 'Page ' + page + '' + ''; 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 += '
' + '' + (i + 1) + '' + '' + (owner ? 'Art. ' + owner.index + '' : '') + '
'; }); html += '
'; } else { html += '

No page to show.

'; } html += '
'; html += '
'; regions.forEach((r, i) => { const isImage = r.kind === 'image'; const owner = magBlockOwner(r.id); const icon = magBlockIcon(isImage ? r.data.type || 'image' : r.data.region_label === 'page_number' ? 'page_number' : r.data.type); if (isImage) { const on = ownImages.has(r.id); const imgFilename = r.data.filename || (r.id + '.png'); const imgUrl = '/api/magazine/' + state.doc.id + '/images/' + esc(imgFilename); html += '
' + '' + (i + 1) + '' + '' + ' ' + esc(r.data.type || 'image') + '' + '
' + '' + esc(r.data.type || 'figure') + '' + '' + (owner ? 'Belongs to Article ' + owner.index + ': ' + esc(owner.title) : on ? 'Shown during playback for this article.' : 'Not linked to any article on this page.') + '' + '
' + '
'; return; } const b = r.data; const narratable = ['title', 'heading', 'deck', 'byline', 'body'].includes(b.type); const on = included.has(b.id); html += ''; }); if (!regions.length) html += '

Nothing detected on this page.

'; html += '
'; html += '
'; // /mag-review-split html += '
' + '' + '' + '' + '
'; 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 magReviewClearCurrentPage() { if (state.reviewPage == null) return; if (!_magReviewChecked) _magReviewChecked = new Set(state.article.blocks || []); const page = state.reviewPage; (state.layout.blocks || []).filter((block) => block.page === page) .forEach((block) => _magReviewChecked.delete(block.id)); const imageIds = new Set((state.layout.images || []).filter((image) => image.page === page).map((image) => image.id)); state.article.images = (state.article.images || []).filter((id) => !imageIds.has(id)); renderReview(); say('All fields on this page cleared — select only the article content to keep.', 'ok'); } 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'); const field = kind === 'title' ? 'title' : kind === 'author' ? 'author' : ''; const drawLabels = { text: 'text region', image: 'image, chart, or graphic', heading: 'headline', page_number: 'page number', source: 'source or footer', ad: 'advertisement' }; if (hint) hint.textContent = state.drawMode ? (field ? ` Drag around the ${field} text to OCR it into this field. Press Escape to cancel.` : ' Drag around the whole ' + (drawLabels[kind] || '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; const wrap = $('mag-review-page-wrap'); const box = $('mag-draw-rect'); if (wrap && box) { 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 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 + '%'; } _magDrawState = null; if (!box || box.hidden) return; const w = parseFloat(box.style.width) || 0, h = parseFloat(box.style.height) || 0; 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'; const field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; const semanticKinds = { heading: 'headline', page_number: 'page number', source: 'source / footer', ad: 'advertisement' }; const semanticLabel = semanticKinds[state.drawKind] || ''; panel.innerHTML = field ? '
Extract ' + field + '
' + '

OCR the selected rectangle and use its recognised text for the article ' + field + '.

' + '
' + '
' : semanticLabel ? '
Add ' + semanticLabel + '
' + '

This region is classified as ' + semanticLabel + (state.drawKind === 'heading' ? ' and can be included in narration.' : ' and is kept out of narration.') + '

' + '' + '
' + '
' : '
' + '' + '' + '
' + '' + '' + '
' + '' + '' + '
'; wrap.appendChild(panel); const top = parseFloat(box.style.top) || 0, left = parseFloat(box.style.left) || 0, height = parseFloat(box.style.height) || 0; panel.style.top = Math.min(85, top + 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 field = state.drawKind === 'title' || state.drawKind === 'author' ? state.drawKind : ''; const kind = field ? 'text' : (['heading', 'page_number', 'source', 'ad'].includes(state.drawKind) ? state.drawKind : (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; if (field) { magReviewCancelDrawPanel(); say(`Reading selected ${field}…`, ''); try { const result = await api('/api/magazine/' + state.doc.id + '/pages/' + state.reviewPage + '/extract', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ bbox }), }); const value = String(result.text || '').replace(/\s+/g, ' ').trim(); if (!value) return say('No text found in that selection', 'err'); article[field] = value; const input = $(field === 'title' ? 'mag-rev-title' : 'mag-rev-author'); if (input) input.value = value; say(`${field === 'title' ? 'Title' : 'Author'} filled from selected text`, 'ok'); } catch (e) { say(`Could not read selected ${field}: ` + e.message, 'err'); } return; } 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 !== 'image' && !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 if (kind === 'text' || kind === 'heading') 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(); } // Re-derives every figure box in the issue from the page renders. Separate // from "OCR again" because no OCR engine is involved: it only corrects where // the figures are, never what the page says, so it is safe to run on an issue // that is half reviewed already. async function magRefineFigures() { const btn = $('mag-refine-figures-btn'); const statusEl = $('mag-reocr-status'); if (btn) btn.disabled = true; if (statusEl) statusEl.textContent = 'Checking figure boundaries…'; let jobId; try { const r = await api('/api/magazine/' + state.doc.id + '/figures/refine', { method: 'POST' }); jobId = r.job_id; } catch (e) { if (btn) btn.disabled = false; if (statusEl) statusEl.textContent = ''; return say('Could not start the check: ' + e.message, 'err'); } let failed = false; for (;;) { let job; try { job = await api('/api/jobs/' + jobId); } catch (_) { break; } if (job.done) { if (job.error) { failed = true; say('Figure check failed: ' + job.error, 'err'); } else say(job.detail || 'Figure boundaries checked', 'ok'); break; } if (statusEl && job.detail) statusEl.textContent = job.detail; await new Promise((r) => setTimeout(r, 2000)); } if (btn) btn.disabled = false; if (statusEl) statusEl.textContent = ''; if (failed) return; state.layout = null; await ensureLayout(); _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 = 'Figure from page ' + img.page + ''; } 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) => '' + esc(w) + '').join(' '); return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' + 'onclick="magSeek(' + i + ')">' + wordsHtml + ' '; }).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 ? ' Pause' : ' 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 = '' + state.voices.map((v) => { const id = v.id || v.name || v; return ''; }).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 = '

No podcast script yet. Pick two or three voices and ' + 'generate one — the hosts discuss this article for someone who has not read it.

'; 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 = '
    ' + (state.podcast.turns || []).map((t, i) => '
  1. ' + esc(names[t.host] || t.host) + ' ' + esc(t.text) + '
  2. ').join('') + '
'; 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 ? '' : ''; btn.setAttribute('aria-label', state.playing ? 'Pause' : 'Play'); } // ── Boot ───────────────────────────────────────────────────────────────────── async function magOnShow() { setupMagDropzone(); setupMagIngestPreviewResize(); if ($('mag-library-list')) { magLoadLibrary(); magLoadVoices(); } } async function magInit() { setupMagDropzone(); setupMagIngestPreviewResize(); if (!$('mag-library-list')) return; // section not mounted magLoadLibrary(); magLoadVoices(); 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.magOnShow = magOnShow; root.magOpen = magOpen; root.magDelete = magDelete; root.magCreate = magCreate; root.magIngest = magIngest; root.magReview = magReview; root.magReviewGoPage = magReviewGoPage; root.magReviewGoMagazinePage = magReviewGoMagazinePage; root.magReviewFocusBlock = magReviewFocusBlock; root.magReviewHighlightBox = magReviewHighlightBox; root.magReviewToggleBox = magReviewToggleBox; root.magReocrPage = magReocrPage; root.magRefineFigures = magRefineFigures; 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);