فهرست منبع

Merge branch 'main' into fix/theme-switch

HapeLee 4 ماه پیش
والد
کامیت
1a7962ea4b
35فایلهای تغییر یافته به همراه669 افزوده شده و 305 حذف شده
  1. 0 4
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 7 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  3. 1 0
      app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt
  4. 17 6
      app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt
  5. 5 5
      app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt
  6. 0 24
      app/src/main/java/io/legado/app/help/config/AppConfig.kt
  7. 2 3
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceAdapter.kt
  8. 2 0
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt
  9. 10 9
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt
  10. 9 10
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt
  11. 2 0
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt
  12. 107 21
      app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt
  13. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  14. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  15. 21 16
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  16. 5 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  17. 78 44
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  18. 151 6
      app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt
  19. 19 115
      app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt
  20. 12 13
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt
  21. 1 1
      app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt
  22. 80 6
      app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt
  23. 5 0
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  24. 1 1
      app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt
  25. 41 0
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  26. 22 3
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  27. 3 0
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentResult.kt
  28. 1 0
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt
  29. 1 0
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt
  30. 55 17
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  31. 5 0
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  32. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  33. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  34. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  35. 1 0
      app/src/main/res/values/strings.xml

+ 0 - 4
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -82,10 +82,6 @@ object PreferKey {
     const val exportNoChapterName = "exportNoChapterName"
     const val exportType = "exportType"
     const val exportPictureFile = "exportPictureFile"
-    const val changeSourceCheckAuthor = "changeSourceCheckAuthor"
-    const val changeSourceLoadToc = "changeSourceLoadToc"
-    const val changeSourceLoadInfo = "changeSourceLoadInfo"
-    const val changeSourceLoadWordCount = "changeSourceLoadWordCount"
     const val chineseConverterType = "chineseConverterType"
     const val launcherIcon = "launcherIcon"
     const val textSelectAble = "selectText"

+ 7 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -24,4 +24,11 @@ object LocalPreferencesKeys {
     val PASSWORD = stringPreferencesKey("password")
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
+
+    // Change source options
+    val CHANGE_SOURCE_CHECK_AUTHOR = booleanPreferencesKey("changeSourceCheckAuthor")
+    val CHANGE_SOURCE_LOAD_INFO = booleanPreferencesKey("changeSourceLoadInfo")
+    val CHANGE_SOURCE_LOAD_TOC = booleanPreferencesKey("changeSourceLoadToc")
+    val CHANGE_SOURCE_LOAD_WORD_COUNT = booleanPreferencesKey("changeSourceLoadWordCount")
+    val CHANGE_SOURCE_SEARCH_SCOPE = stringPreferencesKey("changeSourceSearchScope")
 }

+ 1 - 0
app/src/main/java/io/legado/app/data/repository/SearchContentRepository.kt

@@ -122,6 +122,7 @@ class SearchContentRepository {
         matches.forEachIndexed { index, match ->
             val construct = getResultAndQueryIndex(mContent, match.position, match.length)
             val result = SearchResult(
+                bookUrl = book.bookUrl,
                 resultCountWithinChapter = index,
                 resultText = construct.second,
                 chapterTitle = chapter.title,

+ 17 - 6
app/src/main/java/io/legado/app/domain/model/BookSearchScope.kt

@@ -1,8 +1,8 @@
 package io.legado.app.domain.model
 
-import com.google.gson.GsonBuilder
-import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.splitNotBlank
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
 
 data class BookSearchScope(val raw: String) {
 
@@ -39,11 +39,13 @@ data class BookSearchScope(val raw: String) {
             get() = groups.isEmpty() && sources.isEmpty()
     }
 
+    @Serializable
     data class ScopeSourceItem(
         val name: String,
         val url: String,
     )
 
+    @Serializable
     private data class SerializedSearchScope(
         val type: String = "",
         val groups: List<String> = emptyList(),
@@ -57,7 +59,9 @@ data class BookSearchScope(val raw: String) {
             return if (selected.isEmpty()) {
                 ""
             } else {
-                scopeGson.toJson(SerializedSearchScope(type = TYPE_GROUP, groups = selected))
+                scopeJson.encodeToString(
+                    SerializedSearchScope(type = TYPE_GROUP, groups = selected)
+                )
             }
         }
 
@@ -66,7 +70,9 @@ data class BookSearchScope(val raw: String) {
             return if (selected.isEmpty()) {
                 ""
             } else {
-                scopeGson.toJson(SerializedSearchScope(type = TYPE_SOURCE, sources = selected))
+                scopeJson.encodeToString(
+                    SerializedSearchScope(type = TYPE_SOURCE, sources = selected)
+                )
             }
         }
 
@@ -84,7 +90,9 @@ data class BookSearchScope(val raw: String) {
             val json = raw.trim()
             if (!json.startsWith("{") || !json.endsWith("}")) return null
 
-            return scopeGson.fromJsonObject<SerializedSearchScope>(json).getOrNull()?.let { scope ->
+            return runCatching {
+                scopeJson.decodeFromString<SerializedSearchScope>(json)
+            }.getOrNull()?.let { scope ->
                 when (scope.type) {
                     TYPE_SOURCE -> ParsedSearchScope(
                         sources = scope.sources.filter { it.url.isNotBlank() }
@@ -124,7 +132,10 @@ data class BookSearchScope(val raw: String) {
 
         private const val TYPE_GROUP = "group"
         private const val TYPE_SOURCE = "source"
-        private val scopeGson = GsonBuilder().disableHtmlEscaping().create()
+        private val scopeJson = Json {
+            encodeDefaults = false
+            ignoreUnknownKeys = true
+        }
     }
 
 }

+ 5 - 5
app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt

@@ -9,7 +9,7 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.primaryStr
 import io.legado.app.help.book.releaseHtmlData
-import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.book.changesource.ChangeSourceConfig
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.ui.book.changesource.ObservableSourceConfig
@@ -135,10 +135,10 @@ class ChangeSourceSearchUseCase(
         fromReadBookActivity: Boolean,
         contentProcessor: ContentProcessor,
     ): List<SearchBook> {
-        val checkAuthor = AppConfig.changeSourceCheckAuthor
-        val loadInfo = AppConfig.changeSourceLoadInfo
-        val loadToc = AppConfig.changeSourceLoadToc
-        val loadWordCount = AppConfig.changeSourceLoadWordCount
+        val checkAuthor = ChangeSourceConfig.checkAuthor
+        val loadInfo = ChangeSourceConfig.loadInfo
+        val loadToc = ChangeSourceConfig.loadToc
+        val loadWordCount = ChangeSourceConfig.loadWordCount
 
         val resultBooks = WebBook.searchBookAwait(
             source, name,

+ 0 - 24
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -465,12 +465,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
             appCtx.putPrefBoolean(PreferKey.parallelExportBook, value)
         }
 
-    var changeSourceCheckAuthor: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.changeSourceCheckAuthor)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.changeSourceCheckAuthor, value)
-        }
-
     var ttsEngine: String?
         get() = io.legado.app.ui.config.readConfig.ReadConfig.ttsEngine
         set(value) {
@@ -504,24 +498,6 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     val autoChangeSource: Boolean
         get() = ReadConfig.autoChangeSource
 
-    var changeSourceLoadInfo: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadInfo)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.changeSourceLoadInfo, value)
-        }
-
-    var changeSourceLoadToc: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadToc)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.changeSourceLoadToc, value)
-        }
-
-    var changeSourceLoadWordCount: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.changeSourceLoadWordCount)
-        set(value) {
-            appCtx.putPrefBoolean(PreferKey.changeSourceLoadWordCount, value)
-        }
-
     var openBookInfoByClickTitle: Boolean
         get() = appCtx.getPrefBoolean(PreferKey.openBookInfoByClickTitle, true)
         set(value) {

+ 2 - 3
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceAdapter.kt

@@ -11,7 +11,6 @@ import io.legado.app.base.adapter.DiffRecyclerAdapter
 import io.legado.app.base.adapter.ItemViewHolder
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.databinding.ItemChangeSourceBinding
-import io.legado.app.help.config.AppConfig
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.utils.gone
 import io.legado.app.utils.invisible
@@ -91,13 +90,13 @@ class ChangeBookSourceAdapter(
                 binding.ivGood.setImageResource(R.drawable.ic_praise)
             }
 
-            if (AppConfig.changeSourceLoadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
+            if (ChangeSourceConfig.loadWordCount && !item.chapterWordCountText.isNullOrBlank()) {
                 tvCurrentChapterWordCount.visible()
             } else {
                 tvCurrentChapterWordCount.gone()
             }
 
-            if (AppConfig.changeSourceLoadWordCount && item.respondTime >= 0) {
+            if (ChangeSourceConfig.loadWordCount && item.respondTime >= 0) {
                 tvRespondTime.visible()
             } else {
                 tvRespondTime.gone()

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -227,11 +227,13 @@ class ChangeBookSourceComposeViewModel(
     fun onLoadInfoChange(enabled: Boolean) {
         if (ChangeSourceConfig.loadInfo == enabled) return
         ChangeSourceConfig.loadInfo = enabled
+        refresh()
     }
 
     fun onLoadTocChange(enabled: Boolean) {
         if (ChangeSourceConfig.loadToc == enabled) return
         ChangeSourceConfig.loadToc = enabled
+        refresh()
     }
 
     fun onLoadWordCountChange(enabled: Boolean) {

+ 10 - 9
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceDialog.kt

@@ -29,7 +29,6 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.databinding.DialogBookChangeSourceBinding
-import io.legado.app.help.config.AppConfig
 import io.legado.app.lib.dialogs.alert
 import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.search.SearchScope
@@ -133,13 +132,13 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
         binding.toolBar.menu.applyTint(requireContext())
         binding.toolBar.setOnMenuItemClickListener(this)
         binding.toolBar.menu.findItem(R.id.menu_check_author)
-            ?.isChecked = AppConfig.changeSourceCheckAuthor
+            ?.isChecked = ChangeSourceConfig.checkAuthor
         binding.toolBar.menu.findItem(R.id.menu_load_info)
-            ?.isChecked = AppConfig.changeSourceLoadInfo
+            ?.isChecked = ChangeSourceConfig.loadInfo
         binding.toolBar.menu.findItem(R.id.menu_load_toc)
-            ?.isChecked = AppConfig.changeSourceLoadToc
+            ?.isChecked = ChangeSourceConfig.loadToc
         binding.toolBar.menu.findItem(R.id.menu_load_word_count)
-            ?.isChecked = AppConfig.changeSourceLoadWordCount
+            ?.isChecked = ChangeSourceConfig.loadWordCount
     }
 
     private fun initRecyclerView() {
@@ -316,23 +315,25 @@ class ChangeBookSourceDialog() : BaseBottomSheetDialogFragment(R.layout.dialog_b
     override fun onMenuItemClick(item: MenuItem?): Boolean {
         when (item?.itemId) {
             R.id.menu_check_author -> {
-                AppConfig.changeSourceCheckAuthor = !item.isChecked
+                ChangeSourceConfig.checkAuthor = !item.isChecked
                 item.isChecked = !item.isChecked
                 viewModel.refresh()
             }
 
             R.id.menu_load_info -> {
-                AppConfig.changeSourceLoadInfo = !item.isChecked
+                ChangeSourceConfig.loadInfo = !item.isChecked
                 item.isChecked = !item.isChecked
+                viewModel.refresh()
             }
 
             R.id.menu_load_toc -> {
-                AppConfig.changeSourceLoadToc = !item.isChecked
+                ChangeSourceConfig.loadToc = !item.isChecked
                 item.isChecked = !item.isChecked
+                viewModel.refresh()
             }
 
             R.id.menu_load_word_count -> {
-                AppConfig.changeSourceLoadWordCount = !item.isChecked
+                ChangeSourceConfig.loadWordCount = !item.isChecked
                 item.isChecked = !item.isChecked
                 viewModel.onLoadWordCountChecked(item.isChecked)
             }

+ 9 - 10
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceViewModel.kt

@@ -19,7 +19,6 @@ import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.book.primaryStr
 import io.legado.app.help.book.releaseHtmlData
-import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.SourceConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.source.SourceHelp
@@ -137,7 +136,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         }
     }.map {
         kotlin.runCatching {
-            val comparator = if (AppConfig.changeSourceLoadWordCount) {
+            val comparator = if (ChangeSourceConfig.loadWordCount) {
                 wordCountComparator
             } else {
                 defaultComparator
@@ -269,10 +268,10 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
     }
 
     private suspend fun search(source: BookSource) {
-        val checkAuthor = AppConfig.changeSourceCheckAuthor
-        val loadInfo = AppConfig.changeSourceLoadInfo
-        val loadToc = AppConfig.changeSourceLoadToc
-        val loadWordCount = AppConfig.changeSourceLoadWordCount
+        val checkAuthor = ChangeSourceConfig.checkAuthor
+        val loadInfo = ChangeSourceConfig.loadInfo
+        val loadToc = ChangeSourceConfig.loadToc
+        val loadWordCount = ChangeSourceConfig.loadWordCount
         val resultBooks = WebBook.searchBookAwait(
             source, name,
             filter = { fName, fAuthor, _ ->
@@ -295,7 +294,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         if (book.tocUrl.isEmpty()) {
             WebBook.getBookInfoAwait(source, book)
         }
-        if (AppConfig.changeSourceLoadToc || AppConfig.changeSourceLoadWordCount) {
+        if (ChangeSourceConfig.loadToc || ChangeSourceConfig.loadWordCount) {
             loadBookToc(source, book)
         } else {
             //从详情页里获取最新章节
@@ -315,7 +314,7 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
         }
         bookMap[book.primaryStr()] = book
         book.releaseHtmlData()
-        if (AppConfig.changeSourceLoadWordCount) {
+        if (ChangeSourceConfig.loadWordCount) {
             loadBookWordCount(source, book, chapters)
         } else {
             val searchBook = book.toSearchBook()
@@ -418,13 +417,13 @@ open class ChangeBookSourceViewModel(application: Application) : BaseViewModel(a
             else -> searchScope.displayNames.firstOrNull() ?: ""
         }
         return if (screenKey.isEmpty()) {
-            if (AppConfig.changeSourceCheckAuthor) {
+            if (ChangeSourceConfig.checkAuthor) {
                 appDb.searchBookDao.changeSourceByGroup(name, author, group)
             } else {
                 appDb.searchBookDao.changeSourceByGroup(name, "", group)
             }
         } else {
-            if (AppConfig.changeSourceCheckAuthor) {
+            if (ChangeSourceConfig.checkAuthor) {
                 appDb.searchBookDao.changeSourceSearch(name, author, screenKey, group)
             } else {
                 appDb.searchBookDao.changeSourceSearch(name, "", screenKey, group)

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/changesource/ChangeChapterSourceViewModel.kt

@@ -133,11 +133,13 @@ class ChangeChapterSourceViewModel(
             is ChangeChapterSourceIntent.SetLoadInfo -> {
                 ChangeSourceConfig.loadInfo = intent.enabled
                 _uiState.update { it.copy(loadInfo = intent.enabled) }
+                refreshResults()
             }
 
             is ChangeChapterSourceIntent.SetLoadToc -> {
                 ChangeSourceConfig.loadToc = intent.enabled
                 _uiState.update { it.copy(loadToc = intent.enabled) }
+                refreshResults()
             }
 
             is ChangeChapterSourceIntent.SetLoadWordCount -> {

+ 107 - 21
app/src/main/java/io/legado/app/ui/book/changesource/ChangeSourceConfig.kt

@@ -1,35 +1,83 @@
 package io.legado.app.ui.book.changesource
 
-import io.legado.app.constant.PreferKey
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.snapshots.Snapshot
+import io.legado.app.data.local.preferences.LocalPreferencesKeys
+import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.domain.usecase.ChangeSourceMigrationOptions
 import io.legado.app.ui.config.prefDelegate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import splitties.init.appCtx
 
 object ChangeSourceConfig {
 
-    var searchScope by prefDelegate(
-        key = "changeSourceSearchScope",
-        defaultValue = ""
-    )
+    private val repo = LocalPreferencesRepository(appCtx)
+    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
 
-    var checkAuthor by prefDelegate(
-        key = PreferKey.changeSourceCheckAuthor,
-        defaultValue = false
-    )
+    private val _searchScope = mutableStateOf("")
+    var searchScope: String
+        get() = _searchScope.value
+        set(value) {
+            if (_searchScope.value != value) {
+                Snapshot.withMutableSnapshot { _searchScope.value = value }
+                scope.launch {
+                    repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, value)
+                }
+            }
+        }
 
-    var loadInfo by prefDelegate(
-        key = PreferKey.changeSourceLoadInfo,
-        defaultValue = false
-    )
+    private val _checkAuthor = mutableStateOf(false)
+    var checkAuthor: Boolean
+        get() = _checkAuthor.value
+        set(value) {
+            if (_checkAuthor.value != value) {
+                Snapshot.withMutableSnapshot { _checkAuthor.value = value }
+                scope.launch {
+                    repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, value)
+                }
+            }
+        }
 
-    var loadToc by prefDelegate(
-        key = PreferKey.changeSourceLoadToc,
-        defaultValue = false
-    )
+    private val _loadInfo = mutableStateOf(false)
+    var loadInfo: Boolean
+        get() = _loadInfo.value
+        set(value) {
+            if (_loadInfo.value != value) {
+                Snapshot.withMutableSnapshot { _loadInfo.value = value }
+                scope.launch {
+                    repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, value)
+                }
+            }
+        }
 
-    var loadWordCount by prefDelegate(
-        key = PreferKey.changeSourceLoadWordCount,
-        defaultValue = false
-    )
+    private val _loadToc = mutableStateOf(false)
+    var loadToc: Boolean
+        get() = _loadToc.value
+        set(value) {
+            if (_loadToc.value != value) {
+                Snapshot.withMutableSnapshot { _loadToc.value = value }
+                scope.launch {
+                    repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, value)
+                }
+            }
+        }
+
+    private val _loadWordCount = mutableStateOf(false)
+    var loadWordCount: Boolean
+        get() = _loadWordCount.value
+        set(value) {
+            if (_loadWordCount.value != value) {
+                Snapshot.withMutableSnapshot { _loadWordCount.value = value }
+                scope.launch {
+                    repo.updatePreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, value)
+                }
+            }
+        }
 
     var migrateChapters by prefDelegate(
         key = "migrateChapters",
@@ -64,6 +112,44 @@ object ChangeSourceConfig {
         defaultValue = false
     )
 
+    init {
+        runBlocking(Dispatchers.IO) {
+            Snapshot.withMutableSnapshot {
+                _searchScope.value =
+                    repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, "").first()
+                _checkAuthor.value =
+                    repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, false).first()
+                _loadInfo.value =
+                    repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, false).first()
+                _loadToc.value =
+                    repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, false).first()
+                _loadWordCount.value =
+                    repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, false).first()
+            }
+        }
+
+        scope.launch {
+            repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_SEARCH_SCOPE, "")
+                .collect { Snapshot.withMutableSnapshot { _searchScope.value = it } }
+        }
+        scope.launch {
+            repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_CHECK_AUTHOR, false)
+                .collect { Snapshot.withMutableSnapshot { _checkAuthor.value = it } }
+        }
+        scope.launch {
+            repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_INFO, false)
+                .collect { Snapshot.withMutableSnapshot { _loadInfo.value = it } }
+        }
+        scope.launch {
+            repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_TOC, false)
+                .collect { Snapshot.withMutableSnapshot { _loadToc.value = it } }
+        }
+        scope.launch {
+            repo.getPreference(LocalPreferencesKeys.CHANGE_SOURCE_LOAD_WORD_COUNT, false)
+                .collect { Snapshot.withMutableSnapshot { _loadWordCount.value = it } }
+        }
+    }
+
     fun getMigrationOptions(): ChangeSourceMigrationOptions {
         return ChangeSourceMigrationOptions(
             migrateChapters = migrateChapters,

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -40,6 +40,7 @@ sealed interface ReadBookMenuRoute {
     data object ReadAloud : ReadBookMenuRoute
     data object AutoRead : ReadBookMenuRoute
     data object PaddingConfig : ReadBookMenuRoute
+    data object HeaderFooterConfig : ReadBookMenuRoute
     data class Bookmark(val bookmark: io.legado.app.data.entities.Bookmark) : ReadBookMenuRoute
 }
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt

@@ -717,7 +717,7 @@ class ReadBookController(
                     // Chapter not loaded — open it, then mark in the success callback
                     ReadBook.openChapter(
                         effect.chapterIndex,
-                        effect.result.queryIndexInChapter
+                        0
                     ) {
                         val tc = ReadBook.curTextChapter ?: return@openChapter
                         val query = effect.result.query

+ 21 - 16
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -139,6 +139,7 @@ import io.legado.app.help.config.ReadStyleResolver
 import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.animation.InteractiveHighlight
 import io.legado.app.ui.book.read.sheet.AutoReadContent
+import io.legado.app.ui.book.read.sheet.HeaderFooterPage
 import io.legado.app.ui.book.read.sheet.PaddingConfigContent
 import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
@@ -186,7 +187,7 @@ fun ReadBookMenuBar(
     } else {
         ReadBookMenuContent.Route(currentRoute)
     }
-    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig
+    val dialogLikeRoute = currentRoute == ReadBookMenuRoute.PaddingConfig || currentRoute == ReadBookMenuRoute.HeaderFooterConfig
     var readStylePage by remember { mutableIntStateOf(0) }
     LaunchedEffect(currentRoute) {
         if (currentRoute != ReadBookMenuRoute.ReadStyle) {
@@ -392,7 +393,7 @@ private fun ReadBookMenuSurface(
         is ReadBookMenuContent.Route -> contentTarget.route
     }
     val expanded = route != ReadBookMenuRoute.Main
-    val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig
+    val dialogLikeRoute = route == ReadBookMenuRoute.PaddingConfig || route == ReadBookMenuRoute.HeaderFooterConfig
     val density = LocalDensity.current
     val windowSize = LocalWindowInfo.current.containerSize
     var surfaceHeightPx by remember { mutableIntStateOf(0) }
@@ -400,18 +401,6 @@ private fun ReadBookMenuSurface(
         targetValue = if (dialogLikeRoute) 1f else 0f,
         label = "ReadBookMenuMorph",
     )
-    val headerFooterLift by animateDpAsState(
-        targetValue = if (route == ReadBookMenuRoute.ReadStyle && readStylePage == 2) {
-            72.dp
-        } else {
-            0.dp
-        },
-        animationSpec = tween(
-            durationMillis = 280,
-            easing = LinearOutSlowInEasing,
-        ),
-        label = "ReadBookMenuHeaderFooterLift",
-    )
     val maxHeight = with(density) {
         windowSize.height.toDp() * 0.64f
     }
@@ -518,8 +507,7 @@ private fun ReadBookMenuSurface(
             .onSizeChanged { surfaceHeightPx = it.height }
             .offset {
                 val dialogLiftPx = ((windowSize.height - surfaceHeightPx) / 2f) * morphProgress
-                val liftPx = dialogLiftPx + headerFooterLift.toPx()
-                IntOffset(x = 0, y = -liftPx.roundToInt())
+                IntOffset(x = 0, y = -dialogLiftPx.roundToInt())
             }
             .then(
                 if (useLiquidGlass) {
@@ -620,6 +608,9 @@ private fun ReadBookMenuSurface(
                                 onOpenPaddingConfig = {
                                     onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.PaddingConfig))
                                 },
+                                onOpenHeaderFooterConfig = {
+                                    onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.HeaderFooterConfig))
+                                },
                                 onOpenMoreConfig = {
                                     onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.MoreConfig))
                                 },
@@ -659,6 +650,20 @@ private fun ReadBookMenuSurface(
                         }
                     }
 
+                    ReadBookMenuRoute.HeaderFooterConfig -> {
+                        ReadBookMenuRoutePage(
+                            title = stringResource(R.string.header_footer),
+                            maxHeight = maxHeight,
+                            scrollContent = false,
+                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
+                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
+                        ) {
+                            HeaderFooterPage(
+                                onIntent = onIntent,
+                            )
+                        }
+                    }
+
                     ReadBookMenuRoute.TextTitle -> {
                         ReadBookMenuRoutePage(
                             title = stringResource(R.string.read_config_text_effects),

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -31,6 +31,7 @@ import io.legado.app.R
 import io.legado.app.constant.AppLog
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentHelp
+import io.legado.app.model.ReadBook
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
@@ -383,6 +384,10 @@ fun ReadBookRouteScreen(
         lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
             SearchContentResult.results.collect { result ->
                 effectsReady.await()
+                if (result.bookUrl != ReadBook.book?.bookUrl) {
+                    SearchContentResult.resetReplayCache()
+                    return@collect
+                }
                 viewModel.onIntent(
                     ReadBookIntent.SetSearchResults(result.searchResults, result.index, result.query)
                 )

+ 78 - 44
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -2241,50 +2241,21 @@ class ReadBookViewModel(
             return arrayOf(-1, 0, 0, 0, 0, 0)
         }
 
-        var pageIndex = 0
-        var length = pages[pageIndex].text.length
-        while (length < contentPosition && pageIndex + 1 < pages.size) {
-            pageIndex += 1
-            length += pages[pageIndex].text.length
-        }
-        if (length < contentPosition) return arrayOf(-1, 0, 0, 0, 0, 0)
-
-        val currentPage = pages[pageIndex]
-        val lines = currentPage.lines
-        if (lines.isEmpty()) return arrayOf(-1, 0, 0, 0, 0, 0)
-
-        var lineIndex = 0
-        var currentLine = lines[lineIndex]
-        length = length - currentPage.text.length + currentLine.text.length
-        if (currentLine.isParagraphEnd) length++
-        while (length <= contentPosition && lineIndex + 1 < lines.size) {
-            lineIndex += 1
-            currentLine = lines[lineIndex]
-            length += currentLine.text.length
-            if (currentLine.isParagraphEnd) length++
-        }
-
-        var currentLineLength = currentLine.text.length
-        if (currentLine.isParagraphEnd) currentLineLength++
-        length -= currentLineLength
-
-        val charIndex = contentPosition - length
-        var addLine = 0
-        var charIndex2 = 0
-        if ((charIndex + queryLength) > currentLineLength) {
-            addLine = 1
-            charIndex2 = charIndex + queryLength - currentLineLength - 1
-        }
-        if ((lineIndex + addLine + 1) > currentPage.lines.size) {
-            addLine = -1
-            charIndex2 = charIndex + queryLength - currentLineLength - 1
-        }
-        return when (addLine) {
-            0 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex + queryLength - 1)
-            1 -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex + 1, charIndex2)
-            -1 -> arrayOf(pageIndex, lineIndex, charIndex, 1, 0, charIndex2)
-            else -> arrayOf(pageIndex, lineIndex, charIndex, 0, lineIndex, charIndex)
-        }
+        val start = findSearchTextPoint(pages, contentPosition, preferPreviousLine = false)
+            ?: return arrayOf(-1, 0, 0, 0, 0, 0)
+        val end = findSearchTextPoint(
+            pages,
+            contentPosition + queryLength - 1,
+            preferPreviousLine = true
+        ) ?: start
+        return arrayOf(
+            start.pageIndex,
+            start.lineIndex,
+            start.charIndex,
+            end.pageIndex - start.pageIndex,
+            end.lineIndex,
+            end.charIndex
+        )
     }
 
     private fun findSearchResultMatch(
@@ -2293,6 +2264,26 @@ class ReadBookViewModel(
         query: String,
     ): Pair<Int, Int>? {
         if (query.isEmpty()) return null
+        val directLength = if (searchResult.matchLength > 0) searchResult.matchLength else query.length
+        val directIndex = searchResult.queryIndexInChapter
+        if (directIndex >= 0 && directIndex + directLength <= content.length) {
+            val directMatch = if (searchResult.isRegex) {
+                runCatching {
+                    Regex(query).matches(content.substring(directIndex, directIndex + directLength))
+                }.getOrDefault(false)
+            } else {
+                content.regionMatches(
+                    directIndex,
+                    query,
+                    0,
+                    query.length,
+                    ignoreCase = false
+                )
+            }
+            if (directMatch) {
+                return directIndex to directLength
+            }
+        }
         if (searchResult.isRegex) {
             return runCatching {
                 Regex(query).findAll(content)
@@ -2311,6 +2302,43 @@ class ReadBookViewModel(
         return index.takeIf { it >= 0 }?.let { it to query.length }
     }
 
+    private fun findSearchTextPoint(
+        pages: List<TextPage>,
+        contentPosition: Int,
+        preferPreviousLine: Boolean,
+    ): SearchTextPoint? {
+        var fallback: SearchTextPoint? = null
+        pages.forEachIndexed { pageIndex, page ->
+            page.lines.forEachIndexed { lineIndex, line ->
+                if (line.columns.isEmpty()) return@forEachIndexed
+                val lineStart = line.chapterPosition
+                val lineEndExclusive = lineStart + line.charSize
+                if (contentPosition in lineStart until lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = (contentPosition - lineStart).coerceIn(0, line.columns.lastIndex)
+                    )
+                }
+                if (preferPreviousLine && line.isParagraphEnd && contentPosition == lineEndExclusive) {
+                    return SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+                if (contentPosition >= lineEndExclusive) {
+                    fallback = SearchTextPoint(
+                        pageIndex = pageIndex,
+                        lineIndex = lineIndex,
+                        charIndex = line.columns.lastIndex
+                    )
+                }
+            }
+        }
+        return fallback
+    }
+
     /**
      * Compute the search result position and emit [ReadBookEffect.NavigateToSearchResult]
      * so the Controller can navigate and highlight.
@@ -3572,6 +3600,12 @@ private const val TOOL_BUTTON_PREFS = "tool_button_config"
 private const val TOOL_BUTTON_KEY = "tool_buttons"
 private const val DEFAULT_ENABLED_BUTTON_COUNT = 5
 
+private data class SearchTextPoint(
+    val pageIndex: Int,
+    val lineIndex: Int,
+    val charIndex: Int,
+)
+
 private fun Int.coerceSearchResultIndex(resultSize: Int): Int {
     return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1)
 }

+ 151 - 6
app/src/main/java/io/legado/app/ui/book/read/sheet/HeaderFooterPage.kt

@@ -1,25 +1,35 @@
 package io.legado.app.ui.book.read.sheet
 
+import androidx.compose.animation.AnimatedVisibility
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.ExpandMore
 import androidx.compose.material.icons.filled.TextFields
+import androidx.compose.material3.Icon
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.derivedStateOf
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableIntStateOf
 import androidx.compose.runtime.mutableStateMapOf
 import androidx.compose.runtime.mutableStateOf
@@ -32,11 +42,9 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clipToBounds
 import androidx.compose.ui.layout.onSizeChanged
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -46,6 +54,7 @@ import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.dialog.ColorPickerSheet
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
@@ -106,6 +115,42 @@ internal fun HeaderFooterPage(
     var colorPickerInitial by remember { mutableIntStateOf(0) }
     var showFontSelect by remember { mutableStateOf(false) }
 
+    var expandHeaderPadding by remember { mutableStateOf(false) }
+    var expandFooterPadding by remember { mutableStateOf(false) }
+
+    val headerScrollState = rememberScrollState()
+    val footerScrollState = rememberScrollState()
+
+    LaunchedEffect(expandHeaderPadding, headerScrollState.maxValue) {
+        if (expandHeaderPadding) {
+            headerScrollState.scrollTo(headerScrollState.maxValue)
+        }
+    }
+
+    LaunchedEffect(expandFooterPadding, footerScrollState.maxValue) {
+        if (expandFooterPadding) {
+            footerScrollState.scrollTo(footerScrollState.maxValue)
+        }
+    }
+
+    DisposableEffect(Unit) {
+        onDispose {
+            onIntent(ReadBookIntent.SaveReadStyleConfig)
+        }
+    }
+
+    // Header padding state
+    var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
+    var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
+    var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
+    var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
+
+    // Footer padding state
+    var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
+    var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
+    var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
+    var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
+
     val tipNames = remember { ReadBookConfig.tipNames }
     val tipValues = remember { ReadBookConfig.tipValues }
 
@@ -153,7 +198,7 @@ internal fun HeaderFooterPage(
                 scope.launch {
                     pagerState.animateScrollToPage(
                         page = index,
-                        animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
+                        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
                     )
                 }
             },
@@ -181,7 +226,7 @@ internal fun HeaderFooterPage(
                         modifier = Modifier
                             .fillMaxWidth()
                             .padding(horizontal = 16.dp)
-                            .verticalScroll(rememberScrollState()),
+                            .verticalScroll(headerScrollState),
                     ) {
                         TinySwitchSettingItem(
                             title = stringResource(R.string.showLine),
@@ -252,6 +297,56 @@ internal fun HeaderFooterPage(
                                 showColorPicker = true
                             },
                         )
+                        Spacer(Modifier.height(8.dp))
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.padding),
+                            description = stringResource(
+                                R.string.padding_format,
+                                headerPaddingTop.toInt(),
+                                headerPaddingBottom.toInt(),
+                                headerPaddingLeft.toInt(),
+                                headerPaddingRight.toInt(),
+                            ),
+                            trailingContent = {
+                                Icon(
+                                    imageVector = if (expandHeaderPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
+                                    contentDescription = null,
+                                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                                    modifier = Modifier.size(20.dp),
+                                )
+                            },
+                            onClick = {
+                                expandHeaderPadding = !expandHeaderPadding
+                            },
+                        )
+                        AnimatedVisibility(
+                            visible = expandHeaderPadding,
+                            enter = expandVertically() + fadeIn(),
+                            exit = shrinkVertically() + fadeOut(),
+                        ) {
+                            Column(modifier = Modifier.fillMaxWidth()) {
+                                PaddingSliders(
+                                    top = headerPaddingTop, bottom = headerPaddingBottom,
+                                    left = headerPaddingLeft, right = headerPaddingRight,
+                                    onTopChange = {
+                                        headerPaddingTop = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
+                                    },
+                                    onBottomChange = {
+                                        headerPaddingBottom = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
+                                    },
+                                    onLeftChange = {
+                                        headerPaddingLeft = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
+                                    },
+                                    onRightChange = {
+                                        headerPaddingRight = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
+                                    },
+                                )
+                            }
+                        }
                     }
                 }
 
@@ -261,7 +356,7 @@ internal fun HeaderFooterPage(
                         modifier = Modifier
                             .fillMaxWidth()
                             .padding(horizontal = 16.dp)
-                            .verticalScroll(rememberScrollState()),
+                            .verticalScroll(footerScrollState),
                     ) {
                         TinySwitchSettingItem(
                             title = stringResource(R.string.showLine),
@@ -332,6 +427,56 @@ internal fun HeaderFooterPage(
                                 showColorPicker = true
                             },
                         )
+                        Spacer(Modifier.height(8.dp))
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.padding),
+                            description = stringResource(
+                                R.string.padding_format,
+                                footerPaddingTop.toInt(),
+                                footerPaddingBottom.toInt(),
+                                footerPaddingLeft.toInt(),
+                                footerPaddingRight.toInt(),
+                            ),
+                            trailingContent = {
+                                Icon(
+                                    imageVector = if (expandFooterPadding) Icons.Default.ExpandMore else Icons.Default.ChevronRight,
+                                    contentDescription = null,
+                                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                                    modifier = Modifier.size(20.dp),
+                                )
+                            },
+                            onClick = {
+                                expandFooterPadding = !expandFooterPadding
+                            },
+                        )
+                        AnimatedVisibility(
+                            visible = expandFooterPadding,
+                            enter = expandVertically() + fadeIn(),
+                            exit = shrinkVertically() + fadeOut(),
+                        ) {
+                            Column(modifier = Modifier.fillMaxWidth()) {
+                                PaddingSliders(
+                                    top = footerPaddingTop, bottom = footerPaddingBottom,
+                                    left = footerPaddingLeft, right = footerPaddingRight,
+                                    onTopChange = {
+                                        footerPaddingTop = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
+                                    },
+                                    onBottomChange = {
+                                        footerPaddingBottom = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
+                                    },
+                                    onLeftChange = {
+                                        footerPaddingLeft = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
+                                    },
+                                    onRightChange = {
+                                        footerPaddingRight = it
+                                        onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
+                                    },
+                                )
+                            }
+                        }
                     }
                 }
 

+ 19 - 115
app/src/main/java/io/legado/app/ui/book/read/sheet/PaddingConfigSheet.kt

@@ -3,18 +3,11 @@ package io.legado.app.ui.book.read.sheet
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.pager.HorizontalPager
-import androidx.compose.foundation.pager.rememberPagerState
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableIntStateOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
-import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
-import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
@@ -24,8 +17,6 @@ import io.legado.app.ui.book.read.ConfigUpdate
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
-import io.legado.app.ui.widget.components.tabRow.CardTabRow
-import kotlinx.coroutines.launch
 
 @Composable
 fun PaddingConfigSheet(
@@ -58,122 +49,35 @@ fun PaddingConfigContent(
     var paddingBottom by remember { mutableFloatStateOf(ReadBookConfig.paddingBottom.toFloat()) }
     var paddingLeft by remember { mutableFloatStateOf(ReadBookConfig.paddingLeft.toFloat()) }
     var paddingRight by remember { mutableFloatStateOf(ReadBookConfig.paddingRight.toFloat()) }
-    // Header padding
-    var headerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingTop.toFloat()) }
-    var headerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingBottom.toFloat()) }
-    var headerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingLeft.toFloat()) }
-    var headerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.headerPaddingRight.toFloat()) }
-    // Footer padding
-    var footerPaddingTop by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingTop.toFloat()) }
-    var footerPaddingBottom by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingBottom.toFloat()) }
-    var footerPaddingLeft by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingLeft.toFloat()) }
-    var footerPaddingRight by remember { mutableFloatStateOf(ReadBookConfig.footerPaddingRight.toFloat()) }
-
-    val scope = rememberCoroutineScope()
-    val tabTitles = listOf(
-        stringResource(R.string.header),
-        stringResource(R.string.main_body),
-        stringResource(R.string.footer),
-    )
-    val pagerState = rememberPagerState(pageCount = { 3 })
-    var selectedTab by remember { mutableIntStateOf(0) }
-
-    LaunchedEffect(pagerState) {
-        snapshotFlow { pagerState.settledPage }.collect { selectedTab = it }
-    }
 
     Column(
         modifier = modifier.fillMaxWidth(),
     ) {
-        CardTabRow(
-            tabTitles = tabTitles,
-            selectedTabIndex = selectedTab,
-            onTabSelected = { index ->
-                selectedTab = index
-                scope.launch { pagerState.animateScrollToPage(index) }
+        PaddingSliders(
+            top = paddingTop, bottom = paddingBottom,
+            left = paddingLeft, right = paddingRight,
+            onTopChange = {
+                paddingTop = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
+            },
+            onBottomChange = {
+                paddingBottom = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
+            },
+            onLeftChange = {
+                paddingLeft = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
+            },
+            onRightChange = {
+                paddingRight = it
+                onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
             },
-            modifier = Modifier.padding(bottom = 8.dp),
         )
-        HorizontalPager(
-            state = pagerState,
-            modifier = Modifier.fillMaxWidth(),
-        ) { page ->
-            when (page) {
-                0 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
-                    PaddingSliders(
-                        top = headerPaddingTop, bottom = headerPaddingBottom,
-                        left = headerPaddingLeft, right = headerPaddingRight,
-                        onTopChange = {
-                            headerPaddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            headerPaddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            headerPaddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            headerPaddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.HeaderPaddingRight(it.toInt())))
-                        },
-                    )
-                }
-
-                1 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
-                    PaddingSliders(
-                        top = paddingTop, bottom = paddingBottom,
-                        left = paddingLeft, right = paddingRight,
-                        onTopChange = {
-                            paddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            paddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            paddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            paddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.PaddingRight(it.toInt())))
-                        },
-                    )
-                }
-
-                2 -> Column(modifier = Modifier.padding(vertical = 8.dp)) {
-                    PaddingSliders(
-                        top = footerPaddingTop, bottom = footerPaddingBottom,
-                        left = footerPaddingLeft, right = footerPaddingRight,
-                        onTopChange = {
-                            footerPaddingTop = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingTop(it.toInt())))
-                        },
-                        onBottomChange = {
-                            footerPaddingBottom = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingBottom(it.toInt())))
-                        },
-                        onLeftChange = {
-                            footerPaddingLeft = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingLeft(it.toInt())))
-                        },
-                        onRightChange = {
-                            footerPaddingRight = it
-                            onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.FooterPaddingRight(it.toInt())))
-                        },
-                    )
-                }
-            }
-        }
     }
 }
 
 @Composable
-private fun PaddingSliders(
+internal fun PaddingSliders(
     top: Float, bottom: Float, left: Float, right: Float,
     onTopChange: (Float) -> Unit, onBottomChange: (Float) -> Unit,
     onLeftChange: (Float) -> Unit, onRightChange: (Float) -> Unit,

+ 12 - 13
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadStyleSheet.kt

@@ -38,6 +38,7 @@ import kotlinx.coroutines.launch
 @Composable
 fun ReadStyleContent(
     onOpenPaddingConfig: () -> Unit,
+    onOpenHeaderFooterConfig: () -> Unit,
     onOpenMoreConfig: () -> Unit,
     onOpenBgTextConfig: (Int) -> Unit,
     onOpenTextTitle: () -> Unit,
@@ -51,7 +52,7 @@ fun ReadStyleContent(
     styleConfig: ReadBookStyleConfig = ReadBookStyleConfig(),
 ) {
     val scope = rememberCoroutineScope()
-    val pagerState = rememberPagerState(pageCount = { 3 })
+    val pagerState = rememberPagerState(pageCount = { 2 })
     var currentPage by remember { mutableIntStateOf(0) }
 
     val pageHeights = remember { mutableStateMapOf<Int, Int>() }
@@ -105,10 +106,6 @@ fun ReadStyleContent(
                         bottomBarButtons = bottomBarButtons,
                         onIntent = onIntent,
                     )
-
-                    2 -> HeaderFooterPage(
-                        onIntent = onIntent,
-                    )
                 }
             }
         }
@@ -123,15 +120,17 @@ fun ReadStyleContent(
             tabTitles = tabTitles,
             selectedTabIndex = currentPage,
             onTabSelected = { index ->
-                if (index < 3) {
-                    scope.launch {
-                        pagerState.animateScrollToPage(
-                            page = index,
-                            animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
-                        )
+                when (index) {
+                    0, 1 -> {
+                        scope.launch {
+                            pagerState.animateScrollToPage(
+                                page = index,
+                                animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
+                            )
+                        }
                     }
-                } else {
-                    onOpenMoreConfig()
+                    2 -> onOpenHeaderFooterConfig()
+                    3 -> onOpenMoreConfig()
                 }
             },
             modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/sheet/SystemMenuPage.kt

@@ -128,7 +128,7 @@ internal fun SystemMenuPage(
                 scope.launch {
                     pagerState.animateScrollToPage(
                         page = index,
-                        animationSpec = tween(durationMillis = 400, easing = FastOutSlowInEasing)
+                        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
                     )
                 }
             },

+ 80 - 6
app/src/main/java/io/legado/app/ui/book/search/ScopeSelectSheet.kt

@@ -25,6 +25,7 @@ import io.legado.app.R
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.icon.AppIcons
@@ -46,9 +47,20 @@ fun ScopeSelectSheet(
     isSourceScope: Boolean = false,
     title: String = stringResource(R.string.search_select_group),
     onConfirm: (() -> Unit)? = null,
+    onApplyScope: ((ScopeSelection) -> Unit)? = null,
 ) {
     var scopeSheetTab by rememberSaveable(show) { mutableIntStateOf(if (isSourceScope) 1 else 0) }
     var filterText by rememberSaveable(show) { mutableStateOf("") }
+    var draftIsAll by remember(show, isAll) { mutableStateOf(isAll) }
+    var draftIsSourceScope by remember(show, isSourceScope) { mutableStateOf(isSourceScope) }
+    var draftGroups by remember(show, selectedGroups) { mutableStateOf(selectedGroups.toSet()) }
+    var draftSourceUrls by remember(show, selectedSources) { mutableStateOf(selectedSources.toSet()) }
+
+    val useDraftSelection = onApplyScope != null
+    val currentIsAll = if (useDraftSelection) draftIsAll else isAll
+    val currentIsSourceScope = if (useDraftSelection) draftIsSourceScope else isSourceScope
+    val currentGroups = if (useDraftSelection) draftGroups else selectedGroups
+    val currentSourceUrls = if (useDraftSelection) draftSourceUrls else selectedSources
 
     val filteredGroups = remember(groups, filterText) {
         if (filterText.isBlank()) groups else groups.filter { it.contains(filterText, ignoreCase = true) }
@@ -87,11 +99,18 @@ fun ScopeSelectSheet(
 
             SelectionItemCard(
                 title = stringResource(R.string.all_source),
-                isSelected = isAll,
+                isSelected = currentIsAll,
                 containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
                 inSelectionMode = true,
                 onToggleSelection = {
-                    onSelectAll()
+                    if (useDraftSelection) {
+                        draftIsAll = true
+                        draftIsSourceScope = false
+                        draftGroups = emptySet()
+                        draftSourceUrls = emptySet()
+                    } else {
+                        onSelectAll()
+                    }
                 }
             )
 
@@ -113,14 +132,27 @@ fun ScopeSelectSheet(
                     verticalArrangement = Arrangement.spacedBy(4.dp)
                 ) {
                     items(filteredGroups, key = { it }) { groupName ->
-                        val selected = !isSourceScope && selectedGroups.contains(groupName)
+                        val selected = !currentIsSourceScope && currentGroups.contains(groupName)
                         SelectionItemCard(
                             title = groupName,
                             isSelected = selected,
                             containerColor = LegadoTheme.colorScheme.surface.copy(alpha = 0.6f),
                             inSelectionMode = true,
                             onToggleSelection = {
-                                onToggleGroup(groupName)
+                                if (useDraftSelection) {
+                                    val next = currentGroups.toMutableSet()
+                                    if (!currentIsSourceScope && next.contains(groupName)) {
+                                        next.remove(groupName)
+                                    } else {
+                                        next.add(groupName)
+                                    }
+                                    draftGroups = next
+                                    draftSourceUrls = emptySet()
+                                    draftIsSourceScope = false
+                                    draftIsAll = next.isEmpty()
+                                } else {
+                                    onToggleGroup(groupName)
+                                }
                             }
                         )
                     }
@@ -142,7 +174,7 @@ fun ScopeSelectSheet(
                         verticalArrangement = Arrangement.spacedBy(4.dp)
                     ) {
                         items(filteredSources, key = { it.bookSourceUrl }) { source ->
-                            val selected = selectedSources.contains(source.bookSourceUrl)
+                            val selected = currentSourceUrls.contains(source.bookSourceUrl)
                             SelectionItemCard(
                                 title = source.bookSourceName,
                                 subtitle = source.bookSourceGroup?.takeIf { group -> group.isNotBlank() },
@@ -150,7 +182,20 @@ fun ScopeSelectSheet(
                                 isSelected = selected,
                                 inSelectionMode = true,
                                 onToggleSelection = {
-                                    onToggleSource(source)
+                                    if (useDraftSelection) {
+                                        val next = currentSourceUrls.toMutableSet()
+                                        if (next.contains(source.bookSourceUrl)) {
+                                            next.remove(source.bookSourceUrl)
+                                        } else {
+                                            next.add(source.bookSourceUrl)
+                                        }
+                                        draftSourceUrls = next
+                                        draftGroups = emptySet()
+                                        draftIsSourceScope = next.isNotEmpty()
+                                        draftIsAll = next.isEmpty()
+                                    } else {
+                                        onToggleSource(source)
+                                    }
                                 }
                             )
                         }
@@ -158,7 +203,36 @@ fun ScopeSelectSheet(
                 }
             }
 
+            if (onApplyScope != null) {
+                Spacer(modifier = Modifier.height(16.dp))
+                ConfirmDismissButtonsRow(
+                    onDismiss = onDismissRequest,
+                    onConfirm = {
+                        onApplyScope(
+                            ScopeSelection(
+                                groupNames = if (!draftIsSourceScope) draftGroups.toList() else emptyList(),
+                                sources = if (draftIsSourceScope) {
+                                    sources.filter { draftSourceUrls.contains(it.bookSourceUrl) }
+                                } else {
+                                    emptyList()
+                                },
+                                isSourceScope = draftIsSourceScope,
+                            )
+                        )
+                        onDismissRequest()
+                    },
+                    dismissText = stringResource(R.string.cancel),
+                    confirmText = stringResource(R.string.confirm),
+                )
+            }
+
             Spacer(modifier = Modifier.height(20.dp))
         }
     }
 }
+
+data class ScopeSelection(
+    val groupNames: List<String>,
+    val sources: List<BookSourcePart>,
+    val isSourceScope: Boolean,
+)

+ 5 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt

@@ -88,6 +88,11 @@ sealed interface SearchIntent {
     data class ToggleSourceType(val type: Int) : SearchIntent
     data object ClearAllSourceTypes : SearchIntent
     data object SelectAllScope : SearchIntent
+    data class ApplyScopeSelection(
+        val groupNames: List<String>,
+        val sources: List<BookSourcePart>,
+        val isSourceScope: Boolean,
+    ) : SearchIntent
     data class ToggleScopeGroup(val groupName: String) : SearchIntent
     data class ToggleScopeSource(val source: BookSourcePart) : SearchIntent
     data class RemoveScopeItem(val scopeName: String) : SearchIntent

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/search/SearchScope.kt

@@ -70,7 +70,7 @@ data class SearchScope(private var scope: String) {
             if (scope.isEmpty()) {
                 return appCtx.getString(R.string.all_source)
             }
-            return scope
+            return parsedScope().groupNames.joinToString(",")
         }
 
     /**

+ 41 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -112,6 +112,7 @@ fun SearchScreen(
     val lifecycleOwner = LocalLifecycleOwner.current
     var queryInput by rememberSaveable { mutableStateOf(state.query) }
     var ignoreNextDebouncedQuery by rememberSaveable { mutableStateOf<String?>(null) }
+    var keepResultsPinnedToTop by rememberSaveable { mutableStateOf(true) }
     val showSuggestionPanel = state.showSuggestions
     val latestQuery by rememberUpdatedState(state.query)
     val scrollBehavior = if (ThemeResolver.isMiuixEngine(LegadoTheme.composeEngine)) {
@@ -243,6 +244,37 @@ fun SearchScreen(
         }
     }
 
+    LaunchedEffect(state.committedQuery) {
+        keepResultsPinnedToTop = true
+    }
+
+    LaunchedEffect(isSourceGroupedMode, listState, groupedListState) {
+        snapshotFlow {
+            val activeState = if (isSourceGroupedMode) groupedListState else listState
+            Triple(
+                activeState.firstVisibleItemIndex,
+                activeState.firstVisibleItemScrollOffset,
+                activeState.isScrollInProgress
+            )
+        }.collect { (index, offset, isScrollInProgress) ->
+            if (index == 0 && offset == 0) {
+                keepResultsPinnedToTop = true
+            } else if (isScrollInProgress) {
+                keepResultsPinnedToTop = false
+            }
+        }
+    }
+
+    val firstResultKey = state.results.firstOrNull()?.let {
+        "${it.book.origin}:${it.book.bookUrl}"
+    }
+    LaunchedEffect(firstResultKey, state.results.size, state.isSearching) {
+        if (state.isSearching && keepResultsPinnedToTop && state.results.isNotEmpty()) {
+            listState.scrollToItem(0)
+            groupedListState.scrollToItem(0)
+        }
+    }
+
     val submitSearch: (String) -> Unit = { rawQuery ->
         val normalized = rawQuery.trim()
         if (normalized.isNotBlank()) {
@@ -611,6 +643,15 @@ fun SearchScreen(
         onToggleSource = { viewModel.onIntent(SearchIntent.ToggleScopeSource(it)) },
         isSourceScope = state.isSourceScope,
         onConfirm = { viewModel.onIntent(SearchIntent.OpenSourceManage) },
+        onApplyScope = { selection ->
+            viewModel.onIntent(
+                SearchIntent.ApplyScopeSelection(
+                    groupNames = selection.groupNames,
+                    sources = selection.sources,
+                    isSourceScope = selection.isSourceScope,
+                )
+            )
+        },
     )
 
     AppModalBottomSheet(

+ 22 - 3
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -211,6 +211,7 @@ class SearchViewModel(
                 syncScopeState(restartSearch = true, oldScope = oldScope)
             }
 
+            is SearchIntent.ApplyScopeSelection -> applyScopeSelection(intent)
             is SearchIntent.ToggleScopeGroup -> toggleScopeGroup(intent.groupName)
             is SearchIntent.ToggleScopeSource -> toggleScopeSource(intent.source)
             is SearchIntent.RemoveScopeItem -> {
@@ -221,6 +222,7 @@ class SearchViewModel(
             }
 
             is SearchIntent.SetMatchMode -> {
+                _uiState.update { it.copy(matchMode = intent.mode) }
                 viewModelScope.launch {
                     localPreferencesRepository.updatePreference(
                         LocalPreferencesKeys.MATCH_MODE, intent.mode.value
@@ -427,7 +429,6 @@ class SearchViewModel(
             it.copy(
                 query = query,
                 showSuggestions = showSuggestions,
-                isManualStop = false,
                 emptyScopeAction = null,
             )
         }
@@ -657,6 +658,7 @@ class SearchViewModel(
         _uiState.update { it.copy(emptyScopeAction = null) }
 
         if (action.wasMatchMode == MatchMode.EXACT) {
+            _uiState.update { it.copy(matchMode = MatchMode.DEFAULT) }
             viewModelScope.launch {
                 localPreferencesRepository.updatePreference(
                     LocalPreferencesKeys.MATCH_MODE, MatchMode.DEFAULT.value
@@ -672,12 +674,29 @@ class SearchViewModel(
     }
 
     private fun restartCommittedSearchIfNeeded() {
-        val committed = _uiState.value.committedQuery
-        if (committed.isNotBlank()) {
+        val state = _uiState.value
+        val committed = state.committedQuery
+        if (
+            committed.isNotBlank() &&
+            state.query.trim() == committed &&
+            !state.showSuggestions &&
+            !state.isManualStop
+        ) {
             submitSearch(committed)
         }
     }
 
+    private fun applyScopeSelection(intent: SearchIntent.ApplyScopeSelection) {
+        val oldScope = searchScope.toString()
+        when {
+            intent.isSourceScope -> searchScope.updateSources(intent.sources)
+            intent.groupNames.isNotEmpty() -> searchScope.update(intent.groupNames)
+            else -> searchScope.update("")
+        }
+        persistSearchScope()
+        syncScopeState(restartSearch = true, oldScope = oldScope)
+    }
+
     private fun syncScopeState(
         restartSearch: Boolean = false,
         oldScope: String? = null,

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentResult.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.book.searchContent
 
+import kotlinx.coroutines.ExperimentalCoroutinesApi
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.asSharedFlow
 
@@ -13,6 +14,7 @@ import kotlinx.coroutines.flow.asSharedFlow
 object SearchContentResult {
 
     data class Result(
+        val bookUrl: String,
         val searchResults: List<SearchResult>,
         val index: Int,
         val query: String,
@@ -25,6 +27,7 @@ object SearchContentResult {
         _results.tryEmit(result)
     }
 
+    @OptIn(ExperimentalCoroutinesApi::class)
     fun resetReplayCache() {
         _results.resetReplayCache()
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt

@@ -195,6 +195,7 @@ class SearchContentViewModel(
         if (index < 0) return false
         SearchContentResult.emitResult(
             SearchContentResult.Result(
+                bookUrl = bookUrl,
                 searchResults = results,
                 index = index,
                 query = searchResult.query

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchResult.kt

@@ -11,6 +11,7 @@ import android.text.style.UnderlineSpan
 import io.legado.app.help.config.AppConfig
 
 data class SearchResult(
+    val bookUrl: String = "",
     val resultCount: Int = 0,
     val resultCountWithinChapter: Int = 0,
     val resultText: String = "",

+ 55 - 17
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.lifecycleScope
 import androidx.navigation3.runtime.NavKey
@@ -67,6 +68,12 @@ import kotlin.coroutines.suspendCoroutine
 open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
 
     companion object {
+        private const val KEY_RESTORE_READ_ROUTE = "restoreReadRoute"
+        private const val KEY_RESTORE_READ_BOOK_URL = "restoreReadBookUrl"
+        private const val KEY_RESTORE_READ_ALOUD = "restoreReadAloud"
+        private const val KEY_RESTORE_READ_IN_BOOKSHELF = "restoreReadInBookshelf"
+        private const val KEY_RESTORE_READ_CHAPTER_CHANGED = "restoreReadChapterChanged"
+
         @Volatile
         var hasActiveReadBookRoute: Boolean = false
 
@@ -143,10 +150,15 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
     private val routeEvents = MutableSharedFlow<NavKey>(extraBufferCapacity = 1)
     private var bookInfoVariableSetter: ((String, String?) -> Unit)? = null
     private var shouldApplyDefaultToRead = true
+    private var restoredReadBookRoute: MainRouteReadBook? = null
+    private var latestBackStack: List<NavKey> = emptyList()
     internal var activeReadBookInputHandler: ReadBookInputHandler? = null
+    internal var activeReadBookRoute: MainRouteReadBook? = null
+
     override fun onCreate(savedInstanceState: Bundle?) {
         installSplashScreen()
         shouldApplyDefaultToRead = savedInstanceState == null
+        restoredReadBookRoute = savedInstanceState?.restoreReadBookRoute()
         super.onCreate(savedInstanceState)
 
         if (checkStartupRoute()) return
@@ -175,6 +187,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
     override fun onNewIntent(intent: Intent) {
         super.onNewIntent(intent)
         setIntent(intent)
+        if (!intent.hasExplicitStartRoute()) return
         routeEvents.tryEmit(MainNavigator.resolveStartRoute(intent))
     }
 
@@ -194,12 +207,21 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
         }
 
         val startRoutes = remember {
-            resolveInitialStartRoutes(
-                resolved = MainNavigator.resolveStartRoute(intent),
-                defaultToRead = OtherConfig.defaultToRead,
-                isFreshCreate = shouldApplyDefaultToRead,
-            ).toTypedArray()
+            val resolved = MainNavigator.resolveStartRoute(intent)
+            val hasExplicitStartRoute = intent?.hasExplicitStartRoute() == true
+            when {
+                !hasExplicitStartRoute && restoredReadBookRoute != null -> {
+                    arrayOf(MainRouteHome, restoredReadBookRoute!!)
+                }
+                shouldApplyDefaultToRead && OtherConfig.defaultToRead && resolved == MainRouteHome -> {
+                    arrayOf(MainRouteHome, MainRouteReadBook())
+                }
+                else -> {
+                    arrayOf(resolved)
+                }
+            }
         }
+        latestBackStack = startRoutes.toList()
         val backStack = rememberNavBackStack(*startRoutes)
 
         SideEffect {
@@ -212,6 +234,11 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
             }
         }
 
+        LaunchedEffect(backStack) {
+            snapshotFlow { backStack.toList() }
+                .collect { latestBackStack = it }
+        }
+
         SharedTransitionLayout {
             NavDisplay(
                 backStack = backStack,
@@ -374,6 +401,29 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
         if (AppConfig.autoRefreshBook) {
             outState.putBoolean("isAutoRefreshedBook", true)
         }
+        val readRoute = latestBackStack.lastOrNull() as? MainRouteReadBook
+            ?: activeReadBookRoute
+        if (readRoute != null) {
+            outState.putBoolean(KEY_RESTORE_READ_ROUTE, true)
+            outState.putString(KEY_RESTORE_READ_BOOK_URL, readRoute.bookUrl)
+            outState.putBoolean(KEY_RESTORE_READ_ALOUD, readRoute.readAloud)
+            outState.putBoolean(KEY_RESTORE_READ_IN_BOOKSHELF, readRoute.inBookshelf)
+            outState.putBoolean(KEY_RESTORE_READ_CHAPTER_CHANGED, readRoute.chapterChanged)
+        }
+    }
+
+    private fun Bundle.restoreReadBookRoute(): MainRouteReadBook? {
+        if (!getBoolean(KEY_RESTORE_READ_ROUTE, false)) return null
+        return MainRouteReadBook(
+            bookUrl = getString(KEY_RESTORE_READ_BOOK_URL),
+            readAloud = getBoolean(KEY_RESTORE_READ_ALOUD, false),
+            inBookshelf = getBoolean(KEY_RESTORE_READ_IN_BOOKSHELF, true),
+            chapterChanged = getBoolean(KEY_RESTORE_READ_CHAPTER_CHANGED, false),
+        )
+    }
+
+    private fun Intent.hasExplicitStartRoute(): Boolean {
+        return hasExtra(MainIntent.EXTRA_START_ROUTE)
     }
 
     override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -438,18 +488,6 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
 
 }
 
-internal fun resolveInitialStartRoutes(
-    resolved: NavKey,
-    defaultToRead: Boolean,
-    isFreshCreate: Boolean,
-): List<NavKey> {
-    return if (isFreshCreate && defaultToRead && resolved == MainRouteHome) {
-        listOf(MainRouteHome, MainRouteReadBook())
-    } else {
-        listOf(resolved)
-    }
-}
-
 class LauncherW : MainActivity()
 class Launcher1 : MainActivity()
 class Launcher2 : MainActivity()

+ 5 - 0
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -310,6 +310,7 @@ fun MainActivity.mainEntryProvider(
 
         DisposableEffect(controller, lifecycleOwner, route.readAloud) {
             activeReadBookInputHandler = controller
+            activeReadBookRoute = route
             MainActivity.hasActiveReadBookRoute = true
             controller.onClose = { onNavigateBack() }
             controller.onStartContentLoadFinish = {
@@ -335,6 +336,9 @@ fun MainActivity.mainEntryProvider(
                 if (activeReadBookInputHandler === controller) {
                     activeReadBookInputHandler = null
                 }
+                if (activeReadBookRoute == route) {
+                    activeReadBookRoute = null
+                }
                 MainActivity.hasActiveReadBookRoute = false
                 controller.clearTts()
             }
@@ -354,6 +358,7 @@ fun MainActivity.mainEntryProvider(
 
     entry<MainRouteSearchContent> { route ->
         val viewModel = koinViewModel<SearchContentViewModel>(
+            key = route.bookUrl,
             parameters = { parametersOf(route) }
         )
         SearchContentScreen(

+ 1 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -352,6 +352,7 @@
     <string name="padding_bottom">下边距</string>
     <string name="padding_left">左边距</string>
     <string name="padding_right">右边距</string>
+    <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校验书源</string>
     <string name="check_select_source">校验所选</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>

+ 1 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -299,6 +299,7 @@
     <string name="padding_bottom">下邊距</string>
     <string name="padding_left">左邊距</string>
     <string name="padding_right">右邊距</string>
+    <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>

+ 1 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -301,6 +301,7 @@
     <string name="padding_bottom">下邊距</string>
     <string name="padding_left">左邊距</string>
     <string name="padding_right">右邊距</string>
+    <string name="padding_format">上: %1$d  下: %2$d  左: %3$d  右: %4$d</string>
     <string name="check_book_source">校驗書源</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>

+ 1 - 0
app/src/main/res/values/strings.xml

@@ -373,6 +373,7 @@
     <string name="padding_bottom">Bottom</string>
     <string name="padding_left">Left</string>
     <string name="padding_right">Right</string>
+    <string name="padding_format">Top: %1$d  Bottom: %2$d  Left: %3$d  Right: %4$d</string>
     <string name="check_book_source">Check book sources</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>