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
|
### 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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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; }
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user