feat(magazine): driving mode as top bar, flexible content and pinned controls dock with picture, lyrics and compact views

Controls can no longer fall off screen (320x568 up to tablets and landscape), share no longer overlaps the title.
Picture view with zoom/pan, Spotify-style whole-text lyrics with follow/back-to-current, minimize to current line,
persisted view choice, 44px segmented control.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-02 16:54:21 +02:00
parent f88fefcee4
commit adc1fab3f8
6 changed files with 594 additions and 15 deletions

View File

@ -11,6 +11,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
- **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.
### Changed ### Changed
- **Driving mode has a new layout that always keeps the controls on screen.** The overlay is now one full-height column: a top bar (share, a *Picture / Lyrics / Compact* switch, minimize, close), a content area that shrinks and scrolls inside itself, and a controls dock pinned to the bottom (position slider, time, previous / -15 / play / +15 / next, speed, text, images). On an iPhone SE (375x667) the controls used to fall below the screen, the share button covered the article title and the top of the title was cut off; now the dock is always fully visible from 320x568 up to tablets, in landscape the picture and the three lines sit side by side, and long titles wrap to two lines. *Picture* fills the area (double-tap, pinch, wheel, +/- keys or the zoom buttons to zoom, drag to pan, Escape or the reset button to reset); *Lyrics* shows the whole text, the spoken sentence large and centered, tap a sentence to jump there, and scrolling by hand pauses following until *Back to current*; *Compact* (default) is the picture plus previous / current / next line. The minimize button shrinks everything to just the current line. The choice is remembered. Transport buttons stay perfect circles.
- **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before. - **Driving mode shows three lines of text instead of the whole article.** The previous sentence (small), the current one (large, with the spoken word marked) and the next one (small) sit in a fixed-height box, so the controls below never jump. Lines fade in smoothly (no animation when your device asks for reduced motion), the text is not a live region so a screen reader does not read out every sentence, it is readable at 375 px, and tapping the previous or next line jumps to that sentence. Pictures work as before.
### Fixed ### Fixed

View File

@ -289,6 +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 and time, previous, back 15 s, play/pause, forward 15 s, next, speed, *Text*, *Images*) 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* (left), a three-way switch (middle), *Minimize* and *Exit*. Switch between **Picture** (the picture fills the area), **Lyrics** (the whole text) and **Compact** (picture plus three lines, the default; in landscape side by side) with a tap or with the arrow keys once the switch has focus. **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** (the chevron button) reduces the area to the title and the current line so the dock keeps minimal text; press 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 the *Text* option 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 the *Text* option 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). Older audio, interview-style audio 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. **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). Older audio, interview-style audio 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.
**Share a link:** the *Share* button in the toolbar (the whole magazine), the share button in the playlist header and the one at the top left of driving mode (the current article) share a link of the form `<this app's address>/#s-magazine?doc=<magazine>&article=<article>&mode=drive`. Where the browser has a share sheet (phones, and any browser on https) it opens; otherwise the link is copied and a confirmation is announced (on a plain-http address a box with the link is shown to copy by hand). Opening the link goes straight to that article in driving mode with audio, pictures and text; if the browser refuses to start audio without a tap, a large *Tap to play* button appears. A link for the whole magazine opens at its first unfinished article. **Privacy and access:** the link contains only ids, no password or key. It is just an address: whoever opens it needs to reach this app the same way as for any other page (this app has no user accounts; access is controlled by your network or reverse proxy), and an unknown or removed article simply shows a message. Only articles that already have audio can be shared. A link whose parameters are not in the expected form is ignored. **Share a link:** the *Share* button in the toolbar (the whole magazine), the share button in the playlist header and the one at the top left of driving mode (the current article) share a link of the form `<this app's address>/#s-magazine?doc=<magazine>&article=<article>&mode=drive`. Where the browser has a share sheet (phones, and any browser on https) it opens; otherwise the link is copied and a confirmation is announced (on a plain-http address a box with the link is shown to copy by hand). Opening the link goes straight to that article in driving mode with audio, pictures and text; if the browser refuses to start audio without a tap, a large *Tap to play* button appears. A link for the whole magazine opens at its first unfinished article. **Privacy and access:** the link contains only ids, no password or key. It is just an address: whoever opens it needs to reach this app the same way as for any other page (this app has no user accounts; access is controlled by your network or reverse proxy), and an unknown or removed article simply shows a message. Only articles that already have audio can be shared. A link whose parameters are not in the expected form is ignored.

View File

@ -5627,7 +5627,7 @@ function magToggleDriving() {
state.driving = !state.driving; state.driving = !state.driving;
const overlay = $('mag-driving'); const overlay = $('mag-driving');
if (overlay) overlay.hidden = !state.driving; if (overlay) overlay.hidden = !state.driving;
if (state.driving) renderDriving(); if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); }
magDrivingWake(); magDrivingWake();
} }
@ -5796,6 +5796,7 @@ function magDrivingTogglePlayback() {
// ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ──────── // ── Driving mode extras: +/-15 s, speed, text and image toggles, seek ────────
const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2]; const MAG_DRIVING_SPEEDS = [0.75, 1, 1.25, 1.5, 1.75, 2];
const MAG_DRIVING_VIEWS = ['image', 'lyrics', 'compact'];
const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs';
function magDrivingPrefs() { function magDrivingPrefs() {
@ -5805,6 +5806,8 @@ function magDrivingPrefs() {
state.drivingPrefs = { state.drivingPrefs = {
speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1, speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1,
text: saved.text !== false, images: saved.images !== false, text: saved.text !== false, images: saved.images !== false,
view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : 'compact',
collapsed: saved.collapsed === true,
}; };
} }
return state.drivingPrefs; return state.drivingPrefs;
@ -5871,6 +5874,7 @@ function magDrivingApplyToggles() {
const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual');
if (lyrics && !prefs.text) lyrics.hidden = true; if (lyrics && !prefs.text) lyrics.hidden = true;
if (visual && !prefs.images) visual.hidden = true; if (visual && !prefs.images) visual.hidden = true;
magDrivingApplyView();
} }
// Called from magPlaylistSync whenever the sentence, word or clock changes while the overlay is open. // Called from magPlaylistSync whenever the sentence, word or clock changes while the overlay is open.
@ -5937,9 +5941,199 @@ function magDrivingSync(audio, duration, pct, sentenceIndex, local, timeline) {
visual.hidden = !show; visual.hidden = !show;
if (show && visual.dataset.imageId !== source.dataset.imageId) { if (show && visual.dataset.imageId !== source.dataset.imageId) {
visual.dataset.imageId = source.dataset.imageId; visual.dataset.imageId = source.dataset.imageId;
visual.innerHTML = source.innerHTML; const pz = $('mag-driving-pz'), cap = $('mag-driving-cap');
const img = source.querySelector('img'), figcap = source.querySelector('figcaption');
if (pz) pz.replaceChildren(...(img ? [img.cloneNode(true)] : []));
if (cap) cap.innerHTML = figcap ? figcap.innerHTML : '';
magDrivingZoomReset();
} }
const noimg = $('mag-driving-noimg');
if (noimg) noimg.hidden = !!show || !prefs.images;
} }
magDrivingSyncFull(sentences, sentenceIndex);
}
// ── Views, whole-text "lyrics", picture zoom ──────────────────────────────────────────────────────
function magDrivingApplyView() {
const prefs = magDrivingPrefs();
const overlay = $('mag-driving');
if (!overlay) return;
overlay.dataset.view = prefs.view;
overlay.dataset.collapsed = prefs.collapsed ? 'true' : 'false';
document.querySelectorAll('#mag-driving-views [role="radio"]').forEach((b) => {
const on = b.dataset.view === prefs.view;
b.setAttribute('aria-checked', String(on));
b.tabIndex = on ? 0 : -1;
});
const c = $('mag-driving-collapse');
if (c) {
c.setAttribute('aria-expanded', String(!prefs.collapsed));
const label = prefs.collapsed ? 'Show the text and picture area' : 'Minimize the text and picture area';
c.setAttribute('aria-label', label); c.title = label;
const ic = c.querySelector('.mdi'); if (ic) ic.className = 'mdi ' + (prefs.collapsed ? 'mdi-chevron-up' : 'mdi-chevron-down');
}
}
function magDrivingSetView(view) {
if (!MAG_DRIVING_VIEWS.includes(view)) return;
const prefs = magDrivingPrefs();
prefs.view = view;
if (prefs.collapsed) prefs.collapsed = false;
magDrivingSavePrefs();
magDrivingApplyView();
magPlaylistSync();
if (view === 'lyrics') magDrivingBackToCurrent();
}
function magDrivingToggleCollapsed() {
const prefs = magDrivingPrefs();
prefs.collapsed = !prefs.collapsed;
magDrivingSavePrefs();
magDrivingApplyView();
if (!prefs.collapsed && prefs.view === 'lyrics') magDrivingBackToCurrent();
}
// Whole text, Spotify style: every sentence is a line; the current one is large and kept centered.
// Manual scrolling pauses the following until "Back to current" (or a tap on a sentence) resumes it.
const _drvFull = { follow: true, key: '', index: -1, wired: false };
function magDrivingScrollToCurrent(smooth) {
const box = $('mag-driving-full');
const cur = box && box.querySelector('.is-current');
if (!cur || !box.clientHeight) return;
const top = cur.offsetTop - (box.clientHeight - cur.offsetHeight) / 2;
box.scrollTo({ top: Math.max(0, top), behavior: smooth && !prefersReducedMotion() ? 'smooth' : 'auto' });
}
function magDrivingBackToCurrent() {
_drvFull.follow = true;
const b = $('mag-driving-backcur'); if (b) b.hidden = true;
magDrivingScrollToCurrent(true);
}
function magDrivingSyncFull(sentences, index) {
const box = $('mag-driving-full');
const prefs = magDrivingPrefs();
if (!box || prefs.view !== 'lyrics' || !prefs.text) return;
const key = state.playlistCurrent + ':' + sentences.length;
if (_drvFull.key !== key) {
_drvFull.key = key; _drvFull.index = -1; _drvFull.follow = true;
box.innerHTML = sentences.map((sent, i) =>
'<p class="mag-driving-fline" data-i="' + i + '" tabindex="-1" role="button">' + esc(sent.text || '') + '</p>').join('');
box.scrollTop = 0;
}
if (!_drvFull.wired) {
_drvFull.wired = true;
const stop = () => { _drvFull.follow = false; const b = $('mag-driving-backcur'); if (b) b.hidden = false; };
['wheel', 'touchmove'].forEach((ev) => box.addEventListener(ev, stop, { passive: true }));
box.addEventListener('pointerdown', (e) => { if (e.target === box) stop(); }, { passive: true }); // grabbing the scrollbar
box.addEventListener('click', (e) => {
const line = e.target.closest('.mag-driving-fline');
if (line) { _drvFull.follow = true; const b = $('mag-driving-backcur'); if (b) b.hidden = true; magPlaylistSeekSentence(Number(line.dataset.i)); }
});
box.addEventListener('keydown', (e) => {
const line = e.target.closest && e.target.closest('.mag-driving-fline');
if (e.key === 'Enter' || e.key === ' ') { if (line) { e.preventDefault(); line.click(); } return; }
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const from = line || box.querySelector('.is-current') || box.firstElementChild;
const to = from && (e.key === 'ArrowDown' ? from.nextElementSibling : from.previousElementSibling);
if (to) { box.querySelectorAll('[tabindex="0"]').forEach((x) => { x.tabIndex = -1; }); to.tabIndex = 0; to.focus(); stop(); }
});
}
if (_drvFull.index !== index) {
const lines = box.children;
const old = _drvFull.index >= 0 ? lines[_drvFull.index] : null;
if (old) { old.classList.remove('is-current'); old.tabIndex = -1; old.removeAttribute('aria-current'); }
for (let i = 0; i < lines.length; i++) lines[i].classList.toggle('is-past', i < 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; }
_drvFull.index = index;
if (_drvFull.follow) { _drvFull.programmatic = Date.now(); magDrivingScrollToCurrent(true); }
} else if (_drvFull.follow && !_drvFull.settled) {
_drvFull.settled = true; magDrivingScrollToCurrent(false);
}
}
// Picture zoom: buttons, wheel, keys, double tap/click, pinch and drag-to-pan. State lives in _drvZoom.
const _drvZoom = { s: 1, x: 0, y: 0, wired: false, pts: new Map(), pinch: 0, lastTap: 0, moved: false };
function magDrivingZoomApply() {
const pz = $('mag-driving-pz'), stage = $('mag-driving-stage');
if (!pz || !stage) return;
const z = _drvZoom;
z.s = Math.min(5, Math.max(1, z.s));
const mx = (z.s - 1) * stage.clientWidth / 2, my = (z.s - 1) * stage.clientHeight / 2;
z.x = Math.max(-mx, Math.min(mx, z.x)); z.y = Math.max(-my, Math.min(my, z.y));
if (z.s === 1) { z.x = 0; z.y = 0; }
pz.style.transform = 'translate(' + z.x + 'px,' + z.y + 'px) scale(' + z.s + ')';
stage.dataset.zoom = String(Math.round(z.s * 100) / 100);
stage.classList.toggle('is-zoomed', z.s > 1);
const out = $('mag-driving-zoom-out'), inn = $('mag-driving-zoom-in'), rs = $('mag-driving-zoom-reset');
if (out) out.disabled = z.s <= 1; if (inn) inn.disabled = z.s >= 5; if (rs) rs.disabled = z.s <= 1;
}
function magDrivingZoomBy(f) { _drvZoom.s *= f; magDrivingZoomApply(); }
function magDrivingZoomReset() { _drvZoom.s = 1; _drvZoom.x = 0; _drvZoom.y = 0; magDrivingZoomApply(); }
function magDrivingZoomWire() {
const stage = $('mag-driving-stage');
if (!stage || _drvZoom.wired) return;
_drvZoom.wired = true;
const z = _drvZoom;
const dist = () => { const [a, b] = [...z.pts.values()]; return Math.hypot(a.x - b.x, a.y - b.y); };
stage.addEventListener('pointerdown', (e) => {
stage.setPointerCapture && stage.setPointerCapture(e.pointerId);
z.pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); z.moved = false;
if (z.pts.size === 2) z.pinch = dist();
});
stage.addEventListener('pointermove', (e) => {
const p = z.pts.get(e.pointerId); if (!p) return;
const dx = e.clientX - p.x, dy = e.clientY - p.y;
p.x = e.clientX; p.y = e.clientY;
if (z.pts.size === 2) { const d = dist(); if (z.pinch) z.s *= d / z.pinch; z.pinch = d; z.moved = true; magDrivingZoomApply(); }
else if (z.s > 1 && (dx || dy)) { z.x += dx; z.y += dy; z.moved = true; magDrivingZoomApply(); }
});
const up = (e) => {
const had = z.pts.delete(e.pointerId); z.pinch = 0;
if (!had || e.type === 'pointercancel' || z.moved || z.pts.size) return;
const now = Date.now();
if (now - z.lastTap < 350) { z.lastTap = 0; if (z.s > 1) magDrivingZoomReset(); else { z.s = 2.5; magDrivingZoomApply(); } }
else z.lastTap = now;
};
stage.addEventListener('pointerup', up);
stage.addEventListener('pointercancel', up);
stage.addEventListener('wheel', (e) => { e.preventDefault(); z.s *= e.deltaY < 0 ? 1.15 : 1 / 1.15; magDrivingZoomApply(); }, { passive: false });
stage.addEventListener('keydown', (e) => {
if (e.key === '+' || e.key === '=') { e.preventDefault(); magDrivingZoomBy(1.5); }
else if (e.key === '-') { e.preventDefault(); magDrivingZoomBy(1 / 1.5); }
else if (e.key === '0') { e.preventDefault(); magDrivingZoomReset(); }
else if (z.s > 1 && e.key.startsWith('Arrow')) {
e.preventDefault(); const step = 40;
if (e.key === 'ArrowLeft') z.x += step; if (e.key === 'ArrowRight') z.x -= step;
if (e.key === 'ArrowUp') z.y += step; if (e.key === 'ArrowDown') z.y -= step;
magDrivingZoomApply();
}
});
const overlay = $('mag-driving');
if (overlay) overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape' && z.s > 1) { e.preventDefault(); magDrivingZoomReset(); } });
window.addEventListener('resize', magDrivingZoomApply);
}
function magDrivingViewsWire() {
const group = $('mag-driving-views');
if (!group || group.dataset.wired) return;
group.dataset.wired = '1';
const radios = () => [...group.querySelectorAll('[role="radio"]')];
group.addEventListener('click', (e) => { const b = e.target.closest('[role="radio"]'); if (b) magDrivingSetView(b.dataset.view); });
group.addEventListener('keydown', (e) => {
const keys = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
if (!(e.key in keys)) return;
e.preventDefault();
const r = radios(), i = r.indexOf(document.activeElement);
const to = r[(i + keys[e.key] + r.length) % r.length];
magDrivingSetView(to.dataset.view); to.focus();
});
} }
function magDrivingNext() { function magDrivingNext() {
@ -6223,6 +6417,11 @@ root.magParseDeepLink = magParseDeepLink;
root.magDrivingTogglePlayback = magDrivingTogglePlayback; root.magDrivingTogglePlayback = magDrivingTogglePlayback;
root.magDrivingNext = magDrivingNext; root.magDrivingNext = magDrivingNext;
root.magDrivingSkip = magDrivingSkip; root.magDrivingSkip = magDrivingSkip;
root.magDrivingZoomBy = magDrivingZoomBy;
root.magDrivingZoomReset = magDrivingZoomReset;
root.magDrivingBackToCurrent = magDrivingBackToCurrent;
root.magDrivingToggleCollapsed = magDrivingToggleCollapsed;
root.magDrivingSetView = magDrivingSetView;
root.magDrivingSeek = magDrivingSeek; root.magDrivingSeek = magDrivingSeek;
root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingCycleSpeed = magDrivingCycleSpeed;
root.magDrivingToggleText = magDrivingToggleText; root.magDrivingToggleText = magDrivingToggleText;

View File

@ -324,24 +324,61 @@
<!-- Large controls, minimal text, no interaction required beyond play/ <!-- Large controls, minimal text, no interaction required beyond play/
pause/skip -- reuses whatever is already playing rather than its pause/skip -- reuses whatever is already playing rather than its
own playback path. --> own playback path. -->
<div id="mag-driving" class="mag-driving" hidden role="dialog" aria-label="Driving mode"> <div id="mag-driving" class="mag-driving" hidden role="dialog" aria-label="Driving mode" data-view="compact">
<button type="button" class="mag-driving-exit" onclick="magToggleDriving()" aria-label="Exit driving mode"> <!-- Layout: top bar / flexible content / controls dock. The dock is never scrolled or overlapped. -->
<span class="mdi mdi-close"></span> <div class="mag-driving-top">
</button> <button type="button" class="mag-driving-share" onclick="magShareCurrent()" aria-label="Share a link to this article" title="Share a link to this article">
<button type="button" class="mag-driving-share" onclick="magShareCurrent()" aria-label="Share a link to this article" title="Share a link to this article"> <span class="mdi mdi-share-variant-outline" aria-hidden="true"></span>
<span class="mdi mdi-share-variant-outline" aria-hidden="true"></span> </button>
</button> <div class="mag-driving-views" role="radiogroup" aria-label="What to show" id="mag-driving-views">
<div class="mag-driving-body"> <button type="button" role="radio" aria-checked="false" tabindex="-1" data-view="image" aria-label="Picture" title="Picture"><span class="mdi mdi-image-outline" aria-hidden="true"></span><span class="mag-driving-view-label">Picture</span></button>
<button type="button" role="radio" aria-checked="false" tabindex="-1" data-view="lyrics" aria-label="Lyrics: whole text" title="Lyrics: whole text"><span class="mdi mdi-text-long" aria-hidden="true"></span><span class="mag-driving-view-label">Lyrics</span></button>
<button type="button" role="radio" aria-checked="true" tabindex="0" data-view="compact" aria-label="Compact: picture and three lines" title="Compact: picture and three lines"><span class="mdi mdi-view-split-horizontal" aria-hidden="true"></span><span class="mag-driving-view-label">Compact</span></button>
</div>
<button type="button" class="mag-driving-collapse" id="mag-driving-collapse" aria-expanded="true" aria-controls="mag-driving-content"
onclick="magDrivingToggleCollapsed()" aria-label="Minimize the text and picture area" title="Minimize the text and picture area">
<span class="mdi mdi-chevron-down" aria-hidden="true"></span>
</button>
<button type="button" class="mag-driving-exit" onclick="magToggleDriving()" aria-label="Exit driving mode">
<span class="mdi mdi-close" aria-hidden="true"></span>
</button>
</div>
<div class="mag-driving-content" id="mag-driving-content">
<button type="button" class="mag-driving-start" id="mag-driving-start" onclick="magDrivingStart()" hidden> <button type="button" class="mag-driving-start" id="mag-driving-start" onclick="magDrivingStart()" hidden>
<span class="mdi mdi-play-circle-outline" aria-hidden="true"></span> Tap to play <span class="mdi mdi-play-circle-outline" aria-hidden="true"></span> Tap to play
</button> </button>
<h2 id="mag-driving-title">Article</h2> <div class="mag-driving-head">
<p id="mag-driving-by" class="muted"></p> <h2 id="mag-driving-title">Article</h2>
<div id="mag-driving-queue" class="mag-driving-queue" hidden> <p id="mag-driving-by" class="muted"></p>
<span id="mag-queue-progress-2" class="muted"></span> <div id="mag-driving-queue" class="mag-driving-queue" hidden>
<span id="mag-queue-progress-2" class="muted"></span>
</div>
</div>
<div class="mag-driving-stagewrap">
<figure id="mag-driving-visual" class="mag-driving-visual" hidden>
<div class="mag-driving-stage" id="mag-driving-stage" tabindex="0" role="group"
aria-label="Picture. Zoom with the buttons, the plus and minus keys, the mouse wheel or by pinching. Double-tap to zoom. Escape resets.">
<div class="mag-driving-pz" id="mag-driving-pz"></div>
</div>
<div class="mag-driving-zoomctl" role="group" aria-label="Zoom">
<button type="button" id="mag-driving-zoom-out" aria-label="Zoom out" title="Zoom out" onclick="magDrivingZoomBy(1/1.5)"><span class="mdi mdi-magnify-minus-outline" aria-hidden="true"></span></button>
<button type="button" id="mag-driving-zoom-in" aria-label="Zoom in" title="Zoom in" onclick="magDrivingZoomBy(1.5)"><span class="mdi mdi-magnify-plus-outline" aria-hidden="true"></span></button>
<button type="button" id="mag-driving-zoom-reset" aria-label="Reset zoom" title="Reset zoom" onclick="magDrivingZoomReset()"><span class="mdi mdi-fit-to-screen-outline" aria-hidden="true"></span></button>
</div>
<figcaption id="mag-driving-cap"></figcaption>
</figure>
<p id="mag-driving-noimg" class="mag-driving-noimg muted" hidden>No picture for this part of the article.</p>
</div> </div>
<figure id="mag-driving-visual" class="mag-driving-visual" hidden></figure>
<div id="mag-driving-lyrics" class="mag-driving-lyrics" role="group" aria-live="off" aria-label="Article text: previous, current and next sentence" hidden></div> <div id="mag-driving-lyrics" class="mag-driving-lyrics" role="group" aria-live="off" aria-label="Article text: previous, current and next sentence" hidden></div>
<div class="mag-driving-full-wrap">
<div id="mag-driving-full" class="mag-driving-full" role="group" aria-live="off" tabindex="0"
aria-label="Whole article text. Arrow keys move between sentences, Enter plays from the sentence."></div>
<button type="button" class="mag-driving-backcur" id="mag-driving-backcur" hidden onclick="magDrivingBackToCurrent()">
<span class="mdi mdi-crosshairs-gps" aria-hidden="true"></span> Back to current
</button>
</div>
</div>
<div class="mag-driving-dock">
<div class="mag-driving-seek"> <div class="mag-driving-seek">
<input type="range" id="mag-driving-seek" min="0" max="1000" value="0" step="1" <input type="range" id="mag-driving-seek" min="0" max="1000" value="0" step="1"
aria-label="Position in article" oninput="magDrivingSeek(this.value)"> aria-label="Position in article" oninput="magDrivingSeek(this.value)">

View File

@ -5713,3 +5713,133 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; }
background: var(--surface); color: var(--text); border: 2px solid var(--accent); border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.35); } background: var(--surface); color: var(--text); border: 2px solid var(--accent); border-radius: 12px; box-shadow: 0 12px 40px rgba(0,0,0,.35); }
.mag-share-sheet[hidden] { display: none; } .mag-share-sheet[hidden] { display: none; }
.mag-share-sheet input { width: 100%; box-sizing: border-box; } .mag-share-sheet input { width: 100%; box-sizing: border-box; }
/* ── Driving mode layout: top bar / flexible content / controls dock ───────────────────────────────
The overlay is one fixed full-viewport column (dvh with a vh fallback, safe-area padding). Only the
content area flexes and scrolls internally; the dock is a flex:none sibling, so it can never be pushed
off screen or covered, from 320x568 up to tablets and landscape. This block supersedes the older
driving-mode rules above. */
.mag-driving { display: flex; flex-direction: column; align-items: stretch; justify-content: flex-start;
inset: 0; height: 100vh; height: 100dvh; overflow: hidden; box-sizing: border-box;
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); }
.mag-driving[hidden], .mag-driving [hidden] { display: none !important; }
.mag-driving-top { flex: none; display: flex; align-items: center; gap: 6px; padding: 8px; box-sizing: border-box; min-width: 0; }
.mag-driving-top .mag-driving-share, .mag-driving-top .mag-driving-exit, .mag-driving-collapse {
position: static; flex: none; width: 44px; height: 44px; min-width: 44px; min-height: 44px; padding: 0; box-sizing: border-box;
border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-size: 22px;
display: flex; align-items: center; justify-content: center; cursor: pointer; }
.mag-driving-collapse:focus-visible, .mag-driving-views button:focus-visible, .mag-driving-zoomctl button:focus-visible,
.mag-driving-backcur:focus-visible, .mag-driving-stage:focus-visible, .mag-driving-full:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-driving-views { flex: 1 1 auto; min-width: 0; display: flex; justify-content: center; gap: 4px; padding: 3px;
border: 1px solid var(--border); border-radius: 26px; background: var(--surface); }
.mag-driving-views button { flex: 1 1 0; min-width: 44px; min-height: 44px; padding: 0 6px; box-sizing: border-box; display: flex;
align-items: center; justify-content: center; gap: 6px; border: 0; border-radius: 22px; background: transparent;
color: var(--text); font-size: 15px; font-weight: 600; cursor: pointer; }
.mag-driving-views button .mdi { font-size: 22px; }
.mag-driving-views button[aria-checked="true"] { background: var(--accent); color: var(--on-accent, #fff); }
.mag-driving-view-label { display: none; }
@media (min-width: 560px) { .mag-driving-view-label { display: inline; } }
.mag-driving-content { flex: 1 1 auto; min-height: 0; width: 100%; max-width: 760px; margin: 0 auto; box-sizing: border-box;
padding: 0 12px; display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
.mag-driving-head { flex: none; text-align: center; min-width: 0; }
.mag-driving-head h2 { margin: 0; font-size: clamp(18px, 5vw, 30px); line-height: 1.2; overflow-wrap: anywhere;
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.mag-driving-head p { margin: 2px 0 0; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mag-driving-head .mag-driving-queue { margin: 2px 0 0; font-size: 14px; }
.mag-driving-start { flex: none; margin: 0; }
.mag-driving-stagewrap { flex: 1 1 0; min-height: 0; position: relative; display: flex; flex-direction: column; }
.mag-driving-visual { margin: 0; flex: 1 1 auto; min-height: 0; max-height: none; position: relative; display: flex; flex-direction: column; gap: 4px; align-items: stretch; }
.mag-driving-stage { flex: 1 1 0; min-height: 0; position: relative; overflow: hidden; touch-action: none; border-radius: 12px;
background: color-mix(in srgb, var(--surface) 70%, transparent); cursor: zoom-in; user-select: none; -webkit-user-select: none; }
.mag-driving-stage.is-zoomed { cursor: grab; }
.mag-driving-pz { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; transform-origin: 50% 50%; will-change: transform; }
.mag-driving-pz img { max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; border-radius: 0; -webkit-user-drag: none; pointer-events: none; }
.mag-driving-visual figcaption { flex: none; font-size: 13px; line-height: 1.25; text-align: center; color: var(--subtext);
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.mag-driving-visual figcaption span { margin-left: 6px; }
.mag-driving-zoomctl { position: absolute; top: 6px; right: 6px; display: flex; gap: 6px; }
.mag-driving-zoomctl button { width: 44px; height: 44px; padding: 0; border-radius: 50%; border: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 88%, transparent); color: var(--text); font-size: 22px; cursor: pointer;
display: flex; align-items: center; justify-content: center; }
.mag-driving-zoomctl button:disabled { opacity: .4; cursor: default; }
.mag-driving-noimg { margin: auto; text-align: center; padding: 12px; }
/* the three-line view: fixed height so nothing moves as lines change */
.mag-driving-lyrics { flex: none; box-sizing: border-box; margin: 0; max-width: none; width: 100%;
height: clamp(110px, 26vh, 220px); height: clamp(110px, 26dvh, 220px); }
/* the whole text */
.mag-driving-full-wrap { flex: 1 1 0; min-height: 0; position: relative; display: none; }
.mag-driving-full { height: 100%; box-sizing: border-box; overflow-y: auto; overscroll-behavior: contain; position: relative;
padding: 30vh 12px; padding: 30dvh 12px; text-align: left; border-radius: 12px; background: color-mix(in srgb, var(--surface) 70%, transparent); }
.mag-driving-fline { margin: 0 0 14px; font-size: clamp(16px, 4.5vw, 22px); line-height: 1.35; color: var(--text); opacity: .5; cursor: pointer; overflow-wrap: anywhere; }
.mag-driving-fline.is-past { opacity: .35; }
.mag-driving-fline.is-current { opacity: 1; font-weight: 700; font-size: clamp(22px, 6.2vw, 34px); cursor: default;
border-left: 4px solid var(--accent); padding-left: 10px; }
.mag-driving-fline:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.mag-driving-backcur { position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%); min-height: 44px; padding: 0 18px;
border: 0; border-radius: 22px; background: var(--accent); color: var(--on-accent, #fff); font-size: 16px; font-weight: 700; cursor: pointer;
display: flex; align-items: center; gap: 8px; box-shadow: 0 2px 10px rgba(0,0,0,.3); }
/* views */
.mag-driving[data-view="image"] .mag-driving-lyrics, .mag-driving[data-view="lyrics"] .mag-driving-lyrics,
.mag-driving[data-view="lyrics"] .mag-driving-stagewrap, .mag-driving[data-view="compact"] .mag-driving-full-wrap,
.mag-driving[data-view="image"] .mag-driving-full-wrap { display: none; }
.mag-driving[data-view="lyrics"] .mag-driving-full-wrap { display: block; }
.mag-driving[data-view="compact"] .mag-driving-stagewrap { flex: 1 1 0; }
/* the dock */
.mag-driving-dock { flex: none; box-sizing: border-box; width: 100%; padding: 8px 12px 10px; border-top: 1px solid var(--border);
background: var(--surface); display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: auto; }
.mag-driving-dock .mag-driving-seek { margin: 0; width: min(680px, 100%); display: flex; align-items: center; gap: 10px; }
.mag-driving-dock .mag-driving-seek input[type="range"] { flex: 1 1 auto; width: auto; min-width: 0; height: 32px; }
.mag-driving-dock .mag-driving-time { margin: 0; flex: none; font-size: 14px; white-space: nowrap; }
.mag-driving-dock .mag-driving-controls { margin: 0; }
.mag-driving-dock .mag-driving-options { margin: 0; flex-wrap: nowrap; gap: 8px; }
.mag-driving-dock .mag-driving-opt { min-width: 44px; min-height: 44px; padding: 0 14px; font-size: 16px; }
@media (max-height: 760px) {
.mag-driving-btn { width: 64px; height: 64px; font-size: 26px; }
.mag-driving-btn.is-small { width: 52px; height: 52px; font-size: 22px; }
.mag-driving-btn.mag-driving-play { width: 84px; height: 84px; font-size: 40px; }
}
@media (max-width: 480px) {
.mag-driving-btn.is-small { width: 44px; height: 44px; }
.mag-driving-dock { padding: 6px 8px 8px; gap: 6px; }
}
@media (max-width: 480px) and (max-height: 640px) {
.mag-driving-btn { width: 48px; height: 48px; font-size: 22px; }
.mag-driving-btn.mag-driving-play { width: 64px; height: 64px; font-size: 34px; }
}
/* landscape phones: compact controls, picture and text side by side */
@media (orientation: landscape) and (max-height: 500px) {
.mag-driving-top { padding: 4px 8px; }
.mag-driving-head h2 { font-size: 16px; -webkit-line-clamp: 1; line-clamp: 1; }
.mag-driving-head p, .mag-driving-head .mag-driving-queue { display: none; }
.mag-driving-btn, .mag-driving-btn.is-small { width: 48px; height: 48px; font-size: 22px; }
.mag-driving-btn.mag-driving-play { width: 60px; height: 60px; font-size: 30px; }
.mag-driving-dock { padding: 4px 12px 6px; gap: 2px; }
.mag-driving-dock .mag-driving-seek input[type="range"] { height: 24px; }
.mag-driving-dock .mag-driving-opt { min-height: 44px; }
.mag-driving[data-view="compact"] .mag-driving-content { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr); column-gap: 12px; row-gap: 4px; max-width: none; }
.mag-driving[data-view="compact"] .mag-driving-start { grid-column: 1 / -1; grid-row: 1; min-height: 44px; }
.mag-driving[data-view="compact"] .mag-driving-head { grid-column: 1 / -1; grid-row: 2; }
.mag-driving[data-view="compact"] .mag-driving-stagewrap { grid-column: 1; grid-row: 3; }
.mag-driving[data-view="compact"] .mag-driving-lyrics { grid-column: 2; grid-row: 3; height: auto; min-height: 0; }
}
/* minimized: just the current line above the dock */
.mag-driving[data-collapsed="true"] .mag-driving-stagewrap, .mag-driving[data-collapsed="true"] .mag-driving-full-wrap,
.mag-driving[data-collapsed="true"] .mag-driving-head p, .mag-driving[data-collapsed="true"] .mag-driving-head .mag-driving-queue { display: none; }
.mag-driving[data-collapsed="true"] .mag-driving-content { flex: 0 1 auto; display: flex; }
.mag-driving[data-collapsed="true"] .mag-driving-head h2 { font-size: 15px; -webkit-line-clamp: 1; line-clamp: 1; }
.mag-driving[data-collapsed="true"] .mag-driving-lyrics { display: flex; height: auto; max-height: 30vh; max-height: 30dvh; min-height: 0; grid-column: auto; grid-row: auto; }
.mag-driving[data-collapsed="true"] .mag-driving-lyrics .is-prev, .mag-driving[data-collapsed="true"] .mag-driving-lyrics .is-next { display: none; }
@media (prefers-reduced-motion: reduce) {
.mag-driving-pz { transition: none; }
.mag-driving-backcur { transition: none; }
}

View File

@ -2731,3 +2731,214 @@ test.describe('Driving mode: three-line text and robust audio/text sync', () =>
await expect.poll(async () => (await state(page)).current).toBe(2); await expect.poll(async () => (await state(page)).current).toBe(2);
}); });
}); });
// ── Driving mode layout: top bar / content / dock; views, whole-text lyrics, picture zoom ──────────────
test.describe('Driving mode layout and views', () => {
const LONG_TITLE = 'Donaudampfschifffahrtsgesellschaftskapitän und die außergewöhnlich lange Überschrift über Batterien';
const EFF = [0, 3.55, 6.55, 9.55];
const expectedAt = (t) => EFF.reduce((n, e, i) => (e <= t ? i : n), 0);
const IMAGE = { id: 'img-1', page: 3, bbox: [0, 0, 1, 1], filename: 'x.png', reading_order: 0, type: 'figure', description: 'Ein sehr langes Bild mit einer langen Beschreibung, die umbrechen muss.' };
async function setup(page) {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
const article = { ...ARTICLE_APPROVED, title: LONG_TITLE, has_audio: true, audio_seconds: 12, images: [IMAGE.id] };
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, images: [IMAGE] } }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => route.fulfill({ json: article }));
await page.route(`**/api/magazine/${DOC_ID}/images/*`, route => route.fulfill({
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), contentType: 'image/png' }));
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'Sentence number ' + i + ' is spoken here.', block: 0, start: s, end: s + 3, speech: s + 0.7 })) } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
}
async function open(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
const card = page.locator('#mag-library-list .mag-card').first();
await page.locator('#mag-library-list .mag-card:visible, button:has-text("Playlist"):visible').first().waitFor();
if (await card.isVisible()) await card.click(); // after a reload the app may already have reopened the magazine
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
}
const view = (page, v) => page.locator(`#mag-driving-views [data-view="${v}"]`);
// Everything is measured from the DOM: the dock is fully on screen, round buttons, nothing covers it or the share button.
async function measure(page) {
return page.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
const q = (s) => document.querySelector(s);
const hit = (a, b) => a.l < b.r - 0.5 && b.l < a.r - 0.5 && a.t < b.b - 0.5 && b.t < a.b - 0.5;
const dock = r(q('.mag-driving-dock')), share = r(q('.mag-driving-share')), content = r(q('.mag-driving-content')), top = r(q('.mag-driving-top'));
const title = q('#mag-driving-title');
const inContent = [...q('.mag-driving-content').querySelectorAll('*')].filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height > 0);
const buttons = [...q('.mag-driving-controls').querySelectorAll('.mag-driving-btn')].map((b) => r(b));
const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]];
return {
vw: innerWidth, vh: innerHeight, dock, share, content, top,
buttonsRound: buttons.every((b) => Math.abs(b.w - b.h) < 0.5),
buttonsInside: buttons.every((b) => b.l >= 0 && b.r <= innerWidth && b.t >= dock.t && b.b <= dock.b),
contentAboveDock: inContent.every((e) => e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption')))),
offenders: inContent.filter((e) => !(e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption'))))).map((e) => e.id || e.className || e.tagName),
contentBoxAboveDock: content.b <= dock.t + 0.5,
shareHitsContent: hit(share, content), shareHitsDock: hit(share, dock),
dockCovered: pts.some(([x, y]) => !q('.mag-driving-dock').contains(document.elementFromPoint(x, y))),
titleClipped: title.scrollWidth > title.clientWidth + 1,
titleBox: r(title),
scrollX: document.documentElement.scrollWidth,
buttonSizes: [...document.querySelectorAll('.mag-driving-top button, #mag-driving-views button')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)),
};
});
}
const sizes = [[320, 568], [375, 667], [414, 896], [667, 375], [1280, 720]];
for (const [w, h] of sizes) {
for (const v of ['compact', 'image', 'lyrics']) {
test(`${w}x${h}, ${v} view: dock fully visible, round buttons, nothing overlaps`, async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: w, height: h });
await open(page);
await view(page, v).click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
const m = await measure(page);
expect(m.dock.l).toBeGreaterThanOrEqual(0); expect(m.dock.r).toBeLessThanOrEqual(m.vw + 0.5);
expect(m.dock.t).toBeGreaterThanOrEqual(0); expect(m.dock.b).toBeLessThanOrEqual(m.vh + 0.5);
expect(m.buttonsRound).toBe(true); expect(m.buttonsInside).toBe(true);
expect(m.offenders).toEqual([]); expect(m.contentBoxAboveDock, JSON.stringify([m.content, m.dock])).toBe(true); expect(m.contentAboveDock).toBe(true);
expect(m.shareHitsContent).toBe(false); expect(m.shareHitsDock).toBe(false);
expect(m.dockCovered).toBe(false);
expect(m.content.t).toBeGreaterThanOrEqual(m.top.b - 0.5); // title lives below the top bar
expect(m.titleBox.l).toBeGreaterThanOrEqual(0); expect(m.titleBox.r).toBeLessThanOrEqual(m.vw + 0.5);
expect(m.titleBox.t).toBeGreaterThanOrEqual(m.top.b - 0.5);
expect(m.scrollX).toBeLessThanOrEqual(m.vw);
for (const s of m.buttonSizes) expect(s).toBeGreaterThanOrEqual(43.5); // 44 px touch targets
// minimized: still fully visible, and the current line stays
await page.locator('#mag-driving-collapse').click();
await expect(page.locator('#mag-driving-collapse')).toHaveAttribute('aria-expanded', 'false');
const c = await measure(page);
expect(c.dock.b).toBeLessThanOrEqual(c.vh + 0.5); expect(c.contentBoxAboveDock).toBe(true);
expect(c.content.h).toBeLessThan(m.content.h + 1);
});
}
}
test('each view renders its own content', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(view(page, 'compact')).toHaveAttribute('aria-checked', 'true'); // the default
await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
await expect(page.locator('#mag-driving-full')).toBeHidden();
await view(page, 'image').click();
await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
await expect(page.locator('#mag-driving-full')).toBeHidden();
await view(page, 'lyrics').click();
await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(4);
await expect(page.locator('#mag-driving-visual')).toBeHidden();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
// keyboard: arrows move the radio selection
await view(page, 'lyrics').focus();
await page.keyboard.press('ArrowRight');
await expect(view(page, 'compact')).toHaveAttribute('aria-checked', 'true');
await page.keyboard.press('ArrowRight'); // wraps around
await expect(view(page, 'image')).toHaveAttribute('aria-checked', 'true');
// no live region spam
expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off');
});
test('the chosen view and the minimized state survive a reload', async ({ page }) => {
await setup(page);
await open(page);
await view(page, 'lyrics').click();
await page.reload();
await open(page);
await expect(view(page, 'lyrics')).toHaveAttribute('aria-checked', 'true');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics');
await page.locator('#mag-driving-collapse').click();
await page.reload();
await open(page);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true');
await page.locator('#mag-driving-collapse').click();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'false');
});
test('lyrics highlight the sentence containing currentTime after a seek, centered; manual scroll pauses following', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await view(page, 'lyrics').click();
const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .is-current')?.dataset.i ?? -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 expect.poll(current, { message: 'seek to ' + t }).toBe(expectedAt(t));
}
// the current line ends up near the vertical middle of the scroller
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 7; });
await expect.poll(current).toBe(2);
await expect.poll(() => page.evaluate(() => {
const box = document.getElementById('mag-driving-full').getBoundingClientRect(), el = document.querySelector('#mag-driving-full .is-current').getBoundingClientRect();
return Math.abs((el.top + el.height / 2) - (box.top + box.height / 2));
})).toBeLessThan(40);
// dim neighbours
expect(Number(await page.evaluate(() => getComputedStyle(document.querySelector('#mag-driving-full [data-i="3"]')).opacity))).toBeLessThan(1);
// manual scroll: no auto-follow, "Back to current" appears and restores it
await page.locator('#mag-driving-full').hover();
await page.mouse.wheel(0, -400);
await expect(page.locator('#mag-driving-backcur')).toBeVisible();
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 10; });
await expect.poll(current).toBe(3);
await page.locator('#mag-driving-backcur').click();
await expect(page.locator('#mag-driving-backcur')).toBeHidden();
// tapping a sentence seeks to it
await page.locator('#mag-driving-full [data-i="1"]').click();
await expect.poll(current).toBe(1);
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThanOrEqual(3.5);
});
test('picture zoom: buttons, double tap, keys and Escape', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await view(page, 'image').click();
const stage = page.locator('#mag-driving-stage');
await expect(stage).toBeVisible();
await expect(stage.locator('img')).toHaveCount(1);
await expect(stage).toHaveAttribute('data-zoom', '1');
await expect(page.locator('#mag-driving-zoom-reset')).toBeDisabled();
await page.locator('#mag-driving-zoom-in').click();
await expect(stage).toHaveAttribute('data-zoom', '1.5');
await page.locator('#mag-driving-zoom-reset').click();
await expect(stage).toHaveAttribute('data-zoom', '1');
await stage.dblclick();
await expect(stage).toHaveAttribute('data-zoom', '2.5');
await stage.dblclick();
await expect(stage).toHaveAttribute('data-zoom', '1');
await stage.focus();
await page.keyboard.press('+');
await expect(stage).toHaveAttribute('data-zoom', '1.5');
await page.keyboard.press('Escape');
await expect(stage).toHaveAttribute('data-zoom', '1');
await expect(page.locator('#mag-driving')).toBeVisible(); // Escape only reset the zoom
for (const b of ['#mag-driving-zoom-in', '#mag-driving-zoom-out', '#mag-driving-zoom-reset']) {
const box = await page.locator(b).boundingBox();
expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44);
await expect(page.locator(b)).toHaveAttribute('aria-label', /Zoom/i);
}
});
});