// 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 asides: [], // sidebars shown beside the text (not narrated), see article_asides view: 'library', // library | contents | review | read contentsView: 'list', // list | pages — how the article list itself is shown contentsSelectMode: false, // batch-select articles for bulk approve/reject contentsSelected: new Set(), pagesCols: 8, // "All pages" grid column count, user-adjustable 1-12 playlistCurrent: null, // article id of the MP3 playlist's current track playlistSentences: [], // transcript aligned approximately to the exported MP3 playlistImages: [], // figures/tables/charts shown alongside that transcript playlistLoadToken: 0, // prevents a slow text fetch replacing a newer track playlistLastSaved: 0, playlistHighlightIndex: -1, playlistSwitching: false, pane: 'reflow', // reflow | pdf playing: false, cancel: false, idx: 0, // current sentence audio: null, voices: [], podcast: null, spell: null, // last spelling findings shown (page or article scope) spellByBlock: new Map(), spellOpen: false, audit: null, // live audit of the reviewed page ({page, data}) flow: null, // how the open article's text continues between regions flowOn: true, // show the flow arrows/chips in review history: null, // {can_undo, can_redo, undo_label, redo_label} from the server historyBusy: false, acronyms: null, // the open article's glossary state 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, reviewSpread: false, // two-page spread view in review, see magReviewToggleSpread audioCache: new Map(), // voice+text -> Promise, see magSynth preparing: false, // "Prepare audio" is synthesising the whole article prepCancel: false, prepGen: 0, // bumped on article change so a stale prepare run stops touching state }; MAG.magazine = state; 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'; } // The contents list doubles as the playlist: one shared