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:
parent
4d7434f9af
commit
834146de30
@ -9,6 +9,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
### 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.
|
||||
### 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.
|
||||
|
||||
### Changed (compare_magazines.py)
|
||||
|
||||
@ -88,6 +88,46 @@ function fmtDuration(seconds) {
|
||||
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) {
|
||||
if (!pages || !pages.length) return '—';
|
||||
if (pages.length === 1) return 'p. ' + pages[0];
|
||||
@ -738,7 +778,19 @@ function renderContents() {
|
||||
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) =>
|
||||
(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>' +
|
||||
(done
|
||||
? '<button type="button" class="btn-sm primary" onclick="magRead(\'' + a.id + '\')">' +
|
||||
@ -786,7 +838,7 @@ function renderContents() {
|
||||
'<span>' + fmtDuration(a.estimated_seconds) + '</span>' +
|
||||
'<span class="mag-pill ' + conf.cls + '" title="' + esc(conf.text) + '">' +
|
||||
esc(conf.badge) + '</span>' +
|
||||
statusBadge +
|
||||
statusBadge + synthHtml(a) +
|
||||
'</div>' +
|
||||
'<div class="mag-toc-actions">' + actionsHtml(a, done, progress) + '</div></li>';
|
||||
});
|
||||
@ -795,6 +847,7 @@ function renderContents() {
|
||||
host.innerHTML = html;
|
||||
renderContentsBulkBar();
|
||||
magUpdateAudioZipLink();
|
||||
magTocSyncButtons();
|
||||
}
|
||||
|
||||
const MAG_CONTENTS_VIEW_CYCLE = ['list', 'pages'];
|
||||
@ -5159,6 +5212,7 @@ root.magReocrMagazine = magReocrMagazine;
|
||||
root.magApprove = magApprove;
|
||||
root.magSetStatus = magSetStatus;
|
||||
root.magRead = magRead;
|
||||
root.magTocPlay = magTocPlay;
|
||||
root.magSeek = magSeek;
|
||||
root.magPlay = magPlay;
|
||||
root.magPrepareAudio = magPrepareAudio;
|
||||
|
||||
@ -4921,6 +4921,9 @@ input[list="lang-codes"] {
|
||||
.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-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 input { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
|
||||
.mag-toc-row { grid-template-columns: auto 1fr auto auto; }
|
||||
|
||||
@ -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)));
|
||||
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);
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user