// 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)
};
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('');
}
// ── 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 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 ────────────────────────────────────────────────────────────────
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) + '
';
arts.forEach((a) => {
const conf = confidenceLabel(a.confidence);
const done = a.status === 'approved';
const progress = (state.doc.progress || {})[a.id];
html += '- ' +
'
' +
'' + esc(a.title || 'Untitled') + '' +
(a.author ? 'by ' + esc(a.author) + '' : '') +
'
' +
'' +
'' + fmtPages(a.pages) + '' +
'' + fmtDuration(a.estimated_seconds) + '' +
'' +
Math.round(a.confidence * 100) + '%' +
'' +
(done ? 'approved' : a.status === 'rejected' ? 'rejected' : 'needs review') + '' +
'
' +
'' +
'' +
(done
? ''
: '') +
'
';
});
html += '
';
});
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 =
'' +
'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,
// 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 = '
';
}
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 + '' + 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
? ' 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) =>
'- ' + esc(names[t.host] || t.host) + ' ' +
esc(t.text) + '
').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();
}
// ── 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 ─────────────────────────────────────────────────────────────────────
function magInit() {
if (!$('mag-library-list')) return; // section not mounted
magLoadLibrary();
magLoadVoices();
setupMagDropzone();
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.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);