From 488e57e6097a1a5bfb921b07a40134e32f523376 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 3 Oct 2026 14:24:05 +0200 Subject: [PATCH] feat(magazine): clearer driving-mode menu with switches and theme selector Menu is grouped into View (Picture/Text/Split radios), Show and Playback (real switches) and Theme (Light|Auto|Dark segmented control). Fixes the Dark option looking disabled: its data-theme=dark attribute matched the app-wide dark palette selector. Adds automatic advance to the next article with audio (switch, default on) with End of magazine state. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 + Manual.md | 4 +- static/js/magazine.js | 100 ++++++-- static/sections/s-magazine.html | 51 ++-- static/style.css | 56 +++- tests/magazine.spec.js | 437 +++++++++++++++++++++++++++++--- 6 files changed, 571 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 670f3b8..46596a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,13 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi - **Character info is translated to English before image prompts are built** (`core/prompt_translation.py`, `_english_prompt_inputs`). Free-text sheet fields (species, appearance, clothing, age, props, story-moment quote) and the book setting are translated in one batched, low-temperature call through the app's existing LLM, validated per field (non-empty, no error echo, length, names preserved, not still German), and cached in the new `prompt_translation_cache` table keyed by source hash, language, model and prompt version. Records and the displayed German text never change; species/age/dwarf/period checks now run on English. If the LLM is down or output is rejected the original text is used, a warning is logged, `prompt_translation.used_untranslated_text` is reported and generation continues. `PROMPT_TRANSLATION=off` disables it. New read-only dry run `POST /api/character-prompt-preview`. See architecture/character-visual-identity.md. - **Record/replay cache for vision OCR responses** (`core/vision_cache.py`, `MAGAZINE_VISION_CACHE=live|record|replay`). Freezes the raw model response per (model, prompt, image, parameters) in a git-ignored cache directory so OCR/layout/post-processing changes can be compared on identical vision output; replay fails loudly on a miss. `bench_ocr.py --vision-cache` and `compare_magazines.py` (`--baseline-vision-run`, `--new-vision-run`) report mode and models and say whether a comparison is frozen. See architecture/magazine-ingestion.md. +- **Driving-mode menu redesigned.** Sections *View*, *Show*, *Playback* and *Theme*. *View* is a radio group with icon, name and indicator: **Picture** (whole picture, no text), **Text** (whole text, lyrics-style scrolling, no picture) and **Split** (picture plus three lines; the former Compact). The on/off options (*Show text*, *Show pictures*, *Minimize text and picture*) are real switches (label left, switch right, thumb position and a tick/cross inside the thumb as well as colour; `role="switch"`). The theme is one segmented control **Light | Auto | Dark** with icons and a tick on the selected segment. Controls are 44 px high, the flyout fits 320 px, text keeps 4.5:1 and switch, segment and radio boundaries and the focus ring 3:1 in both themes; the thumb does not slide with reduced motion. Keyboard: arrows (Up/Down/Left/Right) move over every control, Home/End jump, Space/Enter activate, Escape closes and returns focus to the menu button. Views stored by older builds migrate (image to picture, lyrics to text, compact to split). +- **Driving mode plays on automatically.** When an article ends it is marked finished (saved like the playlist does) and the next article that has audio starts (rows without audio are skipped, speed carries over, title, picture, text, index highlight and lock-screen metadata follow, "Now playing: ..." is announced once). At the last article it stops with an *End of magazine* state, no wrap-around. New switch *Play next article automatically* (default on, remembered in the driving preferences); off stops after the article as before. A next article whose audio cannot be loaded is skipped with a brief message. Manual Next, seeks and duplicate `ended` events never advance twice. + ### Changed - `vision_ocr_page` routes its request through the cache layer; the default `live` mode behaves exactly as before. ### Fixed +- **"Colors: Dark (night)" looked disabled in the light theme.** The menu item carried `data-theme="dark"`, which is also the hook of the app-wide dark palette (`[data-theme="dark"] {...}`), so the button itself got the dark palette's light text colour on the light menu. The theme buttons now use `data-theme-choice`, so no app theme selector can match them. - **A boy rendered as an adult:** `_character_age_category` read `age`, but sheets store `age_estimate`. - **Characters' data no longer bleeds into each other through shared labels or names.** A real-library audit (270 records) traced Arthag's (book 02) copy of Kolon's species and relationships and 16 whole-sheet copies (Zwerg/Kolon = Arthag, Mann = Fremder, Alchimist = Promos ...) to identity matching on any shared token ("Zwerg", "der Zwerg", "Mann") with "longer text wins" merging, and to `csRosterCanonicalName` renaming a sheet to the first cast name found among its aliases instead of its own name. Now only a real name links two profiles (`static/js/character-identity.js`, `core/character_records.py`), the own name always wins (`csFindMergeKey`, `csRosterCanonicalName`, `clUpsert` writes to the exact-name record before any alias scan, no seed rename onto a name that has its own record). Tests: `tests/character-identity.test.mjs`. - **Other cast members' names are no longer stored as a character's alias or real name** (Kolon carried "Sharraz Garthai"/"Sharraz"): `_charsheets_parse` drops them in cast-target mode, keeping relations such as "Tochter des Promos". diff --git a/Manual.md b/Manual.md index 77eef04..406a61c 100644 --- a/Manual.md +++ b/Manual.md @@ -355,8 +355,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, 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. + **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 in four groups. **View** (radio options with icon and name): **Picture** (the whole picture, no text), **Text** (the whole text, lyrics-style, no picture) and **Split** (picture plus three lines, the default; in landscape side by side). **Show** (switches, label left and switch right; on = thumb right with a tick, off = thumb left with a cross): *Show text* and *Show pictures* hide just that part in the views that contain it (so with Split you can drop either), and *Minimize text and picture*. **Playback:** *Play next article automatically* (see below). **Theme:** one control with *Light*, *Auto* and *Dark*; the chosen one carries a tick. Each control is at least 44 px high and the flyout fits phones from 320 px wide. Keyboard: Enter or Arrow Down opens the menu; Up/Down/Left/Right move over every control, Home/End jump to the first/last, Space or Enter switches or selects, Escape or a tap outside closes it and returns focus to the menu button. Choosing a view closes the flyout; switches and the theme keep it open so you see the effect. **Automatic next article:** when an article ends it is marked finished and the next article that has audio starts (articles without audio are skipped, the speed stays); title, picture, text and the index follow and a screen reader hears "Now playing: ..." once. After the last article it stops and shows *End of magazine* (it never starts over). Switch *Play next article automatically* off to stop after each article. An article whose audio cannot be loaded is skipped with a short message. Older saved views are converted (Lyrics to Text, Compact to Split). **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. **Theme (night driving):** the *Theme* control at the end of the menu offers **Auto** (follows the 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. **Lyrics** in the older wording is now the **Text** view. **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. **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 9dfd7e8..d7b39ed 100644 --- a/static/js/magazine.js +++ b/static/js/magazine.js @@ -5402,6 +5402,9 @@ function magPlaylistPlay(articleId, options) { state.playlistCurrent = articleId; state.article = a; state.playlistLastSaved = 0; + state.playlistEndedHandled = false; + state.playlistAuto = !!options?.auto; // only automatic advances are skipped when their audio cannot load + magDrivingEndState(false); const audio = $('mag-playlist-audio'); if (audio) { state.playlistSwitching = true; @@ -5435,15 +5438,57 @@ function magPlaylistPlay(articleId, options) { renderPlaylistTracks(); loadPlaylistCompanion(a); if (state.driving) { renderDriving(); magDrivingApplyToggles(); magApplyPlaybackSpeed(); } + if (options?.auto && state.driving) magDrivingAnnounce('Now playing: ' + (a.title || 'Untitled')); } +// Polite live-region message, once per article (never per sentence). +function magDrivingAnnounce(msg) { + const el = $('mag-driving-announce'); + if (!el) return; + el.textContent = ''; + setTimeout(() => { el.textContent = msg; }, 50); +} +function magDrivingEndState(on) { + const el = $('mag-driving-endstate'); + if (el) el.hidden = !on; +} + +// Called for the media element's 'ended' event and, with `false`, for the manual Next button. function _magPlaylistNext(eventOrCompleted) { + const isEnded = typeof eventOrCompleted === 'object' && eventOrCompleted !== null; const completed = eventOrCompleted !== false; + const audio = $('mag-playlist-audio'); + if (isEnded) { + // A stale or duplicate 'ended' must not advance twice (the browser drops events queued for a replaced source; the flag covers repeats). + if (!audio || state.playlistEndedHandled || audio.dataset.article !== state.playlistCurrent) return; + if (!audio.ended && !audio.paused && Number.isFinite(audio.duration) && audio.currentTime < audio.duration - 1) return; // demonstrably mid-track + state.playlistEndedHandled = true; + } savePlaylistProgress(completed, true); const order = _magPlaylistOrder(); const i = order.findIndex((a) => a.id === state.playlistCurrent); - if (i >= 0 && i < order.length - 1) magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true }); - else syncDrivingPlayButton(); + const auto = isEnded && state.driving; + if (auto && !magDrivingPrefs().autonext) { syncDrivingPlayButton(); return; } // switch off: stop, as before + if (i >= 0 && i < order.length - 1) { + magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true, auto }); + } else { + if (state.driving && isEnded) { magDrivingEndState(true); magDrivingAnnounce('End of magazine'); } + syncDrivingPlayButton(); + } +} + +// A next article that cannot be loaded during automatic advance is skipped with a brief message (no wrap-around, +// and a run of failures ends at the last article instead of looping). +function magPlaylistAudioError() { + const audio = $('mag-playlist-audio'); + if (!audio || !audio.getAttribute('src') || !state.driving || !state.playlistAuto) return; + const order = _magPlaylistOrder(); + const i = order.findIndex((a) => a.id === state.playlistCurrent); + const failed = state.articles.find((x) => x.id === state.playlistCurrent); + toast('Could not play "' + ((failed && failed.title) || 'article') + '", skipped', 'error', 4000); + if (i >= 0 && i < order.length - 1) { + magPlaylistPlay(order[i + 1].id, { skipCurrentSave: true, auto: true }); + } else { magDrivingEndState(true); magDrivingAnnounce('End of magazine'); state.playlistAuto = false; syncDrivingPlayButton(); } } function magPlaylistPrevious() { @@ -5692,7 +5737,7 @@ function magToggleDriving() { 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(); } + if (state.driving) { magDrivingViewsWire(); magDrivingZoomWire(); magDrivingApplyView(); magDrivingApplyToggles(); renderDriving(); } magDrivingWake(); } @@ -5862,7 +5907,9 @@ 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_VIEWS = ['picture', 'text', 'split']; +// Older builds stored image / lyrics / compact; they map to picture / text / split so saved choices keep working. +const MAG_DRIVING_VIEW_LEGACY = { image: 'picture', lyrics: 'text', compact: 'split' }; const MAG_DRIVING_THEMES = ['auto', 'light', 'dark']; const MAG_THEME_COLOR = { dark: '#000000', light: '#F5F3EE' }; const MAG_DRIVING_PREFS_KEY = 'ttsvc_mag_driving_prefs'; @@ -5874,8 +5921,9 @@ 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', + view: MAG_DRIVING_VIEWS.includes(saved.view) ? saved.view : (MAG_DRIVING_VIEW_LEGACY[saved.view] || 'split'), collapsed: saved.collapsed === true, + autonext: saved.autonext !== false, theme: MAG_DRIVING_THEMES.includes(saved.theme) ? saved.theme : 'auto', }; } @@ -5894,8 +5942,8 @@ 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)); + document.querySelectorAll('#mag-driving-menu [data-theme-choice]').forEach((b) => { + b.setAttribute('aria-checked', String(b.dataset.themeChoice === magDrivingPrefs().theme)); }); if (state.driving) { if (theme === 'dark') root.setAttribute('data-mag-night', ''); else root.removeAttribute('data-mag-night'); @@ -5957,6 +6005,13 @@ function magDrivingToggleText() { magPlaylistSync(); } +function magDrivingToggleAutonext() { + const prefs = magDrivingPrefs(); + prefs.autonext = !prefs.autonext; + magDrivingSavePrefs(); + magDrivingApplyToggles(); +} + function magDrivingToggleImages() { const prefs = magDrivingPrefs(); prefs.images = !prefs.images; @@ -5970,6 +6025,8 @@ function magDrivingApplyToggles() { const text = $('mag-driving-text-toggle'), images = $('mag-driving-image-toggle'); if (text) text.setAttribute('aria-checked', String(prefs.text)); if (images) images.setAttribute('aria-checked', String(prefs.images)); + const an = $('mag-driving-autonext'); + if (an) an.setAttribute('aria-checked', String(prefs.autonext)); const lyrics = $('mag-driving-lyrics'), visual = $('mag-driving-visual'); if (lyrics && !prefs.text) lyrics.hidden = true; if (visual && !prefs.images) visual.hidden = true; @@ -6059,7 +6116,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"][data-view]').forEach((b) => { + document.querySelectorAll('#mag-driving-menu [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]; @@ -6080,7 +6137,7 @@ function magDrivingSetView(view) { magDrivingSavePrefs(); magDrivingApplyView(); magPlaylistSync(); - if (view === 'lyrics') magDrivingBackToCurrent(); + if (view === 'text') magDrivingBackToCurrent(); } function magDrivingToggleCollapsed() { @@ -6088,7 +6145,7 @@ function magDrivingToggleCollapsed() { prefs.collapsed = !prefs.collapsed; magDrivingSavePrefs(); magDrivingApplyView(); - if (!prefs.collapsed && prefs.view === 'lyrics') magDrivingBackToCurrent(); + if (!prefs.collapsed && prefs.view === 'text') magDrivingBackToCurrent(); } // Whole text, Spotify style: every sentence is a line; the current one is large and kept centered. @@ -6112,7 +6169,7 @@ function magDrivingBackToCurrent() { function magDrivingSyncFull(sentences, index, wi) { const box = $('mag-driving-full'); const prefs = magDrivingPrefs(); - if (!box || prefs.view !== 'lyrics' || !prefs.text) return; + if (!box || prefs.view !== 'text' || !prefs.text) return; const key = state.playlistCurrent + ':' + sentences.length; if (_drvFull.key !== key) { _drvFull.key = key; _drvFull.index = -1; _drvFull.follow = true; @@ -6230,11 +6287,11 @@ 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'] }; +const MAG_VIEW_META = { picture: ['mdi-image-outline', 'Picture'], text: ['mdi-text-long', 'Text'], split: ['mdi-view-split-horizontal', 'Split'] }; // 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 magDrivingMenuItems() { return [...document.querySelectorAll('#mag-driving-menu button')]; } function magDrivingMenuPlace() { const menu = $('mag-driving-menu'), btn = $('mag-driving-menubtn'), dock = document.querySelector('.mag-driving-dock'); if (!menu || menu.hidden) return; @@ -6271,17 +6328,20 @@ function magDrivingViewsWire() { btn.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); magDrivingMenuOpen(e.key === 'ArrowUp' ? 'last' : undefined); } }); + // Views close the flyout (the change is the answer); the theme and the switches keep it open so the effect is seen. 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(); + const th = e.target.closest('button[data-theme-choice]'); + if (th) { magDrivingSetTheme(th.dataset.themeChoice); return; } + const v = e.target.closest('button[data-view]'); + if (v) { magDrivingSetView(v.dataset.view); magDrivingMenuClose(); } }); + // One roving tab stop over all controls: Down/Right = next, Up/Left = previous (wrapping), Home/End = first/last. + // Enter and Space activate the focused button natively. Escape closes and returns focus to the menu button. 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); + if (e.key === 'ArrowDown' || e.key === 'ArrowRight') go((i + 1) % items.length); + else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') 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(); } @@ -6503,6 +6563,7 @@ function wirePlaylistAudio() { if (!audio || audio.dataset.wired) return; audio.dataset.wired = '1'; audio.addEventListener('ended', _magPlaylistNext); + audio.addEventListener('error', magPlaylistAudioError); audio.addEventListener('playing', () => magDrivingStartPrompt(false)); audio.addEventListener('timeupdate', () => { magPlaylistSync(); savePlaylistProgress(false, false); magMediaSessionPosition(); }); // The loop covers smooth playback; these cover every way the position can change without it noticing. @@ -6704,6 +6765,7 @@ root.magDrivingSetView = magDrivingSetView; root.magDrivingSeek = magDrivingSeek; root.magDrivingCycleSpeed = magDrivingCycleSpeed; root.magDrivingToggleText = magDrivingToggleText; +root.magDrivingToggleAutonext = magDrivingToggleAutonext; root.magDrivingToggleImages = magDrivingToggleImages; root.magDrivingPrevious = magDrivingPrevious; root.magHistoryStep = magHistoryStep; diff --git a/static/sections/s-magazine.html b/static/sections/s-magazine.html index 731d8ba..7fda92d 100644 --- a/static/sections/s-magazine.html +++ b/static/sections/s-magazine.html @@ -324,33 +324,46 @@ -