diff --git a/CHANGELOG.md b/CHANGELOG.md index 9433f77..18222c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi ## [Unreleased] ### Added +- **Night theme for driving mode.** A *Colors* group at the end of the display menu: Auto (follows the system, live; default), Light, Dark; remembered in this browser (storage errors are tolerated) and independent of the app's own theme. Dark uses true black, text at about 85% white, a muted blue accent, dimmed pictures (brightness .85), a themed top bar, menu, index with rows, lyric lines and current-line highlight, spoken-word marker, slider, dock buttons (still perfect circles), zoom buttons, *Tap to play*, toasts and share box, and sets the browser theme colour while driving mode is open (restored on exit). All text pairs are at least 4.5:1 and borders 3:1, including *no audio* rows; status icons differ by shape, not just colour. The overlay is painted dark from its first frame. Tested with Playwright (persistence, Auto vs emulated colour scheme, computed-style contrast ratios, first-frame background, theme-color restore). - **Driving mode index shows what the playlist shows.** Each row has a 44 px round play / resume / replay button, the author ("by Name", smaller and muted, ellipsized), and the three-state listening indicator (not played / partly played / played) with accessible text, updated live as articles play and finish. Shares one state helper (`magListenState`) with the playlist. - **Article index in driving mode.** A new *Index* button in the top bar opens a sheet between the top bar and the controls dock that lists every article of the magazine with its title and length (m:ss or h:mm:ss; articles without audio are dimmed, show *no audio* and cannot be chosen). The playing article is highlighted and scrolled into view; tap or press Enter to jump to an article while driving mode keeps running. Escape, the close button or a tap outside closes it and returns focus to the button; Tab stays inside the sheet. Lengths come from the existing articles endpoint (`audio_seconds`), so there is no new backend call; a 113-article list is a plain list rendered at once. - **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). diff --git a/Manual.md b/Manual.md index c904a10..9c351ff 100644 --- a/Manual.md +++ b/Manual.md @@ -289,7 +289,7 @@ turned into a podcast discussion between two or three voices you pick. large touch target and keyboard reachable; the speed also applies to the 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, 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. Every row is its own item, separated from the next by a thin divider line and generous space above and below (the playing row is highlighted instead), so long two- or three-line titles do not run into each other. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. Each row also has a round **play button** (Play for unplayed, *Resume* with a circular-arrow icon for partly played, *Play again* with the replay icon for finished articles; it starts that article in driving mode and continues from the saved position), the author as *by Name* in smaller muted text under the title (omitted when unknown, long names are shortened), and a status icon on the right (empty circle: not played, dashed circle: partly played, check: finished) that also has the text *Not played / Partly played / Finished-Played* for screen readers and updates live while you listen. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines. + **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. Every row is its own item, separated from the next by a thin divider line and generous space above and below (the playing row is highlighted instead), so long two- or three-line titles do not run into each other. Escape or the close button closes the list (Tab stays inside it while open); the controls below stay visible and usable by touch. Lengths come from the already generated audio, nothing is recomputed. Each row also has a round **play button** (Play for unplayed, *Resume* with a circular-arrow icon for partly played, *Play again* with the replay icon for finished articles; it starts that article in driving mode and continues from the saved position), the author as *by Name* in smaller muted text under the title (omitted when unknown, long names are shortened), and a status icon on the right (empty circle: not played, dashed circle: partly played, check: finished) that also has the text *Not played / Partly played / Finished-Played* for screen readers and updates live while you listen. **Speed** is the button after the time next to the slider. **Picture zoom:** double-tap or double-click, pinch, mouse wheel, the + and - keys, or the zoom in / zoom out / reset buttons on the picture; drag (or arrow keys) to move a zoomed picture; Escape or the reset button returns to full view. **Lyrics:** the spoken sentence is large and kept in the middle, earlier and later ones are dimmed; tap or press Enter on a sentence to play from it; scrolling by hand stops the automatic following and shows *Back to current*. With reduced motion switched on in your device the text jumps instead of gliding. **Minimize** (in the menu) reduces the area to the title and the current line so the dock keeps minimal text; choose it again to expand. Your view and the minimized state are remembered in this browser only. Titles wrap to two lines (with an ellipsis beyond that), picture captions to two lines. **Colors (night driving):** at the end of the menu, *Colors* offers **Auto (follow system)** (default), **Light** and **Dark (night)**. Dark is a night palette for the whole driving overlay (top bar, menu, index and rows, text lines, slider, dock, zoom buttons, the *Tap to play* button, toasts and the share box): true black background, text at about 85% white, a muted blue accent, pictures slightly dimmed, and the browser's address bar colour follows while driving mode is open and is restored when you leave. Text keeps at least 4.5:1 contrast and buttons/borders 3:1, also for rows without audio (shown dimmed with a dashed play button); the listening states stay distinguishable by icon, not only by colour, and the focus ring and spoken-word highlight stay clearly visible. Auto switches live when your phone changes between day and night mode. The choice is independent of the rest of the app and remembered in this browser only (if storage is blocked it applies until you leave the page). Nothing glides or fades in extra for the theme, so reduced-motion settings are unaffected. **Three lines of text:** driving mode shows the previous sentence (small), the current one (large, the spoken word marked) and the next (small) in a box of fixed height, so nothing moves around it. Tap or press Enter on the previous or next line to jump to that sentence. The text is deliberately not announced by screen readers sentence by sentence; the play/pause button and the position slider stay the way to control it. With reduced motion switched on in your device, lines change without animation. On a phone (375 px wide) the sentences wrap and a very long current sentence scrolls inside its box. Turn the text off with *Show text* in the menu to see only the picture. **Text and picture follow the voice:** for audio generated since sentence times were recorded, the highlighted sentence (playlist and driving mode) and the picture shown come from the exact time each sentence was spoken, so they stay right after the slider, the 15-second buttons, a tap on a sentence, the lock screen or a resume. Captions are shown but are not in the MP3, so they are never highlighted. The highlight lights up a sentence just before its first word is heard (each sentence starts with a short silence, which is not counted). Audio from before sentence times were recorded, and audio whose text was edited afterwards, has no usable record and is followed by an estimate from sentence length (less exact); regenerate the audio to get exact sync. Interview-voice audio is covered like any other. **Word by word:** the first time an article is played, the app asks the forced-aligner service (the separate `tts-aligner` container, see below) to measure where each word starts and ends and stores that beside the audio. Playback never waits for it: until it is ready (usually well under a minute for a magazine article) the marked word is estimated from word length as before, and it switches to the measured word on its own. The marked word is the last one that has started, so it stays lit through a pause; the *Lyrics* view now marks the spoken word too (as do *Compact* and the playlist text). If the service is not running, nothing changes for you and the app tries again later (at most every 10 minutes per article). Prerequisites: `docker compose up -d tts-aligner` (first start downloads about 1.2 GB of model weights into the `aligner-models` volume; after that it needs no internet). `ALIGNER_URL` empty turns word timing off. Privacy: text and audio go only to your own container on the internal Docker network. Accessibility: the marking is visual only and adds no announcements; with reduced motion nothing animates. API: `POST /api/magazine/{doc}/articles/{id}/align-words` starts it explicitly, `GET` shows progress. diff --git a/static/js/magazine.js b/static/js/magazine.js index 8d3047d..9dfd7e8 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -5660,6 +5660,7 @@ function magBackToContents() { magPause(); magStopQueue(); state.driving = false; + magDrivingApplyTheme(); magShow('contents'); magRefreshArticles(); } @@ -5671,6 +5672,7 @@ async function magBackToLibrary() { magPause(); magStopQueue(); state.driving = false; + magDrivingApplyTheme(); magForgetLastDoc(); state.doc = null; state.article = null; @@ -5687,6 +5689,7 @@ async function magBackToLibrary() { function magToggleDriving() { state.driving = !state.driving; const overlay = $('mag-driving'); + magDrivingApplyTheme(); // before the overlay is shown, so it never flashes the wrong palette if (overlay) overlay.hidden = !state.driving; if (!state.driving) { magDrivingMenuClose(false); magDrivingIndexClose(false); } if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); renderDriving(); } @@ -5860,6 +5863,8 @@ 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_THEMES = ['auto', 'light', 'dark']; +const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' }; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; function magDrivingPrefs() { @@ -5871,11 +5876,42 @@ function magDrivingPrefs() { text: saved.text !== false, images: saved.images !== false, view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : 'compact', collapsed: saved.collapsed === true, + theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto', }; } return state.drivingPrefs; } +// Colour theme of driving mode only (the app has its own). Auto follows the system setting live. +function magDrivingResolvedTheme() { + const t = magDrivingPrefs().theme; + if (t === 'light' || t === 'dark') return t; + try { return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } catch (_) { return 'light'; } +} +// Sets the palette on the overlay, the page-level hook for toast/share sheet and ; +// when driving mode is closed it removes the hook and restores the original theme-color. +function magDrivingApplyTheme() { + const overlay = $('mag-driving'), theme = magDrivingResolvedTheme(), root = document.documentElement; + const meta = document.querySelector('meta[name="theme-color"]'); + if (overlay) overlay.dataset.dtheme = theme; + document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-theme]').forEach((b) => { + b.setAttribute('aria-checked', String(b.dataset.theme === magDrivingPrefs().theme)); + }); + if (state.driving) { + if (theme === 'dark') root.setAttribute('data-mag-night', ''); else root.removeAttribute('data-mag-night'); + if (meta) { if (state.themeColorOrig == null) state.themeColorOrig = meta.getAttribute('content') || ''; meta.setAttribute('content', MAG_THEME_COLOR[theme]); } + } else { + root.removeAttribute('data-mag-night'); + if (meta && state.themeColorOrig != null) { meta.setAttribute('content', state.themeColorOrig); state.themeColorOrig = null; } + } +} +function magDrivingSetTheme(theme) { + if (!MAG_DRIVING_THEMES.includes(theme)) return; + magDrivingPrefs().theme = theme; + magDrivingSavePrefs(); + magDrivingApplyTheme(); +} + function magDrivingSavePrefs() { try { localStorage.setItem(MAG_DRIVING_PREFS_KEY, JSON.stringify(magDrivingPrefs())); } catch (_) {} } @@ -6023,7 +6059,7 @@ function magDrivingApplyView() { if (!overlay) return; overlay.dataset.view = prefs.view; overlay.dataset.collapsed = prefs.collapsed ? 'true' : 'false'; - document.querySelectorAll('#mag-driving-menu [role="menuitemradio"]').forEach((b) => { + document.querySelectorAll('#mag-driving-menu [role="menuitemradio"][data-view]').forEach((b) => { b.setAttribute('aria-checked', String(b.dataset.view === prefs.view)); }); const mb = $('mag-driving-menubtn'), meta = MAG_VIEW_META[prefs.view]; @@ -6237,6 +6273,7 @@ function magDrivingViewsWire() { }); menu.addEventListener('click', (e) => { const it = e.target.closest('[role="menuitemradio"]'); + if (it && it.dataset.theme) { magDrivingSetTheme(it.dataset.theme); magDrivingMenuClose(); return; } if (it) { magDrivingSetView(it.dataset.view); magDrivingMenuClose(); return; } if (e.target.closest('#mag-driving-collapse, #mag-driving-text-toggle, #mag-driving-image-toggle')) magDrivingMenuClose(); }); @@ -6256,6 +6293,11 @@ function magDrivingViewsWire() { if (panel && !panel.hidden && !e.target.closest('#mag-driving-index, #mag-driving-indexbtn, .mag-driving-dock')) magDrivingIndexClose(false); }); window.addEventListener('resize', () => { magDrivingMenuPlace(); magDrivingIndexPlace(); }); + try { + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const onChange = () => { if (state.driving && magDrivingPrefs().theme === 'auto') magDrivingApplyTheme(); }; + if (mq.addEventListener) mq.addEventListener('change', onChange); else if (mq.addListener) mq.addListener(onChange); + } catch (_) {} } // ── Article index: every article of the magazine with its length, as a sheet between top bar and dock ────────── diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 6f3ce90..731d8ba 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -346,6 +346,12 @@ title="Show or hide the article's pictures">Show pictures +
+
+ + + +