From ab7da981da24a750dc501e0d7d9ada9241d35a2e Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Sat, 11 Apr 2026 00:22:06 +0800 Subject: [PATCH] fix(eink): remove scroll animation in eink mode and optimize eink detection (#3822) * fix(eink): remove scroll animation in eink mode * fix(android): fix startup ANR on e-ink devices from getprop subprocesses --- Cargo.lock | 21 ++++++++++++++ apps/readest-app/src-tauri/Cargo.toml | 3 ++ .../readest-app/src-tauri/src/android/eink.rs | 29 +++++++------------ .../app/reader/components/sidebar/TOCView.tsx | 11 +++++-- .../src/app/reader/hooks/useScrollToItem.ts | 6 +++- .../src/services/settingsService.ts | 6 ++-- 6 files changed, 51 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65157fcf..197858f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ dependencies = [ "rand 0.8.5", "read-progress-stream", "reqwest 0.12.28", + "rsproperties", "serde", "serde_json", "tauri", @@ -5080,6 +5081,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty-hex" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a65843dfefbafd3c879c683306959a6de478443ffe9c9adf02f5976432402d7" + [[package]] name = "prettyplease" version = "0.2.37" @@ -5835,6 +5842,20 @@ dependencies = [ "byteorder", ] +[[package]] +name = "rsproperties" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98d55abde44982a00d67a84809166ea8f5c143abc86818f555485f19766cfe4" +dependencies = [ + "log", + "pretty-hex", + "rustix 1.1.4", + "thiserror 2.0.18", + "zerocopy", + "zerocopy-derive", +] + [[package]] name = "rust-ini" version = "0.21.3" diff --git a/apps/readest-app/src-tauri/Cargo.toml b/apps/readest-app/src-tauri/Cargo.toml index 0445a874..bdd9135b 100644 --- a/apps/readest-app/src-tauri/Cargo.toml +++ b/apps/readest-app/src-tauri/Cargo.toml @@ -78,3 +78,6 @@ tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" tauri-plugin-window-state = "2" discord-rich-presence = "1.0.0" + +[target.'cfg(target_os = "android")'.dependencies] +rsproperties = "0.3" diff --git a/apps/readest-app/src-tauri/src/android/eink.rs b/apps/readest-app/src-tauri/src/android/eink.rs index 4b886e5f..b806d72c 100644 --- a/apps/readest-app/src-tauri/src/android/eink.rs +++ b/apps/readest-app/src-tauri/src/android/eink.rs @@ -1,4 +1,4 @@ -use std::process::Command; +use std::sync::OnceLock; /// Known e-ink device manufacturers and brands (case-insensitive matching) const EINK_MANUFACTURERS: &[&str] = &[ @@ -45,28 +45,21 @@ const EINK_MODELS: &[&str] = &[ "max lumi", ]; -/// Get Android system property using getprop command fn get_system_property(prop: &str) -> Option { - Command::new("getprop") - .arg(prop) - .output() + rsproperties::get::(prop) .ok() - .and_then(|output| { - if output.status.success() { - let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if value.is_empty() { - None - } else { - Some(value) - } - } else { - None - } - }) + .filter(|s| !s.is_empty()) } -/// Check if the current Android device is an e-ink device +/// Check if the current Android device is an e-ink device. +/// +/// The result is cached on first call so subsequent calls are free. pub fn is_eink_device() -> bool { + static IS_EINK: OnceLock = OnceLock::new(); + *IS_EINK.get_or_init(detect_eink_device) +} + +fn detect_eink_device() -> bool { // Get device manufacturer and model let manufacturer = get_system_property("ro.product.manufacturer") .or_else(|| get_system_property("ro.product.brand")) diff --git a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx index 92d96735..b457a67b 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/TOCView.tsx @@ -43,9 +43,10 @@ const TOCView: React.FC<{ bookKey: string; toc: TOCItem[]; }> = ({ bookKey, toc }) => { - const { getView, getProgress } = useReaderStore(); + const { getView, getViewSettings, getProgress } = useReaderStore(); const { sideBarBookKey, isSideBarVisible } = useSidebarStore(); const progress = getProgress(bookKey); + const isEink = !!getViewSettings(bookKey)?.isEink; const [expandedItems, setExpandedItems] = useState>(() => computeExpandedSet(toc, progress?.sectionHref), @@ -131,14 +132,18 @@ const TOCView: React.FC<{ const timer = setTimeout(() => { const idx = flatItems.findIndex((f) => f.item.href === activeHref); if (idx !== -1) { + // Eink displays ghost previous frames during smooth JS scroll + // animations; force an instant jump to avoid the artifact. A CSS-only + // fix is impossible because scrollTo({ behavior: 'smooth' }) overrides + // CSS scroll-behavior and is not a CSS transition. const distance = Math.abs(idx - visibleCenterRef.current); - const behavior = distance > 16 ? 'auto' : 'smooth'; + const behavior = isEink || distance > 16 ? 'auto' : 'smooth'; virtuosoRef.current?.scrollToIndex({ index: idx, align: 'center', behavior }); } pendingScrollRef.current = false; }, 100); return () => clearTimeout(timer); - }, [flatItems, activeHref, isSideBarVisible]); + }, [flatItems, activeHref, isSideBarVisible, isEink]); return (
diff --git a/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts b/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts index d095d4d0..3cdf3a8e 100644 --- a/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts +++ b/apps/readest-app/src/app/reader/hooks/useScrollToItem.ts @@ -20,7 +20,11 @@ const useScrollToItem = ( const isVisible = rect.top >= 0 && rect.bottom <= window.innerHeight; if (!isVisible) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Eink displays ghost previous frames during smooth JS scroll animations; + // force an instant jump. scrollIntoView({ behavior: 'smooth' }) overrides + // CSS scroll-behavior, so a CSS-only fix via useEinkMode is impossible. + const isEink = document.documentElement.getAttribute('data-eink') === 'true'; + element.scrollIntoView({ behavior: isEink ? 'auto' : 'smooth', block: 'center' }); } if (isCurrent) { diff --git a/apps/readest-app/src/services/settingsService.ts b/apps/readest-app/src/services/settingsService.ts index 24fa7ab6..b780cb0f 100644 --- a/apps/readest-app/src/services/settingsService.ts +++ b/apps/readest-app/src/services/settingsService.ts @@ -39,13 +39,13 @@ export function getDefaultViewSettings(ctx: Context): ViewSettings { ...DEFAULT_BOOK_STYLE, ...DEFAULT_BOOK_FONT, ...DEFAULT_BOOK_LANGUAGE, - ...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), - ...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}), - ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), ...DEFAULT_VIEW_CONFIG, ...DEFAULT_TTS_CONFIG, ...DEFAULT_SCREEN_CONFIG, ...DEFAULT_ANNOTATOR_CONFIG, + ...(ctx.isMobile ? DEFAULT_MOBILE_VIEW_SETTINGS : {}), + ...(ctx.isEink ? DEFAULT_EINK_VIEW_SETTINGS : {}), + ...(isCJKEnv() ? DEFAULT_CJK_VIEW_SETTINGS : {}), ...{ ...DEFAULT_TRANSLATOR_CONFIG, translateTargetLang: getTargetLang() }, }; }