From 4d8d71a04663fc46cc8f5cbe89191c9adc8df582 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 2 Oct 2026 17:51:13 +0200 Subject: [PATCH] feat(magazine): driving mode display menu, article index sheet, speed beside the slider Replace the three-icon view switch with one flyout menu (views, show text/pictures, minimize), add an Index sheet listing all articles with durations, and move playback speed into the slider row. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 + Manual.md | 4 +- static/js/magazine.js | 179 ++++++++++++++++++++--- static/sections/s-magazine.html | 45 ++++-- static/style.css | 46 ++++-- tests/magazine.spec.js | 252 +++++++++++++++++++++++++++++--- 6 files changed, 456 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0939378..78842ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,12 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **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=&article=&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. ### Changed +- **Driving mode: one display menu instead of the three-icon switch; fewer controls in the dock.** The top bar now has *Share*, a *menu* button (shows the current view), *Index* and *Exit*. The menu opens a flyout with *Picture*, *Lyrics* and *Compact* plus *Show text*, *Show pictures* (moved from the dock) and *Minimize text and picture*; it works with arrow keys, Home/End, Enter and Escape, closes on an outside tap, keeps 44 px targets, stays inside the screen from 320 px wide and never covers the dock. The playback speed button moved to the end of the slider row, after the time. The dock is now just the slider row and the transport buttons. Picture zoom stays on the picture. - **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. diff --git a/Manual.md b/Manual.md index f95d637..47c7df9 100644 --- a/Manual.md +++ b/Manual.md @@ -289,8 +289,8 @@ turned into a podcast discussion between two or three voices you pick. large touch target and keyboard reachable; the speed also applies to the 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. - **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. + **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. + **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). 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 `/#s-magazine?doc=&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. diff --git a/static/js/magazine.js b/static/js/magazine.js index 74ccdf2..a98d72c 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -5627,6 +5627,7 @@ function magToggleDriving() { state.driving = !state.driving; const overlay = $('mag-driving'); if (overlay) overlay.hidden = !state.driving; + if (!state.driving) { magDrivingMenuClose(false); magDrivingIndexClose(false); } if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); } magDrivingWake(); } @@ -5772,6 +5773,7 @@ function renderDriving() { } } magPlaylistSync(); + magDrivingIndexMark(false); } function syncDrivingPlayButton() { @@ -5869,8 +5871,8 @@ function magDrivingToggleImages() { function magDrivingApplyToggles() { const prefs = magDrivingPrefs(); const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle'); - if (text) text.setAttribute('aria-pressed', String(prefs.text)); - if (images) images.setAttribute('aria-pressed', String(prefs.images)); + if (text) text.setAttribute('aria-checked', String(prefs.text)); + if (images) images.setAttribute('aria-checked', String(prefs.images)); const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); if (lyrics && !prefs.text) lyrics.hidden = true; if (visual && !prefs.images) visual.hidden = true; @@ -5960,18 +5962,17 @@ function magDrivingApplyView() { 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; + document.querySelectorAll('#mag-driving-menu [role="menuitemradio"]').forEach((b) => { + b.setAttribute('aria-checked', String(b.dataset.view === prefs.view)); }); - 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'); + const mb = $('mag-driving-menubtn'), meta = MAG_VIEW_META[prefs.view]; + if (mb && meta) { + mb.querySelector('.mdi').className = 'mdi ' + meta[0]; + $('mag-driving-menulabel').textContent = meta[1]; + mb.setAttribute('aria-label', 'Display menu: ' + meta[1] + (prefs.collapsed ? ' (minimized)' : '')); } + const c = $('mag-driving-collapse'); + if (c) c.setAttribute('aria-checked', String(!!prefs.collapsed)); } function magDrivingSetView(view) { @@ -6120,20 +6121,147 @@ function magDrivingZoomWire() { window.addEventListener('resize', magDrivingZoomApply); } +const MAG_VIEW_META = { image: ['mdi-image-outline', 'Picture'], lyrics: ['mdi-text-long', 'Lyrics'], compact: ['mdi-view-split-horizontal', 'Compact'] }; + +// One menu button opens a flyout (view choice and minimize). It is placed under the button, clamped to the +// viewport and never taller than the room above the dock. +function magDrivingMenuItems() { return [...document.querySelectorAll('#mag-driving-menu [role^="menuitem"]')]; } +function magDrivingMenuPlace() { + const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'), dock = document.querySelector('.mag-driving-dock'); + if (!menu || menu.hidden) return; + const b = btn.getBoundingClientRect(), vw = document.documentElement.clientWidth; + menu.style.maxHeight = ''; menu.style.left = '0px'; + const w = menu.offsetWidth; + const left = Math.max(8, Math.min(b.left, vw - 8 - w)); + menu.style.left = (left - b.left) + 'px'; + const room = (dock ? dock.getBoundingClientRect().top : innerHeight) - b.bottom - 12; + menu.style.maxHeight = Math.max(88, room) + 'px'; +} +function magDrivingMenuOpen(focusIndex) { + const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'); + if (!menu) return; + magDrivingIndexClose(false); + menu.hidden = false; btn.setAttribute('aria-expanded', 'true'); + magDrivingMenuPlace(); + const items = magDrivingMenuItems(); + const target = focusIndex === 'last' ? items[items.length - 1] : (items.find((i) => i.getAttribute('aria-checked') === 'true') || items[0]); + items.forEach((i) => { i.tabIndex = i === target ? 0 : -1; }); + target.focus(); +} +function magDrivingMenuClose(returnFocus) { + const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'); + if (!menu || menu.hidden) return; + menu.hidden = true; btn.setAttribute('aria-expanded', 'false'); + if (returnFocus !== false) btn.focus(); +} 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(); + const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'); + if (!menu || menu.dataset.wired) return; + menu.dataset.wired = '1'; + btn.addEventListener('click', () => { if (menu.hidden) magDrivingMenuOpen(); else magDrivingMenuClose(); }); + btn.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); magDrivingMenuOpen(e.key === 'ArrowUp' ? 'last' : undefined); } }); + menu.addEventListener('click', (e) => { + const it = e.target.closest('[role="menuitemradio"]'); + if (it) { magDrivingSetView(it.dataset.view); magDrivingMenuClose(); return; } + if (e.target.closest('#mag-driving-collapse, #mag-driving-text-toggle, #mag-driving-image-toggle')) magDrivingMenuClose(); + }); + menu.addEventListener('keydown', (e) => { + const items = magDrivingMenuItems(), i = items.indexOf(document.activeElement); + const go = (n) => { e.preventDefault(); items.forEach((x) => { x.tabIndex = -1; }); items[n].tabIndex = 0; items[n].focus(); }; + if (e.key === 'ArrowDown') go((i + 1) % items.length); + else if (e.key === 'ArrowUp') go((i - 1 + items.length) % items.length); + else if (e.key === 'Home') go(0); + else if (e.key === 'End') go(items.length - 1); + else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); magDrivingMenuClose(); } + else if (e.key === 'Tab') magDrivingMenuClose(false); + }); + document.addEventListener('pointerdown', (e) => { + if (!menu.hidden && !e.target.closest('.mag-driving-menuwrap')) magDrivingMenuClose(false); + const panel = $('mag-driving-index'); + if (panel && !panel.hidden && !e.target.closest('#mag-driving-index, #mag-driving-indexbtn, .mag-driving-dock')) magDrivingIndexClose(false); + }); + window.addEventListener('resize', () => { magDrivingMenuPlace(); magDrivingIndexPlace(); }); +} + +// ── Article index: every article of the magazine with its length, as a sheet between top bar and dock ────────── +function magDrivingIndexOrder() { + return state.articles.slice().sort((a, b) => (a.pages?.[0] ?? 0) - (b.pages?.[0] ?? 0)); +} +function magDrivingIndexRender() { + const list = $('mag-driving-index-list'); + if (!list) return; + const order = magDrivingIndexOrder(); + const t = $('mag-driving-index-title'); if (t) t.textContent = 'Articles (' + order.length + ')'; + list.innerHTML = order.map((a, n) => { + const secs = Number(a.audio_seconds || 0); + const dur = a.has_audio ? (secs > 0 ? magFmtClock(secs) : '') : 'no audio'; + return '
  • '; + }).join(''); + magDrivingIndexMark(true); +} +// Highlights the playing article; scrolls it into view when asked (on open) so the list does not jump while open. +function magDrivingIndexMark(scroll) { + const list = $('mag-driving-index-list'); + if (!list) return; + let cur = null; + list.querySelectorAll('button').forEach((b) => { + const on = b.dataset.id === state.playlistCurrent; + if (on) { b.setAttribute('aria-current', 'true'); cur = b; } else b.removeAttribute('aria-current'); + }); + if (scroll && cur) cur.scrollIntoView({ block: 'center' }); +} +function magDrivingIndexPlace() { + const p = $('mag-driving-index'), top = document.querySelector('.mag-driving-top'), dock = document.querySelector('.mag-driving-dock'); + if (!p || p.hidden || !top || !dock) return; + p.style.top = top.getBoundingClientRect().bottom + 'px'; + p.style.bottom = (innerHeight - dock.getBoundingClientRect().top) + 'px'; +} +function magDrivingIndexToggle() { if ($('mag-driving-index').hidden) magDrivingIndexOpen(); else magDrivingIndexClose(); } +function magDrivingIndexOpen() { + const p = $('mag-driving-index'), btn = $('mag-driving-indexbtn'); + magDrivingMenuClose(false); + magDrivingViewsWire(); + if (!p.dataset.wired) { + p.dataset.wired = '1'; + p.addEventListener('click', (e) => { + const b = e.target.closest('#mag-driving-index-list button'); + if (!b || b.disabled) return; + magDrivingIndexClose(); + magPlaylistPlay(b.dataset.id); + }); + p.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); magDrivingIndexClose(); return; } + const items = [...p.querySelectorAll('button:not([disabled])')]; + if (!items.length) return; + const i = items.indexOf(document.activeElement); + const rows = [...p.querySelectorAll('#mag-driving-index-list button:not([disabled])')]; + const ri = rows.indexOf(document.activeElement); + if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && rows.length) { + e.preventDefault(); + rows[ri < 0 ? 0 : Math.max(0, Math.min(rows.length - 1, ri + (e.key === 'ArrowDown' ? 1 : -1)))].focus(); + } else if (e.key === 'Tab') { // focus trap: Tab cycles inside the sheet + const first = items[0], last = items[items.length - 1]; + if (e.shiftKey && (i <= 0)) { e.preventDefault(); last.focus(); } + else if (!e.shiftKey && (i === items.length - 1)) { e.preventDefault(); first.focus(); } + } + }); + } + magDrivingIndexRender(); + p.hidden = false; btn.setAttribute('aria-expanded', 'true'); + magDrivingIndexPlace(); + const cur = p.querySelector('[aria-current="true"]:not([disabled])') || p.querySelector('#mag-driving-index-list button:not([disabled])') || $('mag-driving-index-close'); + magDrivingIndexMark(true); + cur.focus({ preventScroll: true }); +} +function magDrivingIndexClose(returnFocus) { + const p = $('mag-driving-index'), btn = $('mag-driving-indexbtn'); + if (!p || p.hidden) return; + p.hidden = true; btn.setAttribute('aria-expanded', 'false'); + if (returnFocus !== false) btn.focus(); } function magDrivingNext() { @@ -6421,6 +6549,9 @@ root.magDrivingZoomBy = magDrivingZoomBy; root.magDrivingZoomReset = magDrivingZoomReset; root.magDrivingBackToCurrent = magDrivingBackToCurrent; root.magDrivingToggleCollapsed = magDrivingToggleCollapsed; +root.magDrivingIndexToggle = magDrivingIndexToggle; +root.magDrivingIndexOpen = magDrivingIndexOpen; +root.magDrivingIndexClose = magDrivingIndexClose; root.magDrivingSetView = magDrivingSetView; root.magDrivingSeek = magDrivingSeek; root.magDrivingCycleSpeed = magDrivingCycleSpeed; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index bbb915d..6f3ce90 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -330,15 +330,29 @@ -
    - - - +
    + +
    - + @@ -378,11 +392,20 @@
    +
    0:00 / 0:00
    +
    -
    - - - -
    diff --git a/static/style.css b/static/style.css index 0aac4db..10fa807 100644 --- a/static/style.css +++ b/static/style.css @@ -5724,19 +5724,43 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; } 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 { +.mag-driving-top .mag-driving-share, .mag-driving-top .mag-driving-exit { 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-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; +.mag-driving-menuwrap { position: relative; flex: none; } +.mag-driving-menubtn, .mag-driving-indexbtn { flex: none; min-width: 44px; height: 44px; padding: 0 12px; box-sizing: border-box; display: flex; + align-items: center; justify-content: center; gap: 6px; border: 1px solid var(--border); border-radius: 22px; background: var(--surface); 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-menubtn .mdi, .mag-driving-indexbtn .mdi { font-size: 22px; } +.mag-driving-menubtn[aria-expanded="true"], .mag-driving-indexbtn[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); } +.mag-driving-topspacer { flex: 1 1 auto; min-width: 0; } +.mag-driving-menubtn:focus-visible, .mag-driving-indexbtn:focus-visible, .mag-driving-menu button:focus-visible, +.mag-driving-index button:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } +.mag-driving-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 3; width: max-content; max-width: calc(100vw - 16px); min-width: 220px; + overflow-y: auto; padding: 6px; box-sizing: border-box; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); + box-shadow: 0 8px 24px rgba(0,0,0,.35); } +.mag-driving-menu button { width: 100%; min-height: 44px; padding: 0 12px; display: flex; align-items: center; gap: 10px; text-align: left; + border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 16px; font-weight: 600; cursor: pointer; } +.mag-driving-menu button .mdi { font-size: 22px; flex: none; } +.mag-driving-menu button[aria-checked="true"] { background: var(--accent); color: var(--on-accent, #fff); } +.mag-driving-menu hr { border: 0; border-top: 1px solid var(--border); margin: 4px 0; } +.mag-driving-index { position: absolute; left: 0; right: 0; z-index: 2; display: flex; flex-direction: column; box-sizing: border-box; + background: var(--surface); border-top: 1px solid var(--border); } +.mag-driving-index[hidden] { display: none; } +.mag-driving-index-head { flex: none; display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 4px 16px; } +.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-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; + border: 0; border-radius: 10px; background: transparent; color: var(--text); font-size: 15px; cursor: pointer; } +.mag-driving-index-list button[aria-current="true"] { background: var(--accent); color: var(--on-accent, #fff); font-weight: 700; } +.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 .ix-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; } +.mag-driving-index-list .ix-dur { flex: none; font-variant-numeric: tabular-nums; font-size: 14px; } .mag-driving-view-label { display: none; } @media (min-width: 560px) { .mag-driving-view-label { display: inline; } } @@ -5797,8 +5821,9 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; } .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; } +.mag-driving-dock .mag-driving-seek .mag-driving-opt { flex: none; min-width: 44px; min-height: 44px; padding: 0 8px; font-size: 16px; + border: 1px solid var(--border); border-radius: 22px; background: var(--surface); color: var(--text); font-weight: 700; cursor: pointer; } +.mag-driving-dock .mag-driving-opt:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } @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; } @@ -5822,7 +5847,6 @@ details.mag-report-group summary { cursor: pointer; margin-bottom: 6px; } .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; } diff --git a/tests/magazine.spec.js b/tests/magazine.spec.js index f1c2924..0f502cd 100644 --- a/tests/magazine.spec.js +++ b/tests/magazine.spec.js @@ -2711,7 +2711,7 @@ test.describe('Driving mode: three-line text and robust audio/text sync', () => test('rapid repeated seeks settle on the final position with no stale highlight', async ({ page }) => { await openDriving(page, { speed: 2 }); - await page.evaluate(() => document.getElementById('mag-playlist-audio').play()); + await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {})); const finals = []; for (let round = 0; round < 4; round++) { const last = await page.evaluate(() => { @@ -2768,14 +2768,17 @@ test.describe('Driving mode layout and views', () => { 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 page.locator('.mag-playlist-track-play').first().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}"]`); + const view = (page, v) => page.locator(`#mag-driving-menu [data-view="${v}"]`); + const openMenu = async (page) => { if ((await page.locator('#mag-driving-menubtn').getAttribute('aria-expanded')) !== 'true') await page.locator('#mag-driving-menubtn').click(); }; + const pick = async (page, v) => { await openMenu(page); await view(page, v).click(); }; + const toggleCollapse = async (page) => { await openMenu(page); await page.locator('#mag-driving-collapse').click(); }; // 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) { @@ -2800,7 +2803,7 @@ test.describe('Driving mode layout and views', () => { 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)), + buttonSizes: [...document.querySelectorAll('.mag-driving-top > button, #mag-driving-menubtn, #mag-driving-indexbtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)), }; }); } @@ -2812,7 +2815,7 @@ test.describe('Driving mode layout and views', () => { await setup(page); await page.setViewportSize({ width: w, height: h }); await open(page); - await view(page, v).click(); + await pick(page, v); 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); @@ -2827,8 +2830,8 @@ test.describe('Driving mode layout and views', () => { 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'); + await toggleCollapse(page); + await expect(page.locator('#mag-driving-collapse')).toHaveAttribute('aria-checked', 'true'); 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); @@ -2844,20 +2847,22 @@ test.describe('Driving mode layout and views', () => { 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 pick(page, 'image'); 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 pick(page, 'lyrics'); 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'); + // keyboard: the menu button opens the flyout, arrows move, Enter selects and closes + await page.locator('#mag-driving-menubtn').focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('#mag-driving-menu')).toBeVisible(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact'); // lyrics -> next item (compact) + await expect(page.locator('#mag-driving-menu')).toBeHidden(); // no live region spam expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off'); }); @@ -2865,24 +2870,231 @@ test.describe('Driving mode layout and views', () => { 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 pick(page, 'lyrics'); 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 toggleCollapse(page); await page.reload(); await open(page); await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true'); - await page.locator('#mag-driving-collapse').click(); + await toggleCollapse(page); await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'false'); }); + // ── flyout menu, moved dock toggles, speed beside the slider ── + test('flyout: one menu button opens a menu with the views, closes on Escape / outside tap / choice, keeps focus right', async ({ page }) => { + await setup(page); + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + const btn = page.locator('#mag-driving-menubtn'), menu = page.locator('#mag-driving-menu'); + await expect(page.locator('#mag-driving-views')).toHaveCount(0); // the three-icon switch is gone + await expect(btn).toHaveAttribute('aria-haspopup', 'menu'); + await expect(btn).toHaveAttribute('aria-expanded', 'false'); + await expect(menu).toBeHidden(); + await btn.click(); + await expect(menu).toBeVisible(); + await expect(btn).toHaveAttribute('aria-expanded', 'true'); + await expect(menu).toHaveAttribute('role', 'menu'); + expect(await page.locator('#mag-driving-menu [role="menuitemradio"]').count()).toBe(3); + for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse']) + await expect(page.locator(id)).toHaveAttribute('role', 'menuitemcheckbox'); + await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0); + for (const b of await page.locator('#mag-driving-menu button').all()) { + const box = await b.boundingBox(); expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5); + } + await page.keyboard.press('Escape'); // closes, focus back on the button, overlay stays + await expect(menu).toBeHidden(); + await expect(btn).toBeFocused(); + await expect(page.locator('#mag-driving')).toBeVisible(); + await btn.click(); await expect(menu).toBeVisible(); + await page.mouse.click(190, 400); // outside tap + await expect(menu).toBeHidden(); + await btn.click(); + await view(page, 'lyrics').click(); // choosing closes it and applies the view + await expect(menu).toBeHidden(); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics'); + await expect(btn).toHaveAttribute('aria-label', /Lyrics/); + for (const v of ['image', 'compact', 'lyrics']) { + await pick(page, v); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v); + await expect(view(page, v)).toHaveAttribute('aria-checked', 'true'); + } + // keyboard: arrows, Home/End, Enter + await btn.focus(); await page.keyboard.press('ArrowDown'); + await expect(menu).toBeVisible(); + await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'lyrics'); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp'); + await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'image'); + await page.keyboard.press('End'); + await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('id', 'mag-driving-collapse'); + await page.keyboard.press('Home'); await page.keyboard.press('Enter'); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'image'); + }); + + test('flyout: text and pictures toggles live in the menu and keep their state; speed sits after the time in the slider row', async ({ page }) => { + await setup(page); + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact'); + await expect(page.locator('#mag-driving-lyrics')).toBeVisible(); + await openMenu(page); + await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'true'); + await page.locator('#mag-driving-text-toggle').click(); + await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); + await openMenu(page); + await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'false'); + await page.locator('#mag-driving-image-toggle').click(); + await expect(page.locator('#mag-driving-visual')).toBeHidden(); + await openMenu(page); await page.locator('#mag-driving-text-toggle').click(); + await openMenu(page); await page.locator('#mag-driving-image-toggle').click(); + await expect(page.locator('#mag-driving-visual')).toBeVisible(); + // speed: last item of the slider row, 44px, cycles + const row = await page.evaluate(() => { + const r = (s) => document.querySelector(s).getBoundingClientRect(); + const sp = r('#mag-driving-speed'), tm = r('#mag-driving-time'), sk = r('#mag-driving-seek'), dock = r('.mag-driving-dock'); + return { speedAfterTime: sp.left >= tm.right - 0.5, sameRow: Math.abs((sp.top + sp.bottom) / 2 - (tm.top + tm.bottom) / 2) < 6, + h: sp.height, w: sp.width, slider: sk.width, inDock: sp.bottom <= dock.bottom && sp.right <= innerWidth, + dockOptions: document.querySelectorAll('.mag-driving-dock .mag-driving-options').length }; + }); + expect(row.speedAfterTime).toBe(true); expect(row.sameRow).toBe(true); expect(row.inDock).toBe(true); + expect(row.h).toBeGreaterThanOrEqual(43.5); expect(row.w).toBeGreaterThanOrEqual(43.5); expect(row.dockOptions).toBe(0); + await expect(page.locator('#mag-driving-speed')).toHaveText('1×'); + await page.locator('#mag-driving-speed').click(); + await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×'); + expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').playbackRate)).toBe(1.25); + await page.locator('#mag-driving-speed').click(); + await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click(); + await page.locator('#mag-driving-speed').click(); // 6 speeds: back to 1x + await expect(page.locator('#mag-driving-speed')).toHaveText('1×'); + }); + + const THREE = [ + { id: 'article-001', title: 'The Future of Batteries', pages: [1], has_audio: true, audio_seconds: 12 }, + { 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 }, + ]; + async function setupIndex(page, count) { + await setup(page); + 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'), + 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 })); + await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, 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 }) => { + await setupIndex(page); + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + const btn = page.locator('#mag-driving-indexbtn'), panel = page.locator('#mag-driving-index'); + await expect(btn).toHaveAttribute('aria-expanded', 'false'); + await expect(panel).toBeHidden(); + await btn.click(); + await expect(panel).toBeVisible(); + await expect(btn).toHaveAttribute('aria-expanded', 'true'); + const rows = page.locator('#mag-driving-index-list button'); + await expect(rows).toHaveCount(3); + await expect(rows.nth(0)).toContainText('The Future of Batteries'); + await expect(rows.nth(0)).toContainText('0:12'); + await expect(rows.nth(0)).toHaveAttribute('aria-current', 'true'); + await expect(rows.nth(1)).toContainText('1:02:05'); + await expect(rows.nth(1)).not.toHaveAttribute('aria-current', 'true'); + await expect(rows.nth(2)).toBeDisabled(); + await expect(rows.nth(2)).toContainText('no audio'); + expect(await page.evaluate(() => document.querySelector('.mag-driving-dock').getBoundingClientRect().top - document.getElementById('mag-driving-index').getBoundingClientRect().bottom)).toBeGreaterThanOrEqual(-0.5); + await expect(page.locator('#mag-driving-index-close')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(panel).toBeHidden(); await expect(btn).toBeFocused(); + await btn.click(); + await expect(rows.nth(0)).toBeFocused(); // focus lands on the current article + await page.keyboard.press('ArrowDown'); + await expect(rows.nth(1)).toBeFocused(); + await page.keyboard.press('ArrowDown'); // disabled row is skipped + 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(rows.nth(0)).toBeFocused(); // trapped: wraps to the first control + await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); + await expect(panel).toBeHidden(); + await expect(page.locator('#mag-driving')).toBeVisible(); + await btn.click(); + await expect(rows.nth(1)).toHaveAttribute('aria-current', 'true'); + await page.locator('#mag-driving-index-close').click(); + await expect(panel).toBeHidden(); + await btn.click(); await expect(panel).toBeVisible(); + await page.mouse.click(230, 25); // tap outside: the top bar beside the buttons + await expect(panel).toBeHidden(); + // the exact top-bar list button, while audio plays + await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {})); + 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(panel).toBeVisible(); + }); + + test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => { + await setupIndex(page, 113); + await page.setViewportSize({ width: 375, height: 667 }); + await open(page); + await page.evaluate(() => window.magPlaylistPlay('article-090')); + const t = await page.evaluate(() => { const t0 = performance.now(); magDrivingIndexOpen(); return performance.now() - t0; }); + expect(t).toBeLessThan(250); + await expect(page.locator('#mag-driving-index-list button')).toHaveCount(113); + 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(); + return c.top >= l.top - 1 && c.bottom <= l.bottom + 1; + }); + expect(inView).toBe(true); + await expect(page.locator('#mag-driving-index-list button:disabled').first()).toContainText('no audio'); + }); + + for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) { + test(`${w}x${h}: flyout and index stay in the viewport and above the dock; dock stays whole with 1:1 buttons`, async ({ page }) => { + await setupIndex(page, 113); + await page.setViewportSize({ width: w, height: h }); + await open(page); + const geo = () => 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 dock = r(document.querySelector('.mag-driving-dock')); + const inside = (b) => b.l >= -0.5 && b.r <= innerWidth + 0.5 && b.t >= -0.5 && b.b <= innerHeight + 0.5; + const menu = document.getElementById('mag-driving-menu'), idx = document.getElementById('mag-driving-index'); + const out = { dock, dockInside: inside(dock), btns: [...document.querySelectorAll('.mag-driving-controls .mag-driving-btn')].map(r).every((b) => Math.abs(b.w - b.h) < 0.5), + speed: r(document.getElementById('mag-driving-speed')), slider: r(document.getElementById('mag-driving-seek')), + topBtns: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)), + topInside: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].every((b) => inside(r(b))), scrollX: document.documentElement.scrollWidth }; + if (!menu.hidden) { const m = r(menu); out.menu = m; out.menuInside = inside(m); out.menuAboveDock = m.b <= dock.t + 0.5; + out.menuItems = [...menu.querySelectorAll('button')].map((b) => b.getBoundingClientRect().height); out.menuScrolls = menu.scrollHeight > menu.clientHeight + 1; } + if (!idx.hidden) { const m = r(idx); out.idx = m; out.idxInside = inside(m); out.idxAboveDock = m.b <= dock.t + 0.5; out.idxBelowTop = m.t >= document.querySelector('.mag-driving-top').getBoundingClientRect().bottom - 0.5; } + const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]]; + out.dockCovered = pts.some(([x, y]) => !document.querySelector('.mag-driving-dock').contains(document.elementFromPoint(x, y))); + return out; + }); + let g = await geo(); + expect(g.dockInside).toBe(true); expect(g.btns).toBe(true); expect(g.topInside).toBe(true); + expect(g.speed.w).toBeGreaterThanOrEqual(43.5); expect(g.speed.h).toBeGreaterThanOrEqual(43.5); + expect(g.slider.w).toBeGreaterThanOrEqual(w === 320 ? 100 : 120); + for (const s of g.topBtns) expect(s).toBeGreaterThanOrEqual(43.5); + await page.locator('#mag-driving-menubtn').click(); + g = await geo(); + expect(g.menuInside, JSON.stringify(g.menu)).toBe(true); expect(g.menuAboveDock, JSON.stringify([g.menu, g.dock])).toBe(true); + for (const hh of g.menuItems) expect(hh).toBeGreaterThanOrEqual(43.5); + expect(g.dockCovered).toBe(false); expect(g.scrollX).toBeLessThanOrEqual(w); + await page.keyboard.press('Escape'); + await page.locator('#mag-driving-indexbtn').click(); + g = await geo(); + 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); + await expect(page.locator('#mag-driving-index-list button').first()).toBeVisible(); + const rowH = await page.locator('#mag-driving-index-list button').first().evaluate((e) => e.getBoundingClientRect().height); + expect(rowH).toBeGreaterThanOrEqual(43.5); + }); + } + 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(); + await pick(page, 'lyrics'); 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); @@ -2915,7 +3127,7 @@ test.describe('Driving mode layout and views', () => { await setup(page); await page.setViewportSize({ width: 375, height: 667 }); await open(page); - await view(page, 'image').click(); + await pick(page, 'image'); const stage = page.locator('#mag-driving-stage'); await expect(stage).toBeVisible(); await expect(stage.locator('img')).toHaveCount(1);