feat(magazine): driving index rows with play/resume buttons, author and listening status

Index rows reuse the playlist state helper (magListenState): round 44px play/resume/replay button,
smaller muted 'by Author', not played / partly played / played indicator with accessible text, live updates.
Also fix a stale word highlight in the lyrics view that made a driving-mode test fail.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 08:45:04 +02:00
parent 2ffab0052c
commit f13f884ab1
5 changed files with 144 additions and 34 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Driving mode index shows what the playlist shows.** Each row has a 44 px round play / resume / replay button, the author ("by Name", smaller and muted, ellipsized), and the three-state listening indicator (not played / partly played / played) with accessible text, updated live as articles play and finish. Shares one state helper (`magListenState`) with the playlist.
- **Article index in driving mode.** A new *Index* button in the top bar opens a sheet between the top bar and the controls dock that lists every article of the magazine with its title and length (m:ss or h:mm:ss; articles without audio are dimmed, show *no audio* and cannot be chosen). The playing article is highlighted and scrolled into view; tap or press Enter to jump to an article while driving mode keeps running. Escape, the close button or a tap outside closes it and returns focus to the button; Tab stays inside the sheet. Lengths come from the existing articles endpoint (`audio_seconds`), so there is no new backend call; a 113-article list is a plain list rendered at once. - **Article index in driving mode.** A new *Index* button in the top bar opens a sheet between the top bar and the controls dock that lists every article of the magazine with its title and length (m:ss or h:mm:ss; articles without audio are dimmed, show *no audio* and cannot be chosen). The playing article is highlighted and scrolled into view; tap or press Enter to jump to an article while driving mode keeps running. Escape, the close button or a tap outside closes it and returns focus to the button; Tab stays inside the sheet. Lengths come from the existing articles endpoint (`audio_seconds`), so there is no new backend call; a 113-article list is a plain list rendered at once.
- **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=<id>&article=<id>&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback). - **Share an article or a magazine as a link that opens straight in driving mode.** A *Share* button in the magazine toolbar (whole magazine), one in the playlist header and one in driving mode (current article) build `/#s-magazine?doc=<id>&article=<id>&mode=drive`. They use the browser's share sheet where there is one (it needs https, so on a plain-http LAN address they copy the link instead, with a polite aria-live toast, or show a box with the link if even copying is refused). Opening such a link, also on a first visit and also pasted into an already-open tab, opens the magazine and then driving mode for that article with audio, pictures and text; when the browser blocks autoplay a large *Tap to play* button appears. The link carries ids only (no key or password) and does not change who may reach the app; the ids are validated against a strict pattern and never put into markup, and anything malformed is ignored. Tested with Playwright (desktop deep link, blocked autoplay, hostile parameters, share sheet at 375 px, copy fallback).
- **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard. - **Filter the page grid by status.** Each legend entry is now a toggle button with the number of pages in that status; choose one or several to show only pages that have any of them. It combines with *Show only pages that need me*, the summary says how many pages are shown, *Show all pages* clears everything, a status no page has is greyed out, and the buttons announce their on/off state and work from the keyboard.
@ -15,6 +16,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **Word-level timing with a forced-aligner sidecar.** A new, separate `tts-aligner` container (`aligner/`, torchaudio MMS_FA, CPU by default, optional `--profile gpu`; weights cached in the `aligner-models` volume, no published port, non-root, read-only filesystem, 3 GB limit) answers `POST /align` and `POST /align/batch` with `[{word, start, end}]`. The app (image unchanged, no torch) measures each sentence's words in a background job the first time an article is played (or via `POST .../articles/{id}/align-words`, progress via `GET`), writes `words: [[start_ms, end_ms], ...]` per segment into `audio/<id>.timings.json` (now version 2; readers of version 1 keep working) and `/timings` returns `words` per sentence. The player marks the word from those times in Compact, Lyrics (new per-word marking) and the playlist, and falls back to the previous length-based estimate when no word times exist or the sidecar is down. Measured on one article: after-pause word starts are within 29 ms on average (max 170 ms) of the audio's energy onsets. - **Word-level timing with a forced-aligner sidecar.** A new, separate `tts-aligner` container (`aligner/`, torchaudio MMS_FA, CPU by default, optional `--profile gpu`; weights cached in the `aligner-models` volume, no published port, non-root, read-only filesystem, 3 GB limit) answers `POST /align` and `POST /align/batch` with `[{word, start, end}]`. The app (image unchanged, no torch) measures each sentence's words in a background job the first time an article is played (or via `POST .../articles/{id}/align-words`, progress via `GET`), writes `words: [[start_ms, end_ms], ...]` per segment into `audio/<id>.timings.json` (now version 2; readers of version 1 keep working) and `/timings` returns `words` per sentence. The player marks the word from those times in Compact, Lyrics (new per-word marking) and the playlist, and falls back to the previous length-based estimate when no word times exist or the sidecar is down. Measured on one article: after-pause word starts are within 29 ms on average (max 170 ms) of the audio's energy onsets.
### Fixed ### Fixed
- **Driving mode lyrics view no longer leaves the previous line's word highlighted** when the playback moves to the next sentence.
- **Sentence timings now also work for interview renders.** Every recorded audio file carries `"interview": true` (set whenever a second voice is configured, even without speaker labels), and the timings endpoint and EPUB export discarded all such records, so the player and the EPUB fell back to estimated sentence positions for every current article. The record is now matched to the text again, for interview renders by the label-stripped length the renderer recorded, so `/timings` returns exact (or anchored) sentence times and speech onsets for these articles too. Records for text edited since the render are still ignored. - **Sentence timings now also work for interview renders.** Every recorded audio file carries `"interview": true` (set whenever a second voice is configured, even without speaker labels), and the timings endpoint and EPUB export discarded all such records, so the player and the EPUB fell back to estimated sentence positions for every current article. The record is now matched to the text again, for interview renders by the label-stripped length the renderer recorded, so `/timings` returns exact (or anchored) sentence times and speech onsets for these articles too. Records for text edited since the render are still ignored.
### Changed ### Changed

View File

@ -289,7 +289,7 @@ turned into a podcast discussion between two or three voices you pick.
large touch target and keyboard reachable; the speed also applies to the large touch target and keyboard reachable; the speed also applies to the
playlist player. playlist player.
**Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it. **Hands-free:** while a track plays, the lock screen, notification shade, Bluetooth headset and car display show the article title, magazine and first-page artwork and offer play/pause, previous/next article and ±15 s (and seeking, where the device offers it). In driving mode the screen is kept awake while audio plays and allowed to sleep when it is paused or you leave. Both are optional browser features; where one is missing or refused, playback itself is unaffected. Opening driving mode resumes the first unfinished article at the position you left it.
**Layout and views:** driving mode is a top bar, a content area and a controls dock. The dock (position slider, time and playback speed, then previous, back 15 s, play/pause, forward 15 s, next) is always completely visible at the bottom and is never covered or pushed off screen, on phones from 320x568, tablets and in landscape. The top bar has *Share*, a *menu* button that shows the current view, *Index* and *Exit*. The menu opens a flyout: choose **Picture** (the picture fills the area), **Lyrics** (the whole text) or **Compact** (picture plus three lines, the default; in landscape side by side), and switch *Show text*, *Show pictures* and *Minimize text and picture* on or off. Keyboard: Enter or Arrow Down opens the menu, arrows/Home/End move, Enter chooses, Escape or a tap outside closes it and returns focus to the button. **Index:** the *Index* button opens a list of all articles of the magazine with their length (articles without audio are dimmed and marked *no audio*). The article that is playing is highlighted; tap it or press Enter on another to jump there while driving mode keeps playing. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines. **Layout and views:** driving mode is a top bar, a content area and a controls dock. The dock (position slider, time and playback speed, then previous, back 15 s, play/pause, forward 15 s, next) is always completely visible at the bottom and is never covered or pushed off screen, on phones from 320x568, tablets and in landscape. The top bar has *Share*, a *menu* button that shows the current view, *Index* and *Exit*. The menu opens a flyout: choose **Picture** (the picture fills the area), **Lyrics** (the whole text) or **Compact** (picture plus three lines, the default; in landscape side by side), and switch *Show text*, *Show pictures* and *Minimize text and picture* on or off. Keyboard: Enter or Arrow Down opens the menu, arrows/Home/End move, Enter chooses, Escape or a tap outside closes it and returns focus to the button. **Index:** the *Index* button opens a list of all articles of the magazine with their length (articles without audio are dimmed and marked *no audio*). The article that is playing is highlighted; tap it or press Enter on another to jump there while driving mode keeps playing. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. Each row also has a round **play button** (Play for unplayed, *Resume* with a circular-arrow icon for partly played, *Play again* with the replay icon for finished articles; it starts that article in driving mode and continues from the saved position), the author as *by Name* in smaller muted text under the title (omitted when unknown, long names are shortened), and a status icon on the right (empty circle: not played, dashed circle: partly played, check: finished) that also has the text *Not played / Partly played / Finished-Played* for screen readers and updates live while you listen. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines.
**Three lines of text:** driving mode shows the previous sentence (small), the current one (large, the spoken word marked) and the next (small) in a box of fixed height, so nothing moves around it. Tap or press Enter on the previous or next line to jump to that sentence. The text is deliberately not announced by screen readers sentence by sentence; the play/pause button and the position slider stay the way to control it. With reduced motion switched on in your device, lines change without animation. On a phone (375 px wide) the sentences wrap and a very long current sentence scrolls inside its box. Turn the text off with *Show text* in the menu to see only the picture. **Three lines of text:** driving mode shows the previous sentence (small), the current one (large, the spoken word marked) and the next (small) in a box of fixed height, so nothing moves around it. Tap or press Enter on the previous or next line to jump to that sentence. The text is deliberately not announced by screen readers sentence by sentence; the play/pause button and the position slider stay the way to control it. With reduced motion switched on in your device, lines change without animation. On a phone (375 px wide) the sentences wrap and a very long current sentence scrolls inside its box. Turn the text off with *Show text* in the menu to see only the picture.
**Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. The highlight lights up a sentence just before its first word is heard (each sentence starts with a short silence, which is not counted). Audio from before sentence times were recorded, and audio whose text was edited afterwards, has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync. Interview-voice audio is covered like any other. **Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. The highlight lights up a sentence just before its first word is heard (each sentence starts with a short silence, which is not counted). Audio from before sentence times were recorded, and audio whose text was edited afterwards, has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync. Interview-voice audio is covered like any other.
**Word by word:** the first time an article is played, the app asks the forced-aligner service (the separate `tts-aligner` container, see below) to measure where each word starts and ends and stores that beside the audio. Playback never waits for it: until it is ready (usually well under a minute for a magazine article) the marked word is estimated from word length as before, and it switches to the measured word on its own. The marked word is the last one that has started, so it stays lit through a pause; the *Lyrics* view now marks the spoken word too (as do *Compact* and the playlist text). If the service is not running, nothing changes for you and the app tries again later (at most every 10 minutes per article). Prerequisites: `docker compose up -d tts-aligner` (first start downloads about 1.2 GB of model weights into the `aligner-models` volume; after that it needs no internet). `ALIGNER_URL` empty turns word timing off. Privacy: text and audio go only to your own container on the internal Docker network. Accessibility: the marking is visual only and adds no announcements; with reduced motion nothing animates. API: `POST /api/magazine/{doc}/articles/{id}/align-words` starts it explicitly, `GET` shows progress. **Word by word:** the first time an article is played, the app asks the forced-aligner service (the separate `tts-aligner` container, see below) to measure where each word starts and ends and stores that beside the audio. Playback never waits for it: until it is ready (usually well under a minute for a magazine article) the marked word is estimated from word length as before, and it switches to the measured word on its own. The marked word is the last one that has started, so it stays lit through a pause; the *Lyrics* view now marks the spoken word too (as do *Compact* and the playlist text). If the service is not running, nothing changes for you and the app tries again later (at most every 10 minutes per article). Prerequisites: `docker compose up -d tts-aligner` (first start downloads about 1.2 GB of model weights into the `aligner-models` volume; after that it needs no internet). `ALIGNER_URL` empty turns word timing off. Privacy: text and audio go only to your own container on the internal Docker network. Accessibility: the marking is visual only and adds no announcements; with reduced motion nothing animates. API: `POST /api/magazine/{doc}/articles/{id}/align-words` starts it explicitly, `GET` shows progress.

View File

@ -5051,7 +5051,21 @@ function magTogglePlaylist(forceOpen) {
else { const audio = $('mag-playlist-audio'); if (audio) audio.pause(); } else { const audio = $('mag-playlist-audio'); if (audio) audio.pause(); }
} }
// One place for the three listening states, shared by the playlist and the driving-mode index.
function magListenState(a) {
const progress = (state.doc?.progress || {})[a.id] || {};
const completed = !!progress.completed;
const started = !completed && Number(progress.seconds) > 0;
return {
progress, completed, started,
status: completed ? 'complete' : started ? 'in-progress' : 'unplayed',
label: completed ? 'Played' : started ? 'Partly played' : 'Not played',
statusIcon: completed ? 'mdi-check-circle' : started ? 'mdi-progress-clock' : 'mdi-circle-outline',
};
}
function renderPlaylistTracks() { function renderPlaylistTracks() {
magDrivingIndexRefresh();
const list = $('mag-playlist-tracks'); const list = $('mag-playlist-tracks');
if (!list) return; if (!list) return;
const order = _magPlaylistOrder(); const order = _magPlaylistOrder();
@ -5061,11 +5075,7 @@ function renderPlaylistTracks() {
} }
list.innerHTML = order.map((a, i) => { list.innerHTML = order.map((a, i) => {
const active = a.id === state.playlistCurrent; const active = a.id === state.playlistCurrent;
const progress = (state.doc.progress || {})[a.id] || {}; const { progress, completed, started, status, label: statusLabel } = magListenState(a);
const completed = !!progress.completed;
const started = !completed && Number(progress.seconds) > 0;
const status = completed ? 'complete' : started ? 'in-progress' : 'unplayed';
const statusLabel = completed ? 'Played' : started ? 'Partly played' : 'Not played';
const duration = a.audio_seconds || progress.duration || a.estimated_seconds; const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' + return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' + '<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
@ -5078,7 +5088,7 @@ function renderPlaylistTracks() {
'</span>' + '</span>' +
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' + '<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' + '<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
(completed ? 'mdi-check-circle' : started ? 'mdi-progress-clock' : 'mdi-circle-outline') + magListenState(a).statusIcon +
'" aria-hidden="true"></span><span class="sr-only">' + statusLabel + '</span></span>' + '" aria-hidden="true"></span><span class="sr-only">' + statusLabel + '</span></span>' +
'</li>'; '</li>';
}).join(''); }).join('');
@ -6097,7 +6107,7 @@ function magDrivingSyncFull(sentences, index, wi) {
if (_drvFull.index !== index) { if (_drvFull.index !== index) {
const lines = box.children; const lines = box.children;
const old = _drvFull.index >= 0 ? lines[_drvFull.index] : null; const old = _drvFull.index >= 0 ? lines[_drvFull.index] : null;
if (old) { old.classList.remove('is-current'); old.tabIndex = -1; old.removeAttribute('aria-current'); } if (old) { old.classList.remove('is-current'); old.tabIndex = -1; old.removeAttribute('aria-current'); old.querySelectorAll('.mag-fw.is-current').forEach((w) => w.classList.remove('is-current')); }
for (let i = 0; i < lines.length; i++) lines[i].classList.toggle('is-past', i < index); for (let i = 0; i < lines.length; i++) lines[i].classList.toggle('is-past', i < index);
const cur = lines[index]; const cur = lines[index];
if (cur) { cur.classList.add('is-current'); cur.setAttribute('aria-current', 'true'); if (!box.contains(document.activeElement) || document.activeElement === box) cur.tabIndex = 0; } if (cur) { cur.classList.add('is-current'); cur.setAttribute('aria-current', 'true'); if (!box.contains(document.activeElement) || document.activeElement === box) cur.tabIndex = 0; }
@ -6260,18 +6270,50 @@ function magDrivingIndexRender() {
list.innerHTML = order.map((a, n) => { list.innerHTML = order.map((a, n) => {
const secs = Number(a.audio_seconds || 0); const secs = Number(a.audio_seconds || 0);
const dur = a.has_audio ? (secs > 0 ? magFmtClock(secs) : '') : 'no audio'; const dur = a.has_audio ? (secs > 0 ? magFmtClock(secs) : '') : 'no audio';
return '<li><button type="button" data-id="' + esc(a.id) + '"' + (a.has_audio ? '' : ' disabled') + '>' + const ls = magListenState(a);
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-title">' + esc(a.title || 'Untitled') + '</span>' + const id = esc(a.id), dis = a.has_audio ? '' : ' disabled';
'<span class="ix-dur">' + esc(dur) + '</span></button></li>'; return '<li class="ix-item is-' + ls.status + '" data-id="' + id + '">' +
'<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' +
'<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' +
'<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' +
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="ix-author">by ' + esc(a.author) + '</span>' : '') + '</span>' +
'<span class="ix-dur">' + esc(dur) + '</span>' +
'<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>';
}).join(''); }).join('');
magDrivingIndexMark(true); magDrivingIndexMark(true);
} }
function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; }
function magIndexPlayLabel(ls, a) {
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + (a.title || 'article');
}
// Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows.
function magDrivingIndexRefresh() {
const list = $('mag-driving-index-list');
if (!list || !list.firstChild) return;
list.querySelectorAll('.ix-item').forEach((li) => {
const a = state.articles.find((x) => x.id === li.dataset.id);
if (!a) return;
const ls = magListenState(a);
const cls = 'ix-item is-' + ls.status;
if (li.className !== cls) li.className = cls;
const st = li.querySelector('.ix-status');
if (st.getAttribute('aria-label') !== ls.label) {
st.setAttribute('aria-label', ls.label);
st.firstChild.className = 'mdi ' + ls.statusIcon;
const pl = li.querySelector('.ix-play');
pl.setAttribute('aria-label', magIndexPlayLabel(ls, a));
pl.firstChild.className = 'mdi ' + magIndexPlayIcon(ls);
}
});
magDrivingIndexMark(false);
}
// Highlights the playing article; scrolls it into view when asked (on open) so the list does not jump while open. // Highlights the playing article; scrolls it into view when asked (on open) so the list does not jump while open.
function magDrivingIndexMark(scroll) { function magDrivingIndexMark(scroll) {
const list = $('mag-driving-index-list'); const list = $('mag-driving-index-list');
if (!list) return; if (!list) return;
let cur = null; let cur = null;
list.querySelectorAll('button').forEach((b) => { list.querySelectorAll('.ix-row').forEach((b) => {
const on = b.dataset.id === state.playlistCurrent; const on = b.dataset.id === state.playlistCurrent;
if (on) { b.setAttribute('aria-current', 'true'); cur = b; } else b.removeAttribute('aria-current'); if (on) { b.setAttribute('aria-current', 'true'); cur = b; } else b.removeAttribute('aria-current');
}); });
@ -6291,7 +6333,7 @@ function magDrivingIndexOpen() {
if (!p.dataset.wired) { if (!p.dataset.wired) {
p.dataset.wired = '1'; p.dataset.wired = '1';
p.addEventListener('click', (e) => { p.addEventListener('click', (e) => {
const b = e.target.closest('#mag-driving-index-list button'); const b = e.target.closest('#mag-driving-index-list .ix-row, #mag-driving-index-list .ix-play');
if (!b || b.disabled) return; if (!b || b.disabled) return;
magDrivingIndexClose(); magDrivingIndexClose();
magPlaylistPlay(b.dataset.id); magPlaylistPlay(b.dataset.id);
@ -6301,8 +6343,9 @@ function magDrivingIndexOpen() {
const items = [...p.querySelectorAll('button:not([disabled])')]; const items = [...p.querySelectorAll('button:not([disabled])')];
if (!items.length) return; if (!items.length) return;
const i = items.indexOf(document.activeElement); const i = items.indexOf(document.activeElement);
const rows = [...p.querySelectorAll('#mag-driving-index-list button:not([disabled])')]; const rows = [...p.querySelectorAll('#mag-driving-index-list .ix-row:not([disabled])')];
const ri = rows.indexOf(document.activeElement); const fa = document.activeElement;
const ri = rows.indexOf(fa && fa.classList.contains('ix-play') ? fa.nextElementSibling : fa);
if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && rows.length) { if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && rows.length) {
e.preventDefault(); e.preventDefault();
rows[ri < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, ri + (e.key === 'ArrowDown' ? 1 : -1)))].focus(); rows[ri < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, ri + (e.key === 'ArrowDown' ? 1 : -1)))].focus();
@ -6316,7 +6359,7 @@ function magDrivingIndexOpen() {
magDrivingIndexRender(); magDrivingIndexRender();
p.hidden = false; btn.setAttribute('aria-expanded', 'true'); p.hidden = false; btn.setAttribute('aria-expanded', 'true');
magDrivingIndexPlace(); magDrivingIndexPlace();
const cur = p.querySelector('[aria-current="true"]:not([disabled])') || p.querySelector('#mag-driving-index-list button:not([disabled])') || $('mag-driving-index-close'); const cur = p.querySelector('.ix-row[aria-current="true"]:not([disabled])') || p.querySelector('#mag-driving-index-list button:not([disabled])') || $('mag-driving-index-close');
magDrivingIndexMark(true); magDrivingIndexMark(true);
cur.focus({ preventScroll: true }); cur.focus({ preventScroll: true });
} }

View File

@ -5766,13 +5766,24 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
.mag-driving-index-head h3 { margin: 0; font-size: 17px; } .mag-driving-index-head h3 { margin: 0; font-size: 17px; }
.mag-driving-index-head button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: transparent; color: var(--text); font-size: 22px; cursor: pointer; } .mag-driving-index-head button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: transparent; color: var(--text); font-size: 22px; cursor: pointer; }
.mag-driving-index-list { list-style: none; margin: 0; padding: 0 8px 8px; flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } .mag-driving-index-list { list-style: none; margin: 0; padding: 0 8px 8px; flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.mag-driving-index-list button { width: 100%; min-height: 44px; padding: 6px 10px; display: flex; align-items: center; gap: 10px; text-align: left; .mag-driving-index-list .ix-item { display: flex; align-items: center; gap: 6px; border-radius: 10px; }
border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 15px; cursor: pointer; } .mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) { background: var(--accent); color: var(--on-accent, #fff); }
.mag-driving-index-list button[aria-current="true"] { background: var(--accent); color: var(--on-accent, #fff); font-weight: 700; } .mag-driving-index-list button { min-height: 44px; border: 0; background: transparent; color: var(--text); font-size: 15px; cursor: pointer; }
.mag-driving-index-list .ix-row { flex: 1 1 auto; min-width: 0; padding: 6px 8px 6px 0; display: flex; align-items: center; gap: 8px; text-align: left; border-radius: 10px; }
.mag-driving-index-list .ix-play { flex: none; width: 44px; height: 44px; min-height: 44px; padding: 0; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center;
background: var(--accent); color: var(--on-accent, #fff); font-size: 22px; box-sizing: border-box; }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) .ix-play { background: var(--on-accent, #fff); color: var(--accent); }
.mag-driving-index-list .ix-row[aria-current="true"] { color: var(--on-accent, #fff); font-weight: 700; }
.mag-driving-index-list button:disabled { opacity: .5; cursor: default; } .mag-driving-index-list button:disabled { opacity: .5; cursor: default; }
.mag-driving-index-list .mdi-num { flex: none; min-width: 2.2em; text-align: right; font-variant-numeric: tabular-nums; opacity: .7; } .mag-driving-index-list .mdi-num { flex: none; min-width: 2em; text-align: right; font-variant-numeric: tabular-nums; opacity: .7; }
.mag-driving-index-list .ix-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } .mag-driving-index-list .ix-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.mag-driving-index-list .ix-title { overflow-wrap: anywhere; }
.mag-driving-index-list .ix-author { font-size: 12px; font-weight: 400; opacity: .75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mag-driving-index-list .ix-dur { flex: none; font-variant-numeric: tabular-nums; font-size: 14px; } .mag-driving-index-list .ix-dur { flex: none; font-variant-numeric: tabular-nums; font-size: 14px; }
.mag-driving-index-list .ix-status { flex: none; font-size: 22px; line-height: 1; display: inline-flex; color: var(--subtext); }
.mag-driving-index-list .is-in-progress .ix-status { color: var(--yellow, #e08a00); }
.mag-driving-index-list .is-complete .ix-status { color: var(--green); }
.mag-driving-index-list .ix-item:has(.ix-row[aria-current="true"]) .ix-status { color: inherit; }
.mag-driving-view-label { display: none; } .mag-driving-view-label { display: none; }
@media (min-width: 560px) { .mag-driving-view-label { display: inline; } } @media (min-width: 560px) { .mag-driving-view-label { display: inline; } }

View File

@ -2976,13 +2976,13 @@ test.describe('Driving mode layout and views', () => {
{ id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 }, { id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 },
{ id: 'article-003', title: 'Unvoiced', pages: [5], has_audio: false }, { id: 'article-003', title: 'Unvoiced', pages: [5], has_audio: false },
]; ];
async function setupIndex(page, count) { async function setupIndex(page, count, extra = {}) {
await setup(page); await setup(page);
const base = { ...ARTICLE_APPROVED, status: 'approved' }; const base = { ...ARTICLE_APPROVED, status: 'approved' };
const arts = count ? Array.from({ length: count }, (_, i) => ({ ...base, id: i === 0 ? 'article-001' : 'article-' + String(i + 1).padStart(3, '0'), const arts = count ? Array.from({ length: count }, (_, i) => ({ ...base, id: i === 0 ? 'article-001' : 'article-' + String(i + 1).padStart(3, '0'),
title: 'Article number ' + (i + 1), pages: [i + 1], has_audio: i % 10 !== 9, audio_seconds: i === 0 ? 12 : 60 + i })) title: 'Article number ' + (i + 1), pages: [i + 1], has_audio: i % 10 !== 9, audio_seconds: i === 0 ? 12 : 60 + i }))
: THREE.map((a) => ({ ...base, ...a })); : THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } })); await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } }));
} }
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => { test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
await setupIndex(page); await setupIndex(page);
@ -2994,7 +2994,7 @@ test.describe('Driving mode layout and views', () => {
await btn.click(); await btn.click();
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'true'); await expect(btn).toHaveAttribute('aria-expanded', 'true');
const rows = page.locator('#mag-driving-index-list button'); const rows = page.locator('#mag-driving-index-list .ix-row');
await expect(rows).toHaveCount(3); await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('The Future of Batteries'); await expect(rows.nth(0)).toContainText('The Future of Batteries');
await expect(rows.nth(0)).toContainText('0:12'); await expect(rows.nth(0)).toContainText('0:12');
@ -3013,8 +3013,9 @@ test.describe('Driving mode layout and views', () => {
await expect(rows.nth(1)).toBeFocused(); await expect(rows.nth(1)).toBeFocused();
await page.keyboard.press('ArrowDown'); // disabled row is skipped await page.keyboard.press('ArrowDown'); // disabled row is skipped
await expect(rows.nth(1)).toBeFocused(); await expect(rows.nth(1)).toBeFocused();
await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-close')).toBeFocused(); await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-close')).toBeFocused(); // trapped: wraps to the first control
await page.keyboard.press('Tab'); await expect(rows.nth(0)).toBeFocused(); // trapped: wraps to the first control await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-list .ix-play').first()).toBeFocused();
await page.keyboard.press('Tab'); await expect(rows.nth(0)).toBeFocused();
await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter');
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
await expect(page.locator('#mag-driving')).toBeVisible(); await expect(page.locator('#mag-driving')).toBeVisible();
@ -3028,10 +3029,60 @@ test.describe('Driving mode layout and views', () => {
// the exact top-bar list button, while audio plays // the exact top-bar list button, while audio plays
await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {})); await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {}));
await page.locator('.mag-driving-top #mag-driving-indexbtn').click(); await page.locator('.mag-driving-top #mag-driving-indexbtn').click();
await expect(page.locator('#mag-driving-index-list button').filter({ hasText: /\d+:\d\d/ })).toHaveCount(2); await expect(page.locator('#mag-driving-index-list .ix-row').filter({ hasText: /\d+:\d\d/ })).toHaveCount(2);
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
}); });
test('index rows: play/resume/replay buttons, three-state indicator with text, author line, live update', async ({ page }) => {
await setupIndex(page, 0, {
authors: { 'article-001': '', 'article-002': 'A very long author name that must be shortened with an ellipsis instead of overflowing the sheet' },
progress: { 'article-001': { seconds: 5, duration: 12, completed: false }, 'article-002': { seconds: 3725, duration: 3725, completed: true } },
});
await page.setViewportSize({ width: 320, height: 568 });
await open(page);
await page.locator('#mag-driving-indexbtn').click();
const item = (n) => page.locator('#mag-driving-index-list .ix-item').nth(n);
await expect(item(0).locator('.ix-status')).toHaveAttribute('aria-label', 'Partly played');
await expect(item(1).locator('.ix-status')).toHaveAttribute('aria-label', 'Played');
await expect(item(2).locator('.ix-status')).toHaveAttribute('aria-label', 'Not played');
await expect(item(0).locator('.ix-play .mdi')).toHaveClass(/mdi-restore/);
await expect(item(0).locator('.ix-play')).toHaveAttribute('aria-label', /^Resume: /);
await expect(item(1).locator('.ix-play .mdi')).toHaveClass(/mdi-replay/);
await expect(item(2).locator('.ix-play')).toBeDisabled(); // no audio stays disabled
await expect(item(2).locator('.ix-row')).toBeDisabled();
await expect(item(0).locator('.ix-author')).toHaveCount(0); // no author: no line
await expect(item(1).locator('.ix-author')).toContainText('by A very long');
const g = await page.evaluate(() => {
const els = [...document.querySelectorAll('#mag-driving-index-list .ix-play')];
const a = document.querySelector('#mag-driving-index-list .ix-author'), li = a.closest('.ix-item'), list = document.getElementById('mag-driving-index-list');
const author = document.querySelectorAll('#mag-driving-index-list .ix-author')[0];
return { circles: els.map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }), over: author.scrollWidth > author.clientWidth,
liInside: li.getBoundingClientRect().right <= list.getBoundingClientRect().right + 0.5, scrollX: document.documentElement.scrollWidth,
fs: parseFloat(getComputedStyle(author).fontSize), tfs: parseFloat(getComputedStyle(document.querySelector('#mag-driving-index-list .ix-title')).fontSize) };
});
for (const [w, h] of g.circles) { expect(w).toBeGreaterThanOrEqual(43.5); expect(Math.abs(w - h)).toBeLessThan(0.5); }
expect(g.over).toBe(true); expect(g.liInside).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(320); expect(g.fs).toBeLessThan(g.tfs);
// the resume button starts that article without leaving driving mode
const current = () => page.evaluate(() => document.getElementById('mag-playlist-audio').dataset.article);
await item(0).locator('.ix-play').click();
await expect(page.locator('#mag-driving-index')).toBeHidden();
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(current).toBe('article-001');
// live: when the current article ends, its indicator flips to Played and the next one becomes current
await page.evaluate(() => document.getElementById('mag-playlist-audio').dispatchEvent(new Event('ended')));
await expect.poll(current).toBe('article-002');
await page.locator('#mag-driving-indexbtn').click();
await expect(item(0).locator('.ix-status')).toHaveAttribute('aria-label', 'Played');
await expect(item(0)).toHaveClass(/is-complete/);
await expect(item(0).locator('.ix-play .mdi')).toHaveClass(/mdi-replay/);
await expect(item(1).locator('.ix-row')).toHaveAttribute('aria-current', 'true');
// the sheet stays live while open: finishing article 002 updates its row in place
await page.evaluate(() => document.getElementById('mag-playlist-audio').dispatchEvent(new Event('ended')));
await expect(item(1)).toHaveClass(/is-complete/);
await item(0).locator('.ix-play').click();
await expect.poll(current).toBe('article-001');
});
test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => { test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => {
await setupIndex(page, 113); await setupIndex(page, 113);
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
@ -3039,13 +3090,13 @@ test.describe('Driving mode layout and views', () => {
await page.evaluate(() => window.magPlaylistPlay('article-090')); await page.evaluate(() => window.magPlaylistPlay('article-090'));
const t = await page.evaluate(() => { const t0 = performance.now(); magDrivingIndexOpen(); return performance.now() - t0; }); const t = await page.evaluate(() => { const t0 = performance.now(); magDrivingIndexOpen(); return performance.now() - t0; });
expect(t).toBeLessThan(250); expect(t).toBeLessThan(250);
await expect(page.locator('#mag-driving-index-list button')).toHaveCount(113); await expect(page.locator('#mag-driving-index-list .ix-row')).toHaveCount(113);
const inView = await page.evaluate(() => { const inView = await page.evaluate(() => {
const l = document.getElementById('mag-driving-index-list').getBoundingClientRect(), c = document.querySelector('#mag-driving-index-list [aria-current="true"]').getBoundingClientRect(); const l = document.getElementById('mag-driving-index-list').getBoundingClientRect(), c = document.querySelector('#mag-driving-index-list [aria-current="true"]').getBoundingClientRect();
return c.top >= l.top - 1 && c.bottom <= l.bottom + 1; return c.top >= l.top - 1 && c.bottom <= l.bottom + 1;
}); });
expect(inView).toBe(true); expect(inView).toBe(true);
await expect(page.locator('#mag-driving-index-list button:disabled').first()).toContainText('no audio'); await expect(page.locator('#mag-driving-index-list .ix-row:disabled').first()).toContainText('no audio');
}); });
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) { for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
@ -3084,9 +3135,12 @@ test.describe('Driving mode layout and views', () => {
g = await geo(); g = await geo();
expect(g.idxInside, JSON.stringify(g.idx)).toBe(true); expect(g.idxAboveDock).toBe(true); expect(g.idxBelowTop).toBe(true); expect(g.idxInside, JSON.stringify(g.idx)).toBe(true); expect(g.idxAboveDock).toBe(true); expect(g.idxBelowTop).toBe(true);
expect(g.dockInside).toBe(true); expect(g.dockCovered).toBe(false); expect(g.btns).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(w); expect(g.dockInside).toBe(true); expect(g.dockCovered).toBe(false); expect(g.btns).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(w);
await expect(page.locator('#mag-driving-index-list button').first()).toBeVisible(); await expect(page.locator('#mag-driving-index-list .ix-row').first()).toBeVisible();
const rowH = await page.locator('#mag-driving-index-list button').first().evaluate((e) => e.getBoundingClientRect().height); const rowH = await page.locator('#mag-driving-index-list .ix-row').first().evaluate((e) => e.getBoundingClientRect().height);
expect(rowH).toBeGreaterThanOrEqual(43.5); expect(rowH).toBeGreaterThanOrEqual(43.5);
const circles = await page.locator('#mag-driving-index-list .ix-play').first().evaluate((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; });
expect(circles[0]).toBeGreaterThanOrEqual(43.5); expect(Math.abs(circles[0] - circles[1])).toBeLessThan(0.5);
expect(await page.evaluate(() => { const l = document.getElementById('mag-driving-index-list'); return l.scrollWidth <= l.clientWidth + 1; })).toBe(true);
}); });
} }
@ -3095,7 +3149,7 @@ test.describe('Driving mode layout and views', () => {
await page.setViewportSize({ width: 375, height: 667 }); await page.setViewportSize({ width: 375, height: 667 });
await open(page); await open(page);
await pick(page, 'lyrics'); await pick(page, 'lyrics');
const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .is-current')?.dataset.i ?? -1)); const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .mag-driving-fline.is-current')?.dataset.i ?? -1));
for (const t of [10.5, 2, 7.5, 3.6, 0.1]) { for (const t of [10.5, 2, 7.5, 3.6, 0.1]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t); await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
await expect.poll(current, { message: 'seek to ' + t }).toBe(expectedAt(t)); await expect.poll(current, { message: 'seek to ' + t }).toBe(expectedAt(t));
@ -3104,7 +3158,7 @@ test.describe('Driving mode layout and views', () => {
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 7; }); await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 7; });
await expect.poll(current).toBe(2); await expect.poll(current).toBe(2);
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const box = document.getElementById('mag-driving-full').getBoundingClientRect(), el = document.querySelector('#mag-driving-full .is-current').getBoundingClientRect(); const box = document.getElementById('mag-driving-full').getBoundingClientRect(), el = document.querySelector('#mag-driving-full .mag-driving-fline.is-current').getBoundingClientRect();
return Math.abs((el.top + el.height / 2) - (box.top + box.height / 2)); return Math.abs((el.top + el.height / 2) - (box.top + box.height / 2));
})).toBeLessThan(40); })).toBeLessThan(40);
// dim neighbours // dim neighbours