fix(reader): let page margins shrink into the safe-area inset (#4761) (#5001)

This commit is contained in:
Huang Xin
2026-07-08 02:31:01 +09:00
committed by GitHub
parent a8d3411203
commit 0c24aad606
16 changed files with 274 additions and 42 deletions
+5 -1
View File
@@ -10,10 +10,11 @@
- Reading ruler: [line-aware](reading-ruler-line-aware.md) frame-offset map; [vertical-rl backwards #4865](reading-ruler-vertical-rtl-4865.md)
- [Vertical-rl horizontal pagination (#624)](vertical-rl-horizontal-pagination-624.md) — horizontal inputs + two-phase slide; rtl gated `!vertical`
- [Slide/curl turn styles via VT (#555)](page-turn-styles-viewtransitions-555.md) — VT turns gated on nested-VT-groups support (iOS 18 WebKit crashes despite having the API); Tauri fallback = `CapturedPageTurn` capture pipeline (WebGL mesh curl + flat canvas slide, full-gridcell capture, instant nav = drop `animated`); MERGED #4940 (2026-07-05), verified live mac/iOS/Android (mobile = JPEG capped 2x, PNG was 1.5s/turn); Win/Linux capture still open
- [Captured turn ignored instant-highlight hold](captured-turn-instant-highlight-scrolllock.md) — captured slide/curl swipe path (app interceptor, `no-swipe` set) didn't honor `renderer.scrollLocked`; fix = foliate `get scrollLocked()` + gate `useCapturedTurn` move on it (push/VT-slide already gated); PR#5000 + foliate#51
- TOC: [expand + auto-scroll](toc-expand-and-autoscroll.md); [current-position row](toc-current-position-row.md); [table heading clip #4439](toc-table-heading-clip-4439.md); [BooknoteView auto-scroll #4352](booknote-view-autoscroll-4352.md)
- Paginated bg: [swipe flash](paginator-swipe-bg-flash.md); [texture occlusion #4399](paginated-texture-occlusion-4399.md); [gutter bleed #4394](paginator-gutter-bleed-asymmetry-4394.md); [bg-replace reflow #4785](pageturn-bg-replace-reflow-4785.md)
- [Inline-block column overflow](inline-block-column-overflow.md) `#demoteUnfragmentableBoxes`
- FXL/PDF: [fit-width scroll reset #4683](fixed-layout-paginated-scroll-reset-4683.md); [PDF spread seam #4587](pdf-spread-canvas-seam-4587.md); [spine seam #4857](fxl-spread-spine-seam-4857.md)
- FXL/PDF: [fit-width scroll reset #4683](fixed-layout-paginated-scroll-reset-4683.md); [PDF spread seam #4587](pdf-spread-canvas-seam-4587.md); [spine seam #4857](fxl-spread-spine-seam-4857.md); [portrait auto-spread off-center #4984](fxl-portrait-autospread-offcenter-4984.md) MERGED PR#4992+foliate#50 lone page kept one-sided auto margin -> stranded + taps turned page; `computeSpreadInlineMargins`
- Scrolled: [PDF wheel double #4727](pdf-scroll-mode-wheel-double-4727.md); [header title center #4436](scrolled-header-title-center-4436.md); [Duokan fullscreen cover](duokan-fullscreen-cover-scroll.md)
## Critical Files (Most Bug-Prone)
- `src/utils/style.ts` EPUB CSS hub · `packages/foliate-js/paginator.js` · `src/services/tts/TTSController.ts`
@@ -82,11 +83,14 @@
- [Save image to gallery (#4680)](save-image-to-gallery-android.md) MediaStore
- [Webtoon Mode (#3647)](webtoon-mode-3647.md)
- [Middle-click autoscroll #4951](middle-click-autoscroll-4951.md) Autoscroller RAF core; `containerPosition +=`; armed-books preventDefault
- [Auto Scroll teleprompter #4998](auto-scroll-teleprompter-4998.md) MERGED PR#4999 PacedScroller + useAutoScroll + gridcell-centered pill; scrolled-only; tap=pause via iframe-single-click consume
- [Biometric app-lock #4645](biometric-app-lock-4645.md) · [Reference Pages #4542](reference-pages-672-4542.md) · [e-ink refresh page-turner #4687](eink-screen-refresh-pageturner-4687.md)
- [Share intent + toolbar (#4014)](annotation-share-toolbar-4014.md)
- Customize Toolbar: [global serializeConfig #4760](customize-toolbar-global-serializeconfig.md); [e-ink black bar #4839](customize-toolbar-eink-black-bar-4839.md)
- [Edge TTS Web Audio engine (#3851)](edge-tts-webaudio-engine.md) gapless WebAudioPlayer + WSOLA
- [Background TTS sessions PR#4941](tts-background-session-decoupling.md) — hash-keyed session manager, detach/attach, NowPlayingBar; header X routes `onCloseBook` NOT `onGoToLibrary`
- [TTS player redesign](tts-player-redesign.md) mini-player + Dialog sheet replaces icon/popup/TTSBar; MERGED #4996; open: isPlaying glyph desync at section transitions
- [Android bg TTS media session fix](android-bg-tts-media-session-fix.md) — #4941/#4931 regression: `startService()` dies backgrounded → in-process instance calls; always request POST_NOTIFICATIONS; + lock-screen duration scrubber + `onSeekTo` (Edge-only)
- Native TTS: [iOS #4676](native-ios-tts-4676.md) pause==stop; [offline halt #4613](native-tts-offline-autoadvance-4613.md)
- Edge TTS: [word highlight #4017](edge-tts-word-highlighting-4017.md); [drift](tts-word-highlight-singletextnode-drift.md)
- TTS UX: [highlight granularity](tts-highlight-granularity-setting.md); [start-from-selection](tts-start-from-selection.md); [reuse session](tts-reuse-session-mode-entry.md)
@@ -0,0 +1,26 @@
---
name: android-bg-tts-media-session-fix
description: Android background TTS regression - startService() dies backgrounded; use in-process service calls; + lock-screen scrubber/seek
metadata:
node_type: memory
type: project
originSessionId: 052bb3f3-27fe-4eb0-95c3-699a3122083a
---
Branch `fix/android-bg-tts-media-session` (worktree `/Users/chrox/dev/readest-fix-android-bg-tts-media-session`), 2026-07-07. **PR readest/readest#4994 MERGED** (2026-07-07; worktree + local branch cleaned up. rebased onto origin/main; foliate-js submodule re-synced to f6dced2 after rebase per [[worktree-rebase-submodule-drift]]). 5 commits: 15817fc4b in-process IPC, 04e4b4fe6 duration scrubber+seek, 67c22b72b FGS hardening+diagnostic logs, 27e224bcc keepAppInForeground removal (the real fix), a8643ec12 Edge edge-fade click fix. Verified on-device (Xiaomi/MIUI/Android 15). Diagnostic Log.d traces left in 67c22b72b (offered to strip).
**Regression (commit 1, `fix(android): keep background TTS media controls alive when backgrounded`):** after #4941 (session decoupling) + #4931 (Edge WebAudio engine), Android background TTS lost the lock-screen control and audio died when backgrounded. Logs: `Not allowed to start service Intent { act=UPDATE_PLAYBACK_STATE ... MediaPlaybackService }: app is in background`.
Root cause: `NativeTTSPlugin.update_media_session_state`/`update_media_session_metadata` pushed updates to the already-running foreground service via `activity.startService(intent)`. Android 8+ (BSSR) rejects `Context.startService()` from the background unless an active foreground service exempts the app; each per-sentence update threw, so the FGS notification stopped refreshing (lock-screen control went stale) and the audio route was lost. **Fix pattern: never `startService()` to talk to a running service - call the live instance in-process.** `MediaPlaybackService` already had the pattern: static `@Volatile instance` + `requestDeactivation()` posting to it on the main thread. Added companion `pushMetadata`/`pushPlaybackState` (update statics, post to `instance` via `Handler(Looper.getMainLooper())`) + private instance `applyMetadata`/`applyPlaybackState`; removed the dead `UPDATE_METADATA`/`UPDATE_PLAYBACK_STATE` intent branches + the now-unused `serviceScope`/`kotlinx.coroutines.*`. `startForeground()` to *update* an already-foregrounded service is allowed from background (unlike `Context.startForegroundService()` to *start* one).
Secondary: #4941 dropped the `keepAppInForeground`/notification titles from `TTSMediaBridge.bind()`'s `setActive({active:true})`. `keepAppInForeground` gated `requestPostNotificationPermission()` in `mediaSession.ts`, and it defaults false (`alwaysInForeground` in constants.ts), so POST_NOTIFICATIONS was never requested. Fix: `setActive` requests it on EVERY activation (no-op once decided), not gated on the setting - else the FGS media notification (= the lock-screen control) is silently suppressed on Android 13+.
**Feature (commit 2, `feat(android): show section duration and enable seek on the TTS media session`):** user asked to show estimated section duration + seek from the media session. JS half was already there - `ttsMediaBridge.#updatePositionState` already sends `{playing, position, duration}` (ms) every mark, and `mediaSession.ts` already listens for a `media-session-seek` event -> `handlers['seekto']` -> `controller.seekToTime(pos/1000)`. Native side never used them. Added: `METADATA_KEY_DURATION` in the session metadata (Android reads scrubber length from METADATA, thumb from PlaybackState), `ACTION_SEEK_TO` in `setActions`, and `SessionCallback.onSeekTo(pos)` -> `pluginEventTrigger("media-session-seek", {position})` + optimistic thumb move. Bare play/pause updates omit position/duration, so `pushPlaybackState(playing, position: Long?, duration: Long?)` preserves last-known statics (else scrubber snaps to 0 on pause). **Section timeline is Edge/WebAudio ONLY** (`TTSController` comment "position/duration/seek (Edge client only)"; `getPlaybackInfo()` returns null for native TextToSpeech) - native TTS leaves duration 0 so no scrubber appears, which is correct.
**On-device (Xiaomi/MIUI, targetSdk 36) round 1 FAILED - two findings:** (1) tested APK was STALE - logcat still showed `startService(act=UPDATE_METADATA/UPDATE_PLAYBACK_STATE)` which the fix removes, so the fix wasn't built in (likely built from main tree, not the worktree). (2) Deeper root cause the IPC fix does NOT touch: `W/ActivityManager: Stopping service due to app idle: ...MediaPlaybackService` = the service was NEVER promoted to a foreground service (FGS services aren't idle-stopped; readest uid never appears in FGS-type logs). Also MIUI hostile: uid 10186 (SecurityCenter) repeatedly sets readest `post_notification` appop to `ignore`; `Force stopping service`. Audio plays via WebView (`org.chromium.content.browser.AudioFocusDelegate` holds focus), and the service ExoPlayer also requests AUDIOFOCUS_GAIN - possible focus-steal conflict (unconfirmed).
**Commit 3 (`fix(android): harden TTS foreground-service promotion + add diagnostics`):** `showNotification` now uses `ServiceCompat.startForeground(this, id, notif, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)` (explicit type, targetSdk 34+) wrapped in try/catch+Log. `setActive` (mediaSession.ts) decoupled: POST_NOTIFICATIONS request in its own try/catch so a throw/hang can't abort `set_media_session_active` (the FGS start). Trace logs added: `set_media_session_active: startForegroundService` (plugin), `activateSession (wasActive=)`, `startForeground ok`/`failed`. Next device run: build FROM the worktree + `adb uninstall com.bilingify.readest` first; set MIUI Autostart ON + battery No-restrictions + lock in recents; grep logcat for those tags to see where the FGS path breaks. If `startForeground ok` but audio still dies backgrounded -> WebAudio AudioContext suspension (test native voice: survives = confirms WebView issue).
**ACTUAL ROOT CAUSE found round 3 (WebView console `[INFO:CONSOLE]` via `adb logcat` chromium tag):** `Failed to set media session active state: invalid args payload for command set_media_session_active: missing field keepAppInForeground`. The Rust `SetMediaSessionActiveRequest` (models.rs) had `keep_app_in_foreground: bool` as a REQUIRED serde field (all other fields `Option`); #4941's `ttsMediaBridge.bind()` sends `setActive({active:true})` without it, so **Tauri rejected the invoke at the serde layer before the command ran** -> `set_media_session_active` never executed -> FGS never started -> no notification + Android 15 `AS.AudioService: AudioHardening background playback would be muted` killed background audio. Every earlier fix (in-process IPC, FGS hardening, POST_NOTIFICATIONS decouple) was downstream of this and couldn't help because the command never ran. Diagnostic trap: native tags (MediaPlaybackService/NativeTTSPlugin) were absent from logcat because the service was never touched; the answer was only in the WebView JS console (grep logcat for `CONSOLE`). Commit 4 (`fix(android): drop required keepAppInForeground so the TTS service starts`, 27e224bcc): removed `keepAppInForeground` ENTIRELY (dead everywhere - no platform read it; FGS always starts, POST_NOTIFICATIONS now unconditional) from Rust/Kotlin/iOS/TS payloads per user request ("default true"). Follow-up commit 0b8843012 also removed the now-dead `alwaysInForeground` setting + its Android "Background Read Aloud" library-menu toggle (settings.ts/constants.ts/SettingsMenu.tsx + tests) and pruned the i18n key across 33 locales via `pnpm i18n:extract`. **Lesson: on a failed Tauri mobile command, capture the WebView console (logcat `CONSOLE` tag) FIRST - serde arg-rejection surfaces only there, not in native logs.**
Verified: `pnpm test` (7022 pass), `pnpm lint` clean, `cargo check/fmt/clippy -p tauri-plugin-native-tts` clean. **Kotlin NOT compiled/device-verified** - worktree `src-tauri/gen/android` lacks `tauri.settings.gradle` so the plugin's `app.tauri.plugin.*` deps don't resolve standalone; needs `pnpm tauri android` on a real Android 13+/14 device (logcat: foreground -> background -> lock screen). Related: [[tts-background-session-decoupling]], [[edge-tts-webaudio-engine]], [[native-ios-tts-4676]].
@@ -0,0 +1,51 @@
---
name: auto-scroll-teleprompter-4998
description: "Auto Scroll teleprompter mode (#4998, PR#4999): PacedScroller core, useAutoScroll hook, control pill centered on gridcell, scrolled-mode-only View menu toggle"
metadata:
node_type: memory
type: project
originSessionId: 129a72a3-6d52-4f4c-a499-972c0055b4e3
---
Auto Scroll reading mode (#4998), PR #4999 MERGED 2026-07-08 (merge
f8ad47a41); worktree and local branch cleaned up.
Teleprompter scrolling for scrolled mode only, toggled from the View menu
(Shift+A, `onToggleAutoScroll`), dispatches `autoscroll-toggle` events.
Key structure:
- `PacedScroller` added to `src/app/reader/utils/autoscroller.ts` alongside the
middle-click `Autoscroller` ([[middle-click-autoscroll-4951]]): constant
velocity, whole-pixel emission + fractional carry, injected raf/now for
tests, `PACED_SCROLL_MAX_FRAME_MS` dt clamp (background tab resume). A
scrollBy callback may stop() the scroller mid-tick; #tick re-checks active
before re-arming (test covers it).
- `useAutoScroll(bookKey, viewRef)` in reader/hooks, mounted in FoliateViewer:
scrolls `renderer.containerPosition += sign * delta`; sign = -1 when
`renderer.scrollProp === 'scrollLeft'` (scrolled+vertical), matching foliate
paginator.js `offset = -offset` for scrolled vertical (vertical-lr is a known
upstream FIXME). Manual wheel/drag composes with the paced steps by design
(no pause-on-wheel). Tap pause/resume consumes `iframe-single-click` via
eventDispatcher.onSync (same swallow mechanism as middle-click). Stall
detection: containerPosition unchanged ≥800ms → `view.next()` (hops sections
under noContinuousScroll) or stop + 'End of book' toast when
`renderer.atEnd`. Session state mirrored to readerStore
`viewState.autoScrollEnabled` (new setter) for the ViewMenu checkmark;
session never persisted, speed IS: `autoScrollSpeed` percent in BookLayout
(default 100 = 20 px/s base, 25-500 step 25, constants in
services/constants.ts).
- `AutoScrollControl` pill reuses the ParagraphBar chassis but positioned
`absolute` (NOT `fixed`): maintainer explicitly wants it centered on the
book's gridcell, not the viewport — pinned sidebar pushes the reading column
off window center. (ParagraphBar's #4474 comment argues the opposite for
paragraph mode; the two are intentionally different.) Fades after 2.5s while
scrolling, wakes on mousemove/pause, hidden while hoveredBookKey shows bars.
- Adding a field to readerStore ViewState breaks two test fixtures that build
ViewState literals (reader-store.test.ts, tts-auto-advance.browser.test.tsx).
- i18n: 6 new keys (Auto Scroll, Toggle Auto Scroll, Slower, Faster, Exit Auto
Scroll, End of book) hand-translated across all 33 locales following each
locale's existing Scrolled Mode / RSVP Slower-Faster terminology; scanner
extraction only touched trailing commas (no pruning this time).
Verified live in dev-web with claude-in-chrome (localhost:3001): 20 px/s at
100%, menu gating, pill geometry (pillCenterX == gridcell center != viewport
center).
@@ -0,0 +1,43 @@
---
name: captured-turn-instant-highlight-scrolllock
description: Captured slide/curl page turns ignored the instant-highlight still-hold gate; fixed by honoring renderer.scrollLocked like the push paginator
metadata:
node_type: memory
type: project
originSessionId: 871c7b42-61c0-44e7-a1d6-8edb35d80300
---
Instant Highlight's 300ms still-hold gate ([[instant-highlight-tap-paginate]])
worked in **push** mode but NOT in **slide/curl** — a swipe after the hold turned
the page (with the slide/curl effect) instead of extending the highlight.
**Root cause: two independent swipe paths.** foliate's native `#onTouchMove`
(paginator.js) bows out at `if (this.hasAttribute('no-swipe')) return` (~2149),
THEN checks `if (this.#scrollLocked) return` (~2162), THEN the `#layeredTurn` VT
drag (~2179). So:
- **push** (no turn-style, no no-swipe) → native swipe, honors `#scrollLocked`. ✅
- **VT-layered slide** (`turn-style='slide'`, no no-swipe; engines with nested VT
groups) → native swipe → layered turn, still AFTER the scrollLocked check. ✅
- **captured curl (always) / captured slide (Tauri w/o full VT support)** →
`applyPageTurnAttributes` sets `no-swipe`, so native swipe returns early and the
APP-side captured-turn touch interceptor in `useCapturedTurn.ts` (priority 5,
driven by `iframe-touchmove``dispatchTouchInterceptors`) is the swipe handler.
It began a drag on any >15px horizontal move WITHOUT checking scrollLocked. ❌
`useTextSelector.startInstantAnnotating` sets `view.renderer.scrollLocked = true`
when the hold engages. The captured interceptor is a parallel reimplementation of
swipe-to-turn and must honor the same lock independently.
**Fix (app PR readest#5000 + foliate readest/foliate-js#51, tests:
`useCapturedTurn-scrollLock.test.ts`):**
1. foliate `paginator.js`: add `get scrollLocked()` — it was setter-only, so JS
couldn't read it back (app `src/types/view.ts` already declared it a readable
boolean). foliate PR #51 MERGED (squash → `ba57ec8` on foliate main); app
#5000 bumps the submodule pointer to `ba57ec8` (mergeable, awaiting merge).
2. `useCapturedTurn.ts` touch interceptor, `move` phase, before starting a drag
(`!state` branch): `if (currentView.renderer.scrollLocked) return false;`.
**Why the `!state` branch is sufficient:** a captured drag needs >15px horizontal
travel, but `maybeCancelInstantHoldOnMove` cancels the hold at >10px — so a drag
can never already be in progress when instant annotation engages; no need to gate
an in-flight drag. See [[page-turn-styles-viewtransitions-555]].
@@ -18,6 +18,7 @@ Branch `feat/edge-tts-webaudio` (worktree `/Users/chrox/dev/readest-feat-edge-tt
- `ensureSharedAudioContext()` is called in the tts-speak gesture path BEFORE any await (WebKit autoplay window); `unblockAudio` silent element runs on ALL platforms (desktop Chromium media keys need a playing HTMLMediaElement).
- `abortSession` never suspends the context (warm output stream IS the #3851 fix); only user pause suspends.
- Word boundaries stay in original untrimmed media time; `getChunkPosition()` returns trim-relative clamped seconds; timeline sums TRIMMED durations.
- Inter-sentence CLICKS/POPS = the silence trim (`findSpeechBounds`) cuts at an amplitude threshold (0.005), NOT a zero crossing, so each buffer edge is a non-zero sample; the source steps to/from silence → click. NOT WSOLA (no-op at rate 1.0, cross-fades internal splices). Fix (commit a8643ec12, branch fix/android-bg-tts-media-session): `applyEdgeFade` in `pcm.ts` ramps ~3ms at both ends of the buffer's OWN copy (`buffer.getChannelData(0)` after `createMonoBuffer`) — never the `trimmed` subarray view (rate 1.0 aliases the decoded buffer). Trim + gap kept. Removing the trim instead would work (WSOLA DOES scale silence, so gaps stay rate-scaled) but doubles each gap to Edge's tail+lead silence.
- `POPUP_HEIGHT` in TTSControl.tsx is fixed and non-scrolling — grows to 200 only when a timeline-capable client is active.
**Follow-up decided (2026-07-04, not yet planned): background TTS decoupling.** App-level TTSSessionManager owns the controller; reader hook becomes attach/detach. Matrix chrox chose: close book = keep playing (headless via `section.createDocument()`); reopen SAME book = seamless reattach (adopt session + `redispatchPosition()` + CFI re-anchoring — the highlighter already re-anchors ranges through CFIs, so cross-doc ranges are safe; swap text supply to rendered doc lazily at next section boundary); open a DIFFERENT book = TTS STOPS (not "keeps playing while browsing"); explicit stop / sleep timer = stops. Fiddly bit: `getCFI` without a rendered view. Recorded in branch TODOS.md.
@@ -0,0 +1,37 @@
---
name: fxl-portrait-autospread-offcenter-4984
description: PDF/FXL auto-spread in portrait rendered the lone page off-center and made taps turn pages
metadata:
node_type: memory
type: project
originSessionId: f24a5890-de13-4767-bb33-97621f332e44
---
Issue #4984: in fixed-layout (PDF) `spread='auto'` + portrait viewport, the page
was shoved into one half of the screen ("weirdly separate") and almost every tap
turned the page instead of opening the menu.
Root cause (verified in Chrome): `FixedLayout.#render` in
`packages/foliate-js/fixed-layout.js` already hides the non-target page in
portrait (`if (portrait && frame !== target) display:none`) and scales the shown
page as a single page, BUT it kept the spread-centering one-sided inline margin —
left page `marginInlineStart:auto`, right page `marginInlineEnd:auto`. With no
partner page to meet at the spine, that auto margin stranded the lone page in one
half of the viewport whenever it was narrower than the viewport (any zoom < 100%,
e.g. the issue's 50% zoom; or a page whose fit-scaled width < viewport width).
The off-center page then sat over a page-turn tap zone (tap zones are
view-relative: center 0.375-0.625 = menu, else turn — see `usePagination.ts`), so
taps turned the page. Symptom 2 was a consequence of symptom 1.
Fix MERGED (readest PR#4992 + foliate-js PR#50 squash -> foliate main f6dced2, readest submodule bumped to it): added pure `computeSpreadInlineMargins(portrait)`; in portrait
both inline margins are `auto` (centered), in landscape one-sided (pages meet at
spine). It sets BOTH margins explicitly (opposite side cleared to '') because
frames are re-styled in place on rotation (ResizeObserver -> `#render`, no
`#respread`), so a stale `auto` would otherwise linger. NOT fixed by forcing
`spread='none'` in portrait — that duplicates the existing portrait-single-page
path, needs app-layer orientation swapping + `#respread` (cache clear + re-nav),
and overrides the user's chosen setting.
Test: `src/__tests__/document/fixed-layout-portrait-single-page.test.ts`. Related:
[[fxl-spread-spine-seam-4857]] shares this render branch;
[[pdf-text-selection-fontscale-4480]].
@@ -0,0 +1,26 @@
---
name: tts-player-redesign
description: TTS control redesigned to mini-player + Dialog player sheet (Apple Books/ElevenLabs style); replaces floating icon/popup/TTSBar; showTTSBar retired; PR #4996
metadata:
node_type: memory
type: project
originSessionId: d8af2d26-c714-44f4-b2f2-dfe08676fe87
---
TTS player redesign built 2026-07-07, **PR readest/readest#4996 MERGED same day** (squash 17de9357d; worktree + local branch cleaned). Spec + plan in `.claude/plans/2026-07-07-tts-player-redesign{,-plan}.md`. Late tweaks: main-view sheet header label dropped; progress line moved to card BOTTOM edge; eink = 1px hairline track + solid base-content fill + buffer hidden (mini), 1px border on `.tts-scrubber` (sheet); chrox added `audio-track`/`audio-played-part` class hooks + `not-eink:` prefixes on the mini progress divs before merge.
Architecture: `usePlaybackInfo` hook (poll/monotonic-hold/2% total quantization/optimistic seek+rollback, extracted from old TTSProgressRow) feeds `TTSMiniPlayer` (persistent bottom card: 3px progress line w/ buffer-ahead fill from `measuredFraction`, sentence transport, stop, tap-to-expand, exports `TTS_MINI_PLAYER_CLEARANCE=64` consumed by FoliateViewer whenever `ttsEnabled`) and `TTSScrubber` (gradient track: currentColor/40%/15% color-mix; `.tts-scrubber` CSS in globals.css). `TTSPlayerSheet` = Dialog bottom sheet (snapHeight 0.65, desktop `sm:!w-[420px]`) with cover, scrubber, 5-button transport, `SpeedChips` presets (off-preset rate like default 1.3 merges as extra chip), Voice/Sleep-Timer NavigationRow sub-views. Deleted: reader TTSPanel/TTSBar/TTSIcon; `showTTSBar` removed from ViewSettings/constants.
**Why:** chrox asked to "redesign the TTS control like modern TTS apps (ElevenLabs/Kindle/Apple Books)"; chose mini-player+sheet structure, sentence/paragraph transport (works on ALL engines; time seek Edge-only via scrubber), preset speed chips. Absorbed TODOS items: sticky-bar scrubber + buffer-ahead indicator.
**How to apply:**
- Sheet mounts only while open (`showPlayerSheet &&` gate in TTSControl) so hidden hooks don't poll; DictionarySheet is the mounting precedent.
- Transport clusters and scrubbers are `dir='ltr'` (audio-timeline convention) — the final review caught the mini player missing this under RTL; wrap the button cluster, keep timer chip outside.
- Rate persistence reads `useSettingsStore.getState()` at call time (stale-closure class #4780); persists BOTH viewSettings.ttsRate and globalViewSettings.ttsRate.
- Native voices (no timeline): scrubber hidden, show `{{time}} left in chapter` from chapterRemainingSec estimates.
- Live-feedback wave (same day, chrox watching dev-web): sheet controls collapsed to ONE row of speed/voice/timer buttons (speed chips now a 'speed' sub-view; SpeedChips exports formatRate); ttsDuration EMA (alpha 0.2) replaced with CUMULATIVE chars/secs ratio per voice (cap 3600s rescale, legacy {cps,n} migrates as 30s prior) to stop elapsed-time jumping; mini player unmounts while sheet open; TTSController #clearAllHighlights on every section entry (stale last-word leak in preloaded neighbor views) + reapplyCurrentHighlight skips the sentence fallback while playing in word mode (page-turn sentence flash).
- **OPEN BUG seen live:** isPlaying glyph desyncs at section transitions (CTA shows play while audio runs; tapping it calls start() which re-speaks from stored ttsLocation = position jump). Repro: watch CTA across chapter auto-advance. Likely a transit state-change ('paused'-flavored or missed 'playing') in useTTSControl handleStateChange.
- Deferred follow-ups from final review: e-ink visual pass (stale+disabled opacity compounds ~30%), two usePlaybackInfo edge tests, dead `!groups` branch in sheet, two usePlaybackInfo instances don't share seek suppression.
- Verified live in dev-web Chrome: mini player + buffer-ahead fill, sheet + sub-views, drag seek + optimistic hold, back-to-TTS pill, section auto-advance label/timeline reset, stop button, zh-CN i18n. NOT yet: timer countdown chip, native-voice degradation, background NowPlayingBar reattach, e-ink, RTL, mobile gestures. Dev-env traps: stale serwist SW on localhost served year-old locale JSON (unregister + caches.delete); dev-web on port 3001 when 3000 busy.
Related: [[edge-tts-webaudio-engine]], [[tts-background-session-decoupling]], [[feedback_use_worktree]].
@@ -2,6 +2,8 @@ import { describe, it, expect, vi } from 'vitest';
import { render } from '@testing-library/react';
import SectionInfo from '@/app/reader/components/SectionInfo';
let currentBookData: { isFixedLayout: boolean } | undefined;
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isAndroidApp: false, isMobile: true } }),
}));
@@ -19,6 +21,14 @@ vi.mock('@/store/readerStore', () => ({
}),
}));
vi.mock('@/store/bookDataStore', () => {
const state = { getBookData: () => currentBookData };
return {
useBookDataStore: <R,>(selector?: (s: typeof state) => R) =>
selector ? selector(state) : state,
};
});
vi.mock('@/hooks/useTranslation', () => ({
useTranslation: () => (key: string) => key,
}));
@@ -70,22 +80,32 @@ describe('SectionInfo notch mask', () => {
});
describe('SectionInfo contrast against the page (#4901)', () => {
// A light-mode PDF shown under a dark theme keeps its white page, but the
// running header text was themed for the dark UI (neutral-content, light)
// and became unreadable on the white page. mix-blend-mode: difference makes
// the text invert against whatever is actually behind it (the page or the
// themed margin), so it stays legible over any background.
it('blends the section title against the background in non-eink mode', () => {
// A light-mode PDF shown under a dark theme keeps its white page, so the
// running header blends against the real backdrop (text-white/75 +
// mix-blend-difference) to stay legible on any background. Reflowable books
// theme their own page to the UI, so the header uses plain base-content text
// instead of the blend.
it('blends the section title over a fixed-layout page in non-eink mode', () => {
currentBookData = { isFixedLayout: true };
const { container } = render(<SectionInfo {...baseProps} isEink={false} />);
const info = container.querySelector('.sectioninfo') as HTMLElement;
expect(info.classList.contains('mix-blend-difference')).toBe(true);
expect(info.classList.contains('text-white/75')).toBe(true);
// The theme-fixed neutral color is what made it unreadable on a white page.
expect(info.classList.contains('text-neutral-content')).toBe(false);
});
it('uses themed base-content text for reflowable books in non-eink mode', () => {
currentBookData = { isFixedLayout: false };
const { container } = render(<SectionInfo {...baseProps} isEink={false} />);
const info = container.querySelector('.sectioninfo') as HTMLElement;
expect(info.classList.contains('mix-blend-difference')).toBe(false);
expect(info.classList.contains('text-white/75')).toBe(false);
expect(info.classList.contains('text-base-content')).toBe(true);
});
it('does not blend in eink mode (base-content text on the e-ink page)', () => {
currentBookData = { isFixedLayout: true };
const { container } = render(<SectionInfo {...baseProps} isEink={true} />);
const info = container.querySelector('.sectioninfo') as HTMLElement;
@@ -288,13 +288,35 @@ describe('ProgressBar — sticky progress bar', () => {
});
describe('ProgressBar — contrast against the page (#4901)', () => {
// A light-mode PDF under a dark theme keeps its white page while the footer
// progress text stayed themed for the dark UI (neutral-content, light) and
// became unreadable over the white page. Blending the text spans against the
// real backdrop keeps the page number and remaining-pages text legible on
// any background. StatusInfo (clock/battery) is intentionally left alone — it
// manages its own blend against the battery glyph.
it('blends the progress and remaining text against the background in non-eink mode', () => {
// A light-mode PDF under a dark theme keeps its white page, so the footer
// progress/remaining text blends against the real backdrop (text-white/75 +
// mix-blend-difference) to stay legible over the white page. Reflowable books
// theme their own page to the UI, so the footer uses plain base-content text
// instead of the blend. StatusInfo (clock/battery) is intentionally left
// alone -- it manages its own blend against the battery glyph.
it('blends the progress and remaining text over a fixed-layout page in non-eink mode', () => {
currentViewSettings = {
...baseSettings,
isEink: false,
showRemainingPages: true,
showRemainingTime: false,
progressInfoMode: 'all',
} as ViewSettings;
currentProgress = makeProgress(2, 5);
currentBookData = { isFixedLayout: true };
currentRenderer = { page: 1, pages: 4 };
const { container } = renderProgressBar();
const progress = container.querySelector('.progress-info') as HTMLElement;
const remaining = container.querySelector('.remaining-info') as HTMLElement;
expect(progress.classList.contains('mix-blend-difference')).toBe(true);
expect(progress.classList.contains('text-white/75')).toBe(true);
expect(remaining.classList.contains('mix-blend-difference')).toBe(true);
expect(remaining.classList.contains('text-white/75')).toBe(true);
});
it('uses themed base-content text for reflowable books in non-eink mode', () => {
currentViewSettings = {
...baseSettings,
isEink: false,
@@ -310,10 +332,10 @@ describe('ProgressBar — contrast against the page (#4901)', () => {
const progress = container.querySelector('.progress-info') as HTMLElement;
const remaining = container.querySelector('.remaining-info') as HTMLElement;
expect(progress.classList.contains('mix-blend-difference')).toBe(true);
expect(progress.classList.contains('text-white/75')).toBe(true);
expect(remaining.classList.contains('mix-blend-difference')).toBe(true);
expect(remaining.classList.contains('text-white/75')).toBe(true);
expect(progress.classList.contains('mix-blend-difference')).toBe(false);
expect(progress.classList.contains('text-base-content')).toBe(true);
expect(remaining.classList.contains('mix-blend-difference')).toBe(false);
expect(remaining.classList.contains('text-base-content')).toBe(true);
});
it('does not blend in eink mode', () => {
@@ -141,7 +141,7 @@ const NowPlayingBar = ({ isSelectMode }: NowPlayingBarProps) => {
aria-label={`${_('Open Book')}: ${title}`}
className={clsx(
'not-eink:bg-base-300 eink-bordered flex items-center gap-2 rounded-full shadow-lg',
'h-14 max-w-[calc(100vw-1rem)] cursor-pointer px-2',
'h-14 max-w-[calc(100vw-2rem)] cursor-pointer px-2',
'focus-visible:ring-primary focus-visible:ring-2 focus-visible:outline-none',
)}
>
@@ -781,6 +781,7 @@ const FoliateViewer: React.FC<{
const applyMarginAndGap = () => {
const viewSettings = getViewSettings(bookKey)!;
const viewState = getViewState(bookKey);
const bookData = getBookData(bookKey);
const viewInsets = getViewInsets(viewSettings);
const showDoubleBorder = viewSettings.vertical && viewSettings.doubleBorder;
const showDoubleBorderHeader = showDoubleBorder && viewSettings.showHeader;
@@ -814,7 +815,7 @@ const FoliateViewer: React.FC<{
const scrollBottom = footerVisible
? Math.max(footerBarHeight, miniPlayerClearance)
: miniPlayerClearance;
setScrollMargins({ top: scrollTop, bottom: scrollBottom });
setScrollMargins({ top: bookData?.isFixedLayout ? 0 : scrollTop, bottom: scrollBottom });
} else {
setScrollMargins({ top: 0, bottom: 0 });
}
@@ -60,8 +60,6 @@ const HintInfo: React.FC<SectionInfoProps> = ({
};
}, [hintMessage]);
if (!hintMessage) return null;
return (
<>
<div
@@ -100,8 +98,8 @@ const HintInfo: React.FC<SectionInfoProps> = ({
>
<h2
className={clsx(
'text-center font-sans',
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-light',
'text-center font-sans line-clamp-1',
isEink ? 'text-sm font-normal' : 'text-base-content text-xs font-light',
)}
>
{hintMessage || ''}
@@ -287,9 +287,9 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
'remaining-info whitespace-nowrap text-start',
!stickyBarActive && 'flex-1',
showStatusInfo && 'overflow-hidden',
// Keep the text legible on any backdrop (e.g. a light PDF page
// under a dark theme, #4901); blend it against what's behind.
!isEink && 'text-white/75 mix-blend-difference',
bookData?.isFixedLayout && !isEink
? 'text-white/75 mix-blend-difference'
: 'text-base-content',
)}
>
{viewSettings.showRemainingTime ? (
@@ -349,9 +349,9 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
className={clsx(
'progress-info items-center overflow-hidden whitespace-nowrap text-end tabular-nums',
!stickyBarActive && 'flex-1',
// Keep the page number legible on any backdrop (e.g. a light PDF
// page under a dark theme, #4901); blend it against what's behind.
!isEink && 'text-white/75 mix-blend-difference',
bookData?.isFixedLayout && !isEink
? 'text-white/75 mix-blend-difference'
: 'text-base-content',
)}
>
{(progressBarMode === 'all' || progressBarMode.includes('progress')) && (
@@ -6,6 +6,7 @@ import { useThemeStore } from '@/store/themeStore';
import { useReaderStore } from '@/store/readerStore';
import { useTranslation } from '@/hooks/useTranslation';
import { eventDispatcher } from '@/utils/event';
import { useBookDataStore } from '@/store/bookDataStore';
interface SectionInfoProps {
bookKey: string;
@@ -34,7 +35,9 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
const { appService } = useEnv();
const { hoveredBookKey, getView, getViewSettings, setHoveredBookKey } = useReaderStore();
const { systemUIVisible, statusBarHeight } = useThemeStore();
const getBookData = useBookDataStore((s) => s.getBookData);
const viewSettings = getViewSettings(bookKey)!;
const bookData = getBookData(bookKey);
const topInset = Math.max(
gridInsets.top,
appService?.isAndroidApp && systemUIVisible ? statusBarHeight / 2 : 0,
@@ -75,13 +78,11 @@ const SectionInfo: React.FC<SectionInfoProps> = ({
<div
className={clsx(
'sectioninfo absolute flex items-center overflow-hidden font-sans',
// A light-mode PDF stays white under a dark theme, so the themed
// neutral-content title was unreadable on the page (#4901). Blend the
// text against whatever is actually behind it (page or margin) so it
// stays legible on any background. text-white/75 matches the previous
// neutral-content brightness over the dark theme, so nothing changes
// for reflowable books. E-ink keeps its plain base-content text.
isEink ? 'text-sm font-normal' : 'text-white/75 mix-blend-difference text-xs font-light',
isEink
? 'text-sm font-normal'
: bookData?.isFixedLayout
? 'text-white/75 mix-blend-difference text-xs font-light'
: 'text-base-content text-xs font-light',
isVertical ? 'writing-vertical-rl max-h-[85%]' : 'top-0',
)}
role='none'
@@ -28,7 +28,7 @@ const StatusInfo: React.FC<StatusInfoProps> = ({
return (
<div
className={clsx(
'status-bar flex shrink-0 items-center gap-2 whitespace-nowrap tabular-nums',
'status-bar flex shrink-0 items-center gap-2 whitespace-nowrap tabular-nums text-base-content',
isVertical ? 'my-auto' : 'flex-row',
)}
>
@@ -614,7 +614,9 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
value={showHeader && !isVertical ? marginTopPx : compactMarginTopPx}
onChange={showHeader && !isVertical ? setMarginTopPx : setCompactMarginTopPx}
min={
showHeader && !isVertical ? Math.max(0, Math.round((16 - gridInsets.top) / 4) * 4) : 0
showHeader && !isVertical
? Math.max(0, Math.round((16 - gridInsets.top) / 4) * 4) - gridInsets.top
: 0
}
max={88}
step={4}
@@ -625,7 +627,7 @@ const LayoutPanel: React.FC<SettingsPanelPanelProp> = ({ bookKey, onRegisterRese
onChange={showFooter && !isVertical ? setMarginBottomPx : setCompactMarginBottomPx}
min={
showFooter && !isVertical
? Math.max(0, Math.round((16 - gridInsets.bottom) / 4) * 4)
? Math.max(0, Math.round((16 - gridInsets.bottom) / 4) * 4) - gridInsets.bottom
: 0
}
max={88}