From adc1fab3f8b14d5f07bfd35e4247d34ff153a85b Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Fri, 2 Oct 2026 16:54:21 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + Manual.md | 1 + static/js/magazine.js | 203 +++++++++++++++++++++++++++++- static/sections/s-magazine.html | 63 ++++++++-- static/style.css | 130 ++++++++++++++++++++ tests/magazine.spec.js | 211 ++++++++++++++++++++++++++++++++ 6 files changed, 594 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72e747c..0939378 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. ### 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. ### Fixed diff --git a/Manual.md b/Manual.md index 98b5afa..f95d637 100644 --- a/Manual.md +++ b/Manual.md @@ -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 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. **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 328205c..74ccdf2 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -5627,7 +5627,7 @@ function magToggleDriving() { state.driving = !state.driving; const overlay = $('mag-driving'); if (overlay) overlay.hidden = !state.driving; - if (state.driving) renderDriving(); + if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); } magDrivingWake(); } @@ -5796,6 +5796,7 @@ function magDrivingTogglePlayback() { // ── 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_VIEWS = ['image', 'lyrics', 'compact']; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; function magDrivingPrefs() { @@ -5805,6 +5806,8 @@ function magDrivingPrefs() { state.drivingPrefs = { speed: MAG_DRIVING_SPEEDS.includes(saved.speed) ? saved.speed : 1, 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; @@ -5871,6 +5874,7 @@ function magDrivingApplyToggles() { const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); if (lyrics && !prefs.text) lyrics.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. @@ -5937,9 +5941,199 @@ function magDrivingSync(audio, duration, pct, sentenceIndex, local, timeline) { visual.hidden = !show; if (show && 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) => + '

' + esc(sent.text || '') + '

').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() { @@ -6223,6 +6417,11 @@ root.magParseDeepLink = magParseDeepLink; root.magDrivingTogglePlayback = magDrivingTogglePlayback; root.magDrivingNext = magDrivingNext; root.magDrivingSkip = magDrivingSkip; +root.magDrivingZoomBy = magDrivingZoomBy; +root.magDrivingZoomReset = magDrivingZoomReset; +root.magDrivingBackToCurrent = magDrivingBackToCurrent; +root.magDrivingToggleCollapsed = magDrivingToggleCollapsed; +root.magDrivingSetView = magDrivingSetView; root.magDrivingSeek = magDrivingSeek; root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingToggleText = magDrivingToggleText; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index c5be38f..bbb915d 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -324,24 +324,61 @@ -