feat(magazine): synthesis state, MP3 length and inline play in the contents list

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-09-29 17:24:05 +02:00
parent 4d7434f9af
commit 834146de30
4 changed files with 84 additions and 1 deletions

View File

@ -9,6 +9,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
### Fixed ### Fixed
- **Voice benchmark failures now say why.** Designed voices are benchmarked through the Voice Design engine; when that service is down the row showed a bare "-" and the raw "Max retries exceeded" text. The error now names the service and the container to start, the Factor and WPM columns show `ERR` beside the time column, and a batch stops after three identical "unreachable" failures instead of retrying every remaining voice. - **Voice benchmark failures now say why.** Designed voices are benchmarked through the Voice Design engine; when that service is down the row showed a bare "-" and the raw "Max retries exceeded" text. The error now names the service and the container to start, the Factor and WPM columns show `ERR` beside the time column, and a batch stops after three identical "unreachable" failures instead of retrying every remaining voice.
### Added ### Added
- **Magazine contents list shows synthesis state.** Each article row says "Synthesised · m:ss" (the MP3's exact length) or "Not synthesised" (approved, no MP3 yet), and has an inline play/pause button that streams the MP3 without opening the article. Icon plus words, screen-reader labelled, works at phone width.
- `scripts/compare_magazines.py`: read-only comparison of two magazine folders (words, boxes, reading order, articles, quality numbers) for re-import checks. - `scripts/compare_magazines.py`: read-only comparison of two magazine folders (words, boxes, reading order, articles, quality numbers) for re-import checks.
### Changed (compare_magazines.py) ### Changed (compare_magazines.py)

View File

@ -88,6 +88,46 @@ function fmtDuration(seconds) {
return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min'; return m < 60 ? m + ' min' : Math.floor(m / 60) + ' h ' + (m % 60) + ' min';
} }
// Exact clock time (m:ss, h:mm:ss) -- the estimate above is deliberately coarse, this one names a real MP3.
function fmtClock(seconds) {
const s = Math.max(0, Math.round(seconds || 0));
const ss = String(s % 60).padStart(2, '0');
return s >= 3600
? Math.floor(s / 3600) + ':' + String(Math.floor(s / 60) % 60).padStart(2, '0') + ':' + ss
: Math.floor(s / 60) + ':' + ss;
}
// The contents list doubles as the playlist: one shared <audio> for the inline play buttons,
// separate from the reading view's own playback so neither disturbs the other.
const tocPlayer = { audio: null, id: null };
function magTocSyncButtons() {
document.querySelectorAll('[data-toc-play]').forEach((btn) => {
const on = tocPlayer.id === btn.dataset.tocPlay && tocPlayer.audio && !tocPlayer.audio.paused;
const title = btn.dataset.title || 'article';
btn.setAttribute('aria-pressed', String(!!on));
btn.setAttribute('aria-label', (on ? 'Pause ' : 'Play ') + title);
btn.innerHTML = '<span class="mdi ' + (on ? 'mdi-pause' : 'mdi-play') + '" aria-hidden="true"></span>';
});
}
function magTocPlay(articleId) {
if (tocPlayer.audio && tocPlayer.id === articleId) {
if (tocPlayer.audio.paused) tocPlayer.audio.play().catch(() => {}); else tocPlayer.audio.pause();
return;
}
if (tocPlayer.audio) tocPlayer.audio.pause();
if (typeof state.audio?.pause === 'function') state.audio.pause();
const audio = new Audio('/api/magazine/' + encodeURIComponent(state.doc.id) + '/articles/' + encodeURIComponent(articleId) + '/audio');
tocPlayer.audio = audio;
tocPlayer.id = articleId;
audio.addEventListener('play', magTocSyncButtons);
audio.addEventListener('pause', magTocSyncButtons);
audio.addEventListener('ended', magTocSyncButtons);
audio.addEventListener('error', () => { say('Could not play this article\'s MP3', 'err'); tocPlayer.audio = null; tocPlayer.id = null; magTocSyncButtons(); });
audio.play().catch(() => {});
}
function fmtPages(pages) { function fmtPages(pages) {
if (!pages || !pages.length) return '—'; if (!pages || !pages.length) return '—';
if (pages.length === 1) return 'p. ' + pages[0]; if (pages.length === 1) return 'p. ' + pages[0];
@ -738,7 +778,19 @@ function renderContents() {
return; return;
} }
// Marked with an icon and words, never colour alone.
const synthHtml = (a) => a.has_audio
? '<span class="mag-synth is-done" title="MP3 rendered"><span class="mdi mdi-waveform" aria-hidden="true"></span> Synthesised' +
(a.audio_seconds ? ' · ' + fmtClock(a.audio_seconds) : '') + '</span>'
: a.status === 'approved'
? '<span class="mag-synth" title="Approved, no MP3 yet"><span class="mdi mdi-microphone-off" aria-hidden="true"></span> Not synthesised</span>'
: '';
const actionsHtml = (a, done, progress) => const actionsHtml = (a, done, progress) =>
(a.has_audio
? '<button type="button" class="btn-sm mag-toc-play" data-toc-play="' + esc(a.id) + '" data-title="' + esc(a.title || 'article') +
'" aria-pressed="false" aria-label="Play ' + esc(a.title || 'article') + '" onclick="magTocPlay(\'' + a.id + '\')">' +
'<span class="mdi mdi-play" aria-hidden="true"></span></button>'
: '') +
'<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' + '<button type="button" class="btn-sm" onclick="magReview(\'' + a.id + '\')">Review</button>' +
(done (done
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' + ? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
@ -786,7 +838,7 @@ function renderContents() {
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' + '<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' + '<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
esc(conf.badge) + '</span>' + esc(conf.badge) + '</span>' +
statusBadge + statusBadge + synthHtml(a) +
'</div>' + '</div>' +
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>'; '<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
}); });
@ -795,6 +847,7 @@ function renderContents() {
host.innerHTML = html; host.innerHTML = html;
renderContentsBulkBar(); renderContentsBulkBar();
magUpdateAudioZipLink(); magUpdateAudioZipLink();
magTocSyncButtons();
} }
const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages']; const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages'];
@ -5159,6 +5212,7 @@ root.magReocrMagazine = magReocrMagazine;
root.magApprove = magApprove; root.magApprove = magApprove;
root.magSetStatus = magSetStatus; root.magSetStatus = magSetStatus;
root.magRead = magRead; root.magRead = magRead;
root.magTocPlay = magTocPlay;
root.magSeek = magSeek; root.magSeek = magSeek;
root.magPlay = magPlay; root.magPlay = magPlay;
root.magPrepareAudio = magPrepareAudio; root.magPrepareAudio = magPrepareAudio;

View File

@ -4921,6 +4921,9 @@ input[list="lang-codes"] {
.mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; } .mag-toc-by { color: var(--subtext); font-size: 13px; margin-left: 8px; }
.mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; } .mag-toc-meta { display: flex; gap: 10px; align-items: center; color: var(--subtext); font-size: 13px; flex-wrap: wrap; }
.mag-toc-actions { display: flex; gap: 6px; } .mag-toc-actions { display: flex; gap: 6px; }
.mag-synth { display: inline-flex; align-items: center; gap: 4px; }
.mag-synth.is-done { color: var(--green); font-weight: 600; }
.mag-toc-play { min-width: 32px; }
.mag-select-check { display: flex; align-items: center; padding-right: 4px; } .mag-select-check { display: flex; align-items: center; padding-right: 4px; }
.mag-select-check input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; } .mag-select-check input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.mag-toc-row { grid-template-columns: auto 1fr auto auto; } .mag-toc-row { grid-template-columns: auto 1fr auto auto; }

View File

@ -1343,3 +1343,28 @@ test('the live preview draws the page regions over the page image as they arrive
const [pic, chart, table] = await Promise.all(['.mpt-figure', '.mpt-chart', '.mpt-table'].map((t) => colour('#mag-ingest-preview-boxes ' + t))); const [pic, chart, table] = await Promise.all(['.mpt-figure', '.mpt-chart', '.mpt-table'].map((t) => colour('#mag-ingest-preview-boxes ' + t)));
expect(new Set([pic, chart, table]).size).toBe(3); expect(new Set([pic, chart, table]).size).toBe(3);
}); });
test('the contents list shows synthesis state with the MP3 length and plays inline', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
const row = page.locator('.mag-toc-row').first();
await expect(row).toContainText('Synthesised · 0:02');
const play = row.getByRole('button', { name: /^Play / });
await expect(play).toHaveAttribute('aria-pressed', 'false');
await play.click();
await expect(row.getByRole('button', { name: /^Pause / })).toHaveAttribute('aria-pressed', 'true');
});
test('the contents list marks an approved article without audio as not synthesised', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
const row = page.locator('.mag-toc-row').first();
await expect(row).toContainText('Not synthesised');
await expect(row.getByRole('button', { name: /^Play / })).toHaveCount(0);
});