From 0c24aad606a5220b9dfb7124c19bff25d7312eeb Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 8 Jul 2026 02:31:01 +0900 Subject: [PATCH] fix(reader): let page margins shrink into the safe-area inset (#4761) (#5001) --- apps/readest-app/.claude/memory/MEMORY.md | 6 ++- .../android-bg-tts-media-session-fix.md | 26 ++++++++++ .../memory/auto-scroll-teleprompter-4998.md | 51 +++++++++++++++++++ ...tured-turn-instant-highlight-scrolllock.md | 43 ++++++++++++++++ .../memory/edge-tts-webaudio-engine.md | 1 + .../fxl-portrait-autospread-offcenter-4984.md | 37 ++++++++++++++ .../.claude/memory/tts-player-redesign.md | 26 ++++++++++ .../reader/components/SectionInfo.test.tsx | 36 ++++++++++--- .../__tests__/components/ProgressBar.test.tsx | 44 ++++++++++++---- .../app/library/components/NowPlayingBar.tsx | 2 +- .../app/reader/components/FoliateViewer.tsx | 3 +- .../src/app/reader/components/HintInfo.tsx | 6 +-- .../src/app/reader/components/ProgressBar.tsx | 12 ++--- .../src/app/reader/components/SectionInfo.tsx | 15 +++--- .../src/app/reader/components/StatusInfo.tsx | 2 +- .../src/components/settings/LayoutPanel.tsx | 6 ++- 16 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md create mode 100644 apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md create mode 100644 apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md create mode 100644 apps/readest-app/.claude/memory/fxl-portrait-autospread-offcenter-4984.md create mode 100644 apps/readest-app/.claude/memory/tts-player-redesign.md diff --git a/apps/readest-app/.claude/memory/MEMORY.md b/apps/readest-app/.claude/memory/MEMORY.md index 2eef0d78..ec64aea3 100644 --- a/apps/readest-app/.claude/memory/MEMORY.md +++ b/apps/readest-app/.claude/memory/MEMORY.md @@ -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) diff --git a/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md b/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.md new file mode 100644 index 00000000..7717db66 --- /dev/null +++ b/apps/readest-app/.claude/memory/android-bg-tts-media-session-fix.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]]. diff --git a/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md b/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md new file mode 100644 index 00000000..0bf292b7 --- /dev/null +++ b/apps/readest-app/.claude/memory/auto-scroll-teleprompter-4998.md @@ -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). diff --git a/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md b/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md new file mode 100644 index 00000000..f186942b --- /dev/null +++ b/apps/readest-app/.claude/memory/captured-turn-instant-highlight-scrolllock.md @@ -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]]. diff --git a/apps/readest-app/.claude/memory/edge-tts-webaudio-engine.md b/apps/readest-app/.claude/memory/edge-tts-webaudio-engine.md index f0999e6a..4099b6d5 100644 --- a/apps/readest-app/.claude/memory/edge-tts-webaudio-engine.md +++ b/apps/readest-app/.claude/memory/edge-tts-webaudio-engine.md @@ -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. diff --git a/apps/readest-app/.claude/memory/fxl-portrait-autospread-offcenter-4984.md b/apps/readest-app/.claude/memory/fxl-portrait-autospread-offcenter-4984.md new file mode 100644 index 00000000..fbe25e56 --- /dev/null +++ b/apps/readest-app/.claude/memory/fxl-portrait-autospread-offcenter-4984.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]]. diff --git a/apps/readest-app/.claude/memory/tts-player-redesign.md b/apps/readest-app/.claude/memory/tts-player-redesign.md new file mode 100644 index 00000000..8d363517 --- /dev/null +++ b/apps/readest-app/.claude/memory/tts-player-redesign.md @@ -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]]. diff --git a/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx b/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx index 60bf2313..e459708f 100644 --- a/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx +++ b/apps/readest-app/src/__tests__/app/reader/components/SectionInfo.test.tsx @@ -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: (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(); 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(); + 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(); const info = container.querySelector('.sectioninfo') as HTMLElement; diff --git a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx index dcf98809..eecc2f3b 100644 --- a/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx +++ b/apps/readest-app/src/__tests__/components/ProgressBar.test.tsx @@ -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', () => { diff --git a/apps/readest-app/src/app/library/components/NowPlayingBar.tsx b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx index 6ea703bb..0426e905 100644 --- a/apps/readest-app/src/app/library/components/NowPlayingBar.tsx +++ b/apps/readest-app/src/app/library/components/NowPlayingBar.tsx @@ -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', )} > diff --git a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx index 7a28f772..577ae610 100644 --- a/apps/readest-app/src/app/reader/components/FoliateViewer.tsx +++ b/apps/readest-app/src/app/reader/components/FoliateViewer.tsx @@ -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 }); } diff --git a/apps/readest-app/src/app/reader/components/HintInfo.tsx b/apps/readest-app/src/app/reader/components/HintInfo.tsx index 2524b5a3..91dd9c19 100644 --- a/apps/readest-app/src/app/reader/components/HintInfo.tsx +++ b/apps/readest-app/src/app/reader/components/HintInfo.tsx @@ -60,8 +60,6 @@ const HintInfo: React.FC = ({ }; }, [hintMessage]); - if (!hintMessage) return null; - return ( <>
= ({ >

{hintMessage || ''} diff --git a/apps/readest-app/src/app/reader/components/ProgressBar.tsx b/apps/readest-app/src/app/reader/components/ProgressBar.tsx index 8ca61c4b..a907ff9d 100644 --- a/apps/readest-app/src/app/reader/components/ProgressBar.tsx +++ b/apps/readest-app/src/app/reader/components/ProgressBar.tsx @@ -287,9 +287,9 @@ const ProgressBar: React.FC = ({ '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 = ({ 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')) && ( diff --git a/apps/readest-app/src/app/reader/components/SectionInfo.tsx b/apps/readest-app/src/app/reader/components/SectionInfo.tsx index f4d35a85..1b87a3cc 100644 --- a/apps/readest-app/src/app/reader/components/SectionInfo.tsx +++ b/apps/readest-app/src/app/reader/components/SectionInfo.tsx @@ -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 = ({ 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 = ({
= ({ return (
diff --git a/apps/readest-app/src/components/settings/LayoutPanel.tsx b/apps/readest-app/src/components/settings/LayoutPanel.tsx index cb3d3796..6690314e 100644 --- a/apps/readest-app/src/components/settings/LayoutPanel.tsx +++ b/apps/readest-app/src/components/settings/LayoutPanel.tsx @@ -614,7 +614,9 @@ const LayoutPanel: React.FC = ({ 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 = ({ 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}