fix: stabilize reader navigation and release 2.33

This commit is contained in:
Курнат Андрей
2026-07-17 21:51:40 +03:00
parent c880395341
commit 2a1a53cf34
9 changed files with 164 additions and 79 deletions
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "com.aletheia.app"
minSdk = 24
targetSdk = 36
versionCode = 42
versionName = "2.31"
versionCode = 44
versionName = "2.33"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
+1
View File
@@ -27,6 +27,7 @@
<activity
android:name=".ui.reader.ReaderActivity"
android:configChanges="orientation|screenSize|smallestScreenSize"
android:exported="false"
android:theme="@style/Theme.Aletheia"
android:windowSoftInputMode="adjustResize" />
@@ -21,6 +21,7 @@
this.preparedDocuments = new WeakSet();
this.resizeTimer = null;
this.resizeObserver = null;
this.pendingResizeTarget = undefined;
}
async load(arrayBuffer, payload) {
@@ -139,7 +140,8 @@
getPreferences: this.host.getPreferences,
next: this.host.next,
previous: this.host.previous,
toggleControls: this.host.toggleControls
toggleControls: this.host.toggleControls,
nativeTapZones: true
});
if (!this.preparedDocuments.has(contents.document)) {
this.preparedDocuments.add(contents.document);
@@ -253,10 +255,14 @@
if (!global.ResizeObserver || this.resizeObserver) return;
const self = this;
this.resizeObserver = new ResizeObserver(function () {
if (self.pendingResizeTarget === undefined) {
self.pendingResizeTarget = self.currentCfi || null;
}
clearTimeout(self.resizeTimer);
self.resizeTimer = setTimeout(async function () {
if (!self.rendition || self.destroyed) return;
const target = self.currentCfi;
const target = self.pendingResizeTarget;
self.pendingResizeTarget = undefined;
try {
self.rendition.resize();
if (target) await self.rendition.display(target);
@@ -594,6 +600,7 @@
destroy() {
this.destroyed = true;
clearTimeout(this.resizeTimer);
this.pendingResizeTarget = undefined;
if (this.resizeObserver) this.resizeObserver.disconnect();
this.resizeObserver = null;
this.clearSearch(false);
+12 -4
View File
@@ -146,6 +146,7 @@
this.searchMarks = [];
this.resizeObserver = null;
this.resizeTimer = null;
this.pendingResizeLocator = undefined;
this.scrollFrame = 0;
this.selectionTimer = null;
this.detachGestures = null;
@@ -169,7 +170,8 @@
getPreferences: this.host.getPreferences,
next: this.host.next,
previous: this.host.previous,
toggleControls: this.host.toggleControls
toggleControls: this.host.toggleControls,
nativeTapZones: true
});
await Internal.waitForPreferredFont(document, this.host.getPreferences());
await this._layout(false);
@@ -487,9 +489,9 @@
return wrapper;
}
async _layout(preserve) {
async _layout(preserve, preservedLocator) {
const preferences = this.host.getPreferences();
const locator = preserve ? this.currentLocator() : null;
const locator = preserve ? (preservedLocator || this.currentLocator()) : null;
const progress = this.currentProgress;
const width = Math.max(1, this.mount.clientWidth);
const height = Math.max(1, this.mount.clientHeight);
@@ -534,9 +536,14 @@
if (!global.ResizeObserver || this.resizeObserver) return;
const self = this;
this.resizeObserver = new ResizeObserver(function () {
if (self.pendingResizeLocator === undefined) {
self.pendingResizeLocator = self.currentLocator();
}
clearTimeout(self.resizeTimer);
self.resizeTimer = setTimeout(function () {
if (!self.destroyed) self._layout(true).catch(function (error) {
const locator = self.pendingResizeLocator;
self.pendingResizeLocator = undefined;
if (!self.destroyed) self._layout(true, locator).catch(function (error) {
Internal.reportError(error, 'fb2.resize', true);
});
}, 120);
@@ -1051,6 +1058,7 @@
destroy() {
this.destroyed = true;
clearTimeout(this.resizeTimer);
this.pendingResizeLocator = undefined;
clearTimeout(this.selectionTimer);
if (this.scrollFrame) cancelAnimationFrame(this.scrollFrame);
if (this.resizeObserver) this.resizeObserver.disconnect();
+37 -11
View File
@@ -3,6 +3,17 @@
const Internal = global.ReaderV2Internal = global.ReaderV2Internal || {};
const attachedDocuments = new WeakMap();
const LEFT_ZONE_END = 1 / 3;
const RIGHT_ZONE_START = 2 / 3;
let lastHandledTouchAt = 0;
function viewportWidth(doc) {
const viewWidth = Number(doc && doc.defaultView && doc.defaultView.innerWidth);
if (Number.isFinite(viewWidth) && viewWidth > 0) return viewWidth;
const documentWidth = Number(doc && doc.documentElement && doc.documentElement.clientWidth);
if (Number.isFinite(documentWidth) && documentWidth > 0) return documentWidth;
return Math.max(1, Number(global.innerWidth) || 1);
}
function selectionText(doc) {
try {
@@ -39,7 +50,7 @@
startAt = Date.now();
startTarget = event.target;
moved = false;
const width = doc.documentElement.clientWidth || global.innerWidth;
const width = viewportWidth(doc);
brightnessCandidate = Boolean(preferences().brightnessGesture && startX <= width * .18);
}
@@ -55,16 +66,18 @@
}
function doTap(x, width) {
if (options.nativeTapZones) return false;
const current = preferences();
const mode = current.pageTurnMode || 'tapSwipe';
const normalized = x / Math.max(1, width);
if (normalized >= .28 && normalized <= .72) {
if (normalized >= LEFT_ZONE_END && normalized <= RIGHT_ZONE_START) {
options.toggleControls();
return;
return true;
}
if (mode !== 'tapSwipe' && mode !== 'tap') return;
if (normalized < .28) current.invertZones ? options.next() : options.previous();
if (mode !== 'tapSwipe' && mode !== 'tap') return false;
if (normalized < LEFT_ZONE_END) current.invertZones ? options.next() : options.previous();
else current.invertZones ? options.previous() : options.next();
return true;
}
function onTouchEnd(event) {
@@ -76,14 +89,14 @@
const horizontal = Math.abs(dx) > Math.abs(dy) * 1.25;
const current = preferences();
const mode = current.pageTurnMode || 'tapSwipe';
const viewportWidth = doc.documentElement.clientWidth || global.innerWidth;
const width = viewportWidth(doc);
const viewportHeight = doc.documentElement.clientHeight || global.innerHeight;
if (interactiveTarget(startTarget) || selectionText(doc)) return;
if (
current.brightnessGesture &&
startX <= viewportWidth * .18 &&
startX <= width * .18 &&
elapsed < 1200 &&
Math.abs(dy) >= 28 &&
Math.abs(dy) > Math.abs(dx) * 1.35
@@ -92,24 +105,37 @@
delta: Internal.clamp((-dy / Math.max(1, viewportHeight)) * 100, -100, 100)
});
consumedAt = Date.now();
lastHandledTouchAt = consumedAt;
event.preventDefault();
return;
}
if (elapsed < 650 && horizontal && Math.abs(dx) >= 48 && (mode === 'tapSwipe' || mode === 'swipe')) {
dx < 0 ? options.next() : options.previous();
consumedAt = Date.now();
lastHandledTouchAt = consumedAt;
event.preventDefault();
return;
}
if (elapsed < 300 && !moved && Math.abs(dx) < 12 && Math.abs(dy) < 12) {
doTap(touch.clientX, doc.documentElement.clientWidth || global.innerWidth);
if (doTap(touch.clientX, width)) {
consumedAt = Date.now();
lastHandledTouchAt = consumedAt;
event.preventDefault();
}
}
}
function onClick(event) {
if (Date.now() - consumedAt < 500 || interactiveTarget(event.target) || selectionText(doc)) return;
doTap(event.clientX, doc.documentElement.clientWidth || global.innerWidth);
const now = Date.now();
if (
now - consumedAt < 700 ||
now - lastHandledTouchAt < 700 ||
interactiveTarget(event.target) ||
selectionText(doc)
) return;
doTap(event.clientX, viewportWidth(doc));
}
function onKeyDown(event) {
@@ -127,7 +153,7 @@
doc.addEventListener('touchstart', onTouchStart, { passive: true });
doc.addEventListener('touchmove', onTouchMove, { passive: false });
doc.addEventListener('touchend', onTouchEnd, { passive: true });
doc.addEventListener('touchend', onTouchEnd, { passive: false });
doc.addEventListener('click', onClick, false);
doc.addEventListener('keydown', onKeyDown, false);
@@ -5,6 +5,7 @@ import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
@@ -24,9 +25,9 @@ import com.aletheia.app.ui.books.BookshelfFragment
import com.aletheia.app.ui.home.HomeFragment
import com.aletheia.app.ui.qbooks.QBooksLibraryFragment
import com.aletheia.app.ui.reader.ReaderActivity
import com.aletheia.app.ui.reader.ReaderHubFragment
import com.aletheia.app.ui.settings.SettingsFragment
import com.aletheia.app.util.setBookCover
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -57,7 +58,10 @@ class MainActivity : AppCompatActivity() {
if (savedInstanceState == null) {
selectOrOpenTab(resolveInitialTab(intent?.getStringExtra(EXTRA_OPEN_TAB)))
} else if (supportFragmentManager.fragments.none { !it.isHidden }) {
} else if (
supportFragmentManager.fragments.none { !it.isHidden } ||
supportFragmentManager.findFragmentByTag(LEGACY_TAG_READER)?.isHidden == false
) {
selectOrOpenTab(R.id.nav_home)
}
}
@@ -86,7 +90,10 @@ class MainActivity : AppCompatActivity() {
private fun openTab(itemId: Int): Boolean = when (itemId) {
R.id.nav_home -> showTab(TAG_HOME) { HomeFragment.newInstance() }
R.id.nav_search -> showTab(TAG_SEARCH) { QBooksLibraryFragment.newInstance() }
R.id.nav_reader -> showTab(TAG_READER) { ReaderHubFragment.newInstance() }
R.id.nav_reader -> {
openLastReadBook()
false
}
R.id.nav_my_books -> showTab(TAG_MY_BOOKS) { BookshelfFragment.newInstance() }
R.id.nav_profile -> showTab(TAG_PROFILE) { SettingsFragment.newInstance() }
else -> false
@@ -130,9 +137,7 @@ class MainActivity : AppCompatActivity() {
private fun refreshMiniReader() {
lifecycleScope.launch {
val book = withContext(Dispatchers.IO) {
app.bookRepository.getAllBooks().maxByOrNull { item -> item.lastRead }
}
val book = findLastReadableBook()
miniReaderBook = book
val visibility = if (book == null) View.GONE else View.VISIBLE
binding.miniReaderPanel.visibility = visibility
@@ -150,6 +155,36 @@ class MainActivity : AppCompatActivity() {
private fun openMiniReader() {
val book = miniReaderBook ?: return
openReader(book)
}
private fun openLastReadBook() {
if (supportFragmentManager.fragments.none { fragment -> !fragment.isHidden }) {
showTab(TAG_HOME) { HomeFragment.newInstance() }
}
lifecycleScope.launch {
val book = findLastReadableBook()
if (book == null) {
Toast.makeText(
this@MainActivity,
R.string.reader_no_book_selected,
Toast.LENGTH_SHORT
).show()
return@launch
}
miniReaderBook = book
openReader(book)
}
}
private suspend fun findLastReadableBook(): Book? = withContext(Dispatchers.IO) {
app.bookRepository.getAllBooks()
.asSequence()
.filter { book -> File(book.filePath).isFile }
.maxByOrNull { book -> book.lastRead }
}
private fun openReader(book: Book) {
startActivity(
Intent(this, ReaderActivity::class.java)
.putExtra(ReaderActivity.EXTRA_BOOK_ID, book.id)
@@ -203,7 +238,7 @@ class MainActivity : AppCompatActivity() {
const val EXTRA_OPEN_TAB = "open_tab"
private const val TAG_HOME = "home"
private const val TAG_SEARCH = "search"
private const val TAG_READER = "reader"
private const val LEGACY_TAG_READER = "reader"
private const val TAG_MY_BOOKS = "my-books"
private const val TAG_PROFILE = "profile"
}
@@ -5,6 +5,7 @@ import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
@@ -14,7 +15,6 @@ import android.graphics.drawable.StateListDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import android.view.KeyEvent
import android.view.Gravity
@@ -23,6 +23,7 @@ import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.WindowManager
import android.webkit.WebView
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
@@ -99,11 +100,9 @@ class ReaderActivity : AppCompatActivity() {
private var readerTouchDownX = 0f
private var readerTouchDownY = 0f
private var readerTouchDownAt = 0L
private var lastNativeControlsToggleAt = 0L
private val readerTapSlop by lazy(LazyThreadSafetyMode.NONE) {
ViewConfiguration.get(this).scaledTouchSlop.toFloat()
}
private var pageHeaderBasePadding = Insets()
private var controlsBasePadding = Insets()
private var settingsBasePadding = Insets()
@@ -159,6 +158,12 @@ class ReaderActivity : AppCompatActivity() {
applySystemUi()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ViewCompat.requestApplyInsets(binding.root)
applySystemUi()
}
override fun onSaveInstanceState(outState: Bundle) {
outState.putBoolean(STATE_FINISHING, finishingReader)
outState.putString(STATE_PREFERENCES, preferences.toStateJson())
@@ -196,11 +201,11 @@ class ReaderActivity : AppCompatActivity() {
if (bookReady && preferences.volumeButtons && !preferences.verticalScroll) {
when (keyCode) {
KeyEvent.KEYCODE_VOLUME_DOWN -> {
webController.next()
webController.previous()
return true
}
KeyEvent.KEYCODE_VOLUME_UP -> {
webController.previous()
webController.next()
return true
}
}
@@ -546,8 +551,8 @@ class ReaderActivity : AppCompatActivity() {
currentPage = book.currentPage.coerceAtLeast(1),
totalPages = book.totalPages.coerceAtLeast(1)
)
position = app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
?: restoredPosition
position = restoredPosition
?: app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
?: databasePosition
restoredPosition = null
publishPosition()
@@ -589,10 +594,7 @@ class ReaderActivity : AppCompatActivity() {
if (finishingReader) return
when (event) {
ReaderEvent.ShellReady -> binding.readerLoadingText.text = "Открываю книгу…"
ReaderEvent.ToggleControls -> {
val sinceNativeToggle = SystemClock.uptimeMillis() - lastNativeControlsToggleAt
if (sinceNativeToggle > NATIVE_TAP_DUPLICATE_WINDOW_MS) toggleControls()
}
ReaderEvent.ToggleControls -> toggleControls()
ReaderEvent.Navigation -> Unit
is ReaderEvent.BookReady -> {
bookReady = true
@@ -669,6 +671,14 @@ class ReaderActivity : AppCompatActivity() {
}
}
private fun toggleControls() {
if (binding.readerSettingsOverlay.isVisible || binding.readerContentsOverlay.isVisible ||
binding.readerSelectionOverlay.isVisible || binding.noteEditorOverlay.isVisible
) return
binding.readerControlsOverlay.isVisible = !binding.readerControlsOverlay.isVisible
renderChromeVisibility()
}
private fun observeNativeReaderTap(event: MotionEvent) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
@@ -694,36 +704,44 @@ class ReaderActivity : AppCompatActivity() {
}
MotionEvent.ACTION_UP -> {
val elapsed = event.eventTime - readerTouchDownAt
val normalizedX = event.x / binding.readerWebView.width.coerceAtLeast(1)
val modalVisible = binding.readerLoadingOverlay.isVisible ||
binding.readerSettingsOverlay.isVisible ||
binding.readerContentsOverlay.isVisible ||
binding.readerSelectionOverlay.isVisible ||
binding.noteEditorOverlay.isVisible
val shouldToggle = readerTouchActive &&
val hitType = binding.readerWebView.hitTestResult.type
val interactiveTarget = hitType == WebView.HitTestResult.SRC_ANCHOR_TYPE ||
hitType == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE ||
hitType == WebView.HitTestResult.EDIT_TEXT_TYPE ||
hitType == WebView.HitTestResult.EMAIL_TYPE ||
hitType == WebView.HitTestResult.PHONE_TYPE ||
hitType == WebView.HitTestResult.GEO_TYPE
val shouldHandle = readerTouchActive &&
!readerTouchMoved &&
elapsed in 0..NATIVE_TAP_MAX_DURATION_MS &&
normalizedX in READER_CENTER_ZONE_START..READER_CENTER_ZONE_END &&
bookReady &&
!finishingReader &&
currentSelection == null &&
!modalVisible
!modalVisible &&
!interactiveTarget
readerTouchActive = false
readerTouchMoved = false
if (shouldToggle) {
lastNativeControlsToggleAt = SystemClock.uptimeMillis()
toggleControls()
}
}
}
}
if (!shouldHandle) return
private fun toggleControls() {
if (binding.readerSettingsOverlay.isVisible || binding.readerContentsOverlay.isVisible ||
binding.readerSelectionOverlay.isVisible || binding.noteEditorOverlay.isVisible
) return
binding.readerControlsOverlay.isVisible = !binding.readerControlsOverlay.isVisible
renderChromeVisibility()
val normalizedX = event.x / binding.readerWebView.width.coerceAtLeast(1)
when {
normalizedX in READER_CENTER_ZONE_START..READER_CENTER_ZONE_END -> toggleControls()
preferences.pageTurnMode != ReaderPreferences.PAGE_TURN_TAP &&
preferences.pageTurnMode != ReaderPreferences.PAGE_TURN_TAP_SWIPE -> Unit
normalizedX < READER_CENTER_ZONE_START -> {
if (preferences.invertZones) webController.next() else webController.previous()
}
else -> {
if (preferences.invertZones) webController.previous() else webController.next()
}
}
}
}
}
private fun renderChromeVisibility() {
@@ -1523,17 +1541,7 @@ class ReaderActivity : AppCompatActivity() {
}
private fun handleBack() {
when {
binding.noteEditorOverlay.isVisible -> closeNoteEditor()
binding.readerSelectionOverlay.isVisible -> closeSelection()
binding.readerContentsOverlay.isVisible -> closeContents()
binding.readerSettingsOverlay.isVisible -> closeSettings()
binding.readerControlsOverlay.isVisible -> {
binding.readerControlsOverlay.isVisible = false
renderChromeVisibility()
}
else -> finishReader()
}
finishReader()
}
private fun openExternalLink(url: String) {
@@ -1719,9 +1727,8 @@ class ReaderActivity : AppCompatActivity() {
private const val PROGRESS_SAVE_DEBOUNCE_MS = 1_500L
private const val SETTINGS_SAVE_DEBOUNCE_MS = 180L
private const val NATIVE_TAP_MAX_DURATION_MS = 350L
private const val NATIVE_TAP_DUPLICATE_WINDOW_MS = 450L
private const val READER_CENTER_ZONE_START = 0.28f
private const val READER_CENTER_ZONE_END = 0.72f
private const val READER_CENTER_ZONE_START = 1f / 3f
private const val READER_CENTER_ZONE_END = 2f / 3f
private val FATAL_ERROR_STAGES = setOf(
"book.load",
"api.loadBook",
+12 -12
View File
@@ -629,13 +629,13 @@
android:focusable="true">
<ImageView
android:layout_width="60dp"
android:layout_height="32dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:background="@drawable/bg_reader_v2_control"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:padding="17dp"
android:padding="6dp"
android:src="@drawable/ic_reader_v2_minus"
android:tag="reader_v2_control" />
</FrameLayout>
@@ -650,13 +650,13 @@
android:focusable="true">
<ImageView
android:layout_width="60dp"
android:layout_height="32dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:background="@drawable/bg_reader_v2_control"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:padding="13dp"
android:padding="6dp"
android:src="@drawable/ic_reader_v2_plus"
android:tag="reader_v2_control" />
</FrameLayout>
@@ -832,13 +832,13 @@
android:focusable="true">
<ImageView
android:layout_width="60dp"
android:layout_height="32dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:background="@drawable/bg_reader_v2_control"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:padding="17dp"
android:padding="6dp"
android:src="@drawable/ic_reader_v2_minus"
android:tag="reader_v2_control" />
</FrameLayout>
@@ -853,13 +853,13 @@
android:focusable="true">
<ImageView
android:layout_width="60dp"
android:layout_height="32dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="center"
android:background="@drawable/bg_reader_v2_control"
android:contentDescription="@null"
android:importantForAccessibility="no"
android:padding="13dp"
android:padding="6dp"
android:src="@drawable/ic_reader_v2_plus"
android:tag="reader_v2_control" />
</FrameLayout>
+1
View File
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="reader_no_book_selected">Книга для чтения не выбрана</string>
<string name="app_name">Алетея</string>
<string name="tab_library">Мои книги</string>