forked from akai/readest
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user