// 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 }] view: 'library', // library | contents | review | read pane: 'reflow', // reflow | pdf playing: false, cancel: false, idx: 0, // current sentence audio: null, voices: [], podcast: 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(', '); } // ── 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'; return ''; }).join(''); } 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 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 = ''; await magOpen(id); await magIngest(); } catch (e) { say('Could not add magazine: ' + e.message, 'err'); } } // ── Ingestion ──────────────────────────────────────────────────────────────── async function magOpen(docId) { try { state.doc = await api('/api/reader/docs/' + docId); state.doc.id = docId; await magRefreshArticles(); magShow('contents'); if ((state.doc.ingest || {}).status === 'running') pollIngest(); } catch (e) { say('Could not open magazine: ' + e.message, 'err'); } } 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'); } } async function pollIngest() { const bar = $('mag-ingest-status'); if (bar) bar.hidden = false; 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) { bar.innerHTML = ing.status === 'running' ? ' ' + esc(detail || 'Analysing…') : ing.status === 'failed' ? ' Ingestion failed: ' + esc(ing.error || '') : ' ' + (ing.articles || 0) + ' articles detected'; } 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 = '

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(); 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); } function renderReview() { const host = $('mag-review'); const a = state.article; if (!host || !a) return; const included = new Set(a.blocks || []); // Offer neighbouring pages too, so a boundary that started a page late can be // corrected without leaving the screen. const span = a.pages.length ? Array.from(new Set(a.pages.concat([a.pages[0] - 1, a.pages[a.pages.length - 1] + 1]))) .filter((p) => p >= 1 && p <= (state.layout.page_count || 0)).sort((x, y) => x - y) : []; const conf = confidenceLabel(a.confidence); let html = '
' + '' + '' + '
' + esc(conf.text) + '
' + '
' + '

Untick anything that is not part of this article — a sidebar, an advert, ' + 'or a neighbouring piece. Only ticked text is read aloud.

'; let currentPage = null; html += '
'; blocksForPages(span).forEach((b) => { if (b.page !== currentPage) { currentPage = b.page; html += '
Page ' + b.page + '
'; } const narratable = ['title', 'deck', 'byline', 'body'].includes(b.type); const on = included.has(b.id); html += ''; }); html += '
' + '
' + '' + '' + '' + '
'; host.innerHTML = html; } function selectedBlockIds() { return Array.from(document.querySelectorAll('#mag-review input[data-block]:checked')) .map((el) => el.dataset.block); } 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, 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 }); } }); }); return out; } async function magRead(articleId) { 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'); } const saved = (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]; return '<' + tag + ' class="mag-s' + (open === 'span' ? '' : '') + '" data-i="' + i + '" ' + 'onclick="magSeek(' + i + ')">' + esc(s.text) + ' '; }).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); }); 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); const counter = $('mag-read-progress'); if (counter) counter.textContent = (i + 1) + ' / ' + state.sentences.length; } 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(); try { while (state.idx < state.sentences.length && !state.cancel) { highlight(state.idx); const text = state.sentences[state.idx].text; let blob; try { blob = await fetchTtsPreviewBlob(voice, text, 'mp3'); } catch (e) { say('Synthesis failed: ' + e.message, 'err'); break; } if (state.cancel) break; await playBlob(blob); if (state.cancel) break; state.idx += 1; saveProgress(); } } finally { state.playing = false; state.cancel = false; syncPlayButton(); if (state.idx >= state.sentences.length) { state.idx = state.sentences.length - 1; say('Article finished', 'ok'); } } } function playBlob(blob) { return new Promise((resolve) => { const url = URL.createObjectURL(blob); const audio = new Audio(url); state.audio = audio; const done = () => { URL.revokeObjectURL(url); state.audio = null; resolve(); }; audio.onended = done; audio.onerror = done; audio.play().catch(done); }); } 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) return; btn.innerHTML = state.playing ? ' Pause' : ' Listen'; btn.setAttribute('aria-label', state.playing ? 'Pause narration' : 'Play narration'); } 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(); magShow('contents'); magRefreshArticles(); } // ── Boot ───────────────────────────────────────────────────────────────────── function magInit() { if (!$('mag-library-list')) return; // section not mounted magLoadLibrary(); magLoadVoices(); magShow('library'); } root.magOpen = magOpen; root.magCreate = magCreate; root.magIngest = magIngest; root.magReview = magReview; 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.magGeneratePodcast = magGeneratePodcast; root.magPlayPodcast = magPlayPodcast; root.magInit = magInit; MAG.magazineApi = { load: magLoadLibrary, refresh: magRefreshArticles, splitSentences, fmtPages }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', magInit); } else { magInit(); } })(window);