Просмотр исходного кода

Feat/reading anchor (#2089)

* feat: add reading anchor return button

* feat: add book content quality detection

* fix: 内容质量检测与阅读锚点的审查问题修复

内容质量检测:
- 单章内容获取失败(瞬时网络错误)只跳过该章,不再整本判为排除
- 整本检测失败(如目录拉取异常)记为检测失败而非排除,避免误杀书源
- 移除未接入 UI 且不参与排除判定的评分体系(hitRate/score/连续命中率)
- cleanContent 清洗后 trim 首尾空白
- 章节数不足的帮助文案与实际排除行为对齐
- 检测协程切 Dispatchers.Default,避免大文本清洗/匹配阻塞主线程
- 补充 parseChapterIndices/cleanContent/countOccurrences 单元测试

阅读锚点:
- 悬浮条增加关闭按钮,不恢复进度时也可主动丢弃锚点
- KeepCurrentBookProgress 同步刷新锚点可用状态,关闭后立即隐藏

i18n:
- 补齐 zh-rHK/zh-rTW 的内容质量检测字符串
- 新增 dismiss_reading_anchor 四语言文案

* feat: 新增阅读锚点开关(阅读设置 → 更多)

- ReadSettings 新增 readingAnchorEnabled(默认开启,DataStore 持久化)
- 开关关闭时不再保存跳转锚点、不再显示返回悬浮按钮
- 阅读设置页「更多」分组新增「阅读锚点」开关项,与主题模式切换提醒并列
- 补齐 PreferKey/读方向/写方向映射,同步更新 ReadSettingsMappingTest 字段守护
- 新增四语言文案

* feat: 阅读悬浮胶囊朗读脱离提示

---------

Co-authored-by: Wuying Created Local Users <admin@3o4a1pohjxfq9dh.CN-HANGZHOU-198-13-1.WUYING.LOCAL>
Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
maker255 2 месяцев назад
Родитель
Сommit
9d31aa4f38
37 измененных файлов с 1140 добавлено и 166 удалено
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 6 0
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  3. 2 0
      app/src/main/java/io/legado/app/di/appModule.kt
  4. 32 0
      app/src/main/java/io/legado/app/domain/model/ContentQuality.kt
  5. 2 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSessionState.kt
  6. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt
  7. 188 0
      app/src/main/java/io/legado/app/domain/usecase/CheckBookContentQualityUseCase.kt
  8. 1 0
      app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt
  9. 10 0
      app/src/main/java/io/legado/app/model/ReadAloudSessionStore.kt
  10. 85 12
      app/src/main/java/io/legado/app/model/ReadBook.kt
  11. 45 14
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  12. 4 4
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  13. 5 4
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  14. 0 107
      app/src/main/java/io/legado/app/ui/book/read/ActionReminder.kt
  15. 7 2
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  16. 10 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  17. 146 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookFloatingActionBar.kt
  18. 1 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  19. 0 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  20. 43 6
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  21. 5 0
      app/src/main/java/io/legado/app/ui/book/read/ReadConfigUpdateDelegate.kt
  22. 10 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt
  23. 34 0
      app/src/main/java/io/legado/app/ui/book/search/SearchContract.kt
  24. 151 6
      app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt
  25. 4 1
      app/src/main/java/io/legado/app/ui/book/search/SearchSourceSection.kt
  26. 112 0
      app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt
  27. 2 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt
  28. 9 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt
  29. 2 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt
  30. 49 2
      app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt
  31. 25 0
      app/src/main/res/values-zh-rCN/strings.xml
  32. 25 0
      app/src/main/res/values-zh-rHK/strings.xml
  33. 25 0
      app/src/main/res/values-zh-rTW/strings.xml
  34. 25 0
      app/src/main/res/values/strings.xml
  35. 4 2
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  36. 54 0
      app/src/test/java/io/legado/app/domain/usecase/CheckBookContentQualityUseCaseTest.kt
  37. 15 0
      app/src/test/java/io/legado/app/model/ReadAloudSessionStoreTest.kt

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

@@ -111,6 +111,7 @@ object PreferKey {
     const val autoClearExpired = "autoClearExpired"
     const val autoChangeSource = "autoChangeSource"
     const val autoSuggestDayNight = "autoSuggestDayNight"
+    const val readingAnchorEnabled = "readingAnchorEnabled"
     const val importKeepName = "importKeepName"
     const val importKeepGroup = "importKeepGroup"
     const val screenOrientation = "screenOrientation"

+ 6 - 0
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -137,6 +137,9 @@ class ReadSettingsRepository(
     suspend fun setAutoSuggestDayNight(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.autoSuggestDayNight, value)
 
+    suspend fun setReadingAnchorEnabled(value: Boolean) =
+        settingsRepository.putBoolean(PreferKey.readingAnchorEnabled, value)
+
     suspend fun setSelectText(value: Boolean) =
         settingsRepository.putBoolean(PreferKey.selectText, value)
 
@@ -375,6 +378,7 @@ class ReadSettingsRepository(
             selectVibrator = compatDsValue(Keys.SelectVibrator, false),
             autoChangeSource = compatDsValue(Keys.AutoChangeSource, true),
             autoSuggestDayNight = compatDsValue(Keys.AutoSuggestDayNight, false),
+            readingAnchorEnabled = compatDsValue(Keys.ReadingAnchorEnabled, true),
             selectText = compatDsValue(Keys.SelectText, true),
             noAnimScrollPage = compatDsValue(Keys.NoAnimScrollPage, false),
             clickImgWay = compatDsValue(Keys.ClickImgWay, "2"),
@@ -494,6 +498,7 @@ class ReadSettingsRepository(
         val SelectVibrator = booleanPreferencesKey(PreferKey.selectVibrator)
         val AutoChangeSource = booleanPreferencesKey(PreferKey.autoChangeSource)
         val AutoSuggestDayNight = booleanPreferencesKey(PreferKey.autoSuggestDayNight)
+        val ReadingAnchorEnabled = booleanPreferencesKey(PreferKey.readingAnchorEnabled)
         val SelectText = booleanPreferencesKey(PreferKey.selectText)
         val NoAnimScrollPage = booleanPreferencesKey(PreferKey.noAnimScrollPage)
         val ClickImgWay = stringPreferencesKey(PreferKey.clickImgWay)
@@ -623,6 +628,7 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.selectVibrator to selectVibrator,
     PreferKey.autoChangeSource to autoChangeSource,
     PreferKey.autoSuggestDayNight to autoSuggestDayNight,
+    PreferKey.readingAnchorEnabled to readingAnchorEnabled,
     PreferKey.selectText to selectText,
     PreferKey.noAnimScrollPage to noAnimScrollPage,
     PreferKey.clickImgWay to clickImgWay,

+ 2 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -186,6 +186,7 @@ import io.legado.app.domain.usecase.ExportBookshelfUseCase
 import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
+import io.legado.app.domain.usecase.CheckBookContentQualityUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.HomeDashboardUseCase
 import io.legado.app.domain.usecase.IdentifyBookCharactersUseCase
@@ -494,6 +495,7 @@ val appModule = module {
     singleOf(::SearchBooksUseCase)
     singleOf(::ChangeSourceSearchUseCase)
     singleOf(::GetChapterContentUseCase)
+    singleOf(::CheckBookContentQualityUseCase)
     singleOf(::AiToolAwareGenerationUseCase)
     singleOf(::AiTaskManager)
     singleOf(::IdentifyBookCharactersUseCase)

+ 32 - 0
app/src/main/java/io/legado/app/domain/model/ContentQuality.kt

@@ -0,0 +1,32 @@
+package io.legado.app.domain.model
+
+data class ContentQualityConfig(
+    val chapterSpec: String,
+    val keywords: List<String>,
+    val skipHeadChars: Int,
+)
+
+enum class ContentQualityStage {
+    ChapterCount,
+    ContentCleaning,
+    KeywordMatching,
+    Excluding,
+    Completed,
+}
+
+data class ContentQualityProgress(
+    val stage: ContentQualityStage,
+    val processedBooks: Int,
+    val totalBooks: Int,
+    val currentBookName: String = "",
+)
+
+data class ContentQualityResult(
+    val bookKey: String,
+    val sourceName: String,
+    val sampledChapterCount: Int,
+    val matchedChapterCount: Int,
+    val keywordHits: Int,
+    val excluded: Boolean,
+    val errorMessage: String? = null,
+)

+ 2 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSessionState.kt

@@ -10,4 +10,6 @@ data class ReadAloudSessionState(
     val status: ReadAloudSessionStatus = ReadAloudSessionStatus.Idle,
     val playback: ReadAloudPlaybackInfo = ReadAloudPlaybackInfo(),
     val timerMinutes: Int = 0,
+    /** 朗读位置是否跟随当前显示页;用户手动翻页/跳章后脱离,回到朗读位置或新会话时恢复。 */
+    val followReadAloudPosition: Boolean = true,
 )

+ 1 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadSettings.kt

@@ -39,6 +39,7 @@ data class ReadSettings(
     val selectVibrator: Boolean = false,
     val autoChangeSource: Boolean = true,
     val autoSuggestDayNight: Boolean = false,
+    val readingAnchorEnabled: Boolean = true,
     val selectText: Boolean = true,
     val noAnimScrollPage: Boolean = false,
     val clickImgWay: String = "2",

+ 188 - 0
app/src/main/java/io/legado/app/domain/usecase/CheckBookContentQualityUseCase.kt

@@ -0,0 +1,188 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.SearchBook
+import io.legado.app.domain.model.ContentQualityConfig
+import io.legado.app.domain.model.ContentQualityProgress
+import io.legado.app.domain.model.ContentQualityResult
+import io.legado.app.domain.model.ContentQualityStage
+import kotlinx.coroutines.ensureActive
+import kotlin.coroutines.coroutineContext
+
+class CheckBookContentQualityUseCase(
+    private val chapterContentUseCase: GetChapterContentUseCase,
+) {
+
+    suspend fun execute(
+        books: List<SearchBook>,
+        config: ContentQualityConfig,
+        onProgress: suspend (ContentQualityProgress) -> Unit,
+    ): List<ContentQualityResult> {
+        val results = ArrayList<ContentQualityResult>(books.size)
+        books.forEachIndexed { index, book ->
+            coroutineContext.ensureActive()
+            onProgress(
+                ContentQualityProgress(
+                    stage = ContentQualityStage.ChapterCount,
+                    processedBooks = index,
+                    totalBooks = books.size,
+                    currentBookName = book.name,
+                )
+            )
+            results += checkBook(book, config, index, books.size, onProgress)
+        }
+        onProgress(
+            ContentQualityProgress(
+                stage = ContentQualityStage.Completed,
+                processedBooks = books.size,
+                totalBooks = books.size,
+            )
+        )
+        return results
+    }
+
+    private suspend fun checkBook(
+        book: SearchBook,
+        config: ContentQualityConfig,
+        processedBooks: Int,
+        totalBooks: Int,
+        onProgress: suspend (ContentQualityProgress) -> Unit,
+    ): ContentQualityResult {
+        val bookKey = book.qualityKey()
+        val sourceName = book.originName.ifBlank { book.origin }
+        return runCatching {
+            val (toc, _) = chapterContentUseCase.getToc(book.toBook())
+            val requestedMaximum = Regex("\\d+")
+                .findAll(config.chapterSpec)
+                .mapNotNull { it.value.toIntOrNull() }
+                .maxOrNull()
+            if (requestedMaximum != null && requestedMaximum > toc.size) {
+                return@runCatching ContentQualityResult(
+                    bookKey = bookKey,
+                    sourceName = sourceName,
+                    sampledChapterCount = 0,
+                    matchedChapterCount = 0,
+                    keywordHits = 0,
+                    excluded = true,
+                    errorMessage = "章节数不足",
+                )
+            }
+            val chapterIndices = parseChapterIndices(config.chapterSpec, toc.size)
+            if (chapterIndices.isEmpty()) {
+                return@runCatching ContentQualityResult(
+                    bookKey = bookKey,
+                    sourceName = sourceName,
+                    sampledChapterCount = 0,
+                    matchedChapterCount = 0,
+                    keywordHits = 0,
+                    excluded = true,
+                    errorMessage = "没有可检测的章节",
+                )
+            }
+
+            var sampledChapterCount = 0
+            var matchedChapterCount = 0
+            var keywordHits = 0
+            chapterIndices.forEachIndexed { _, chapterIndex ->
+                coroutineContext.ensureActive()
+                onProgress(
+                    ContentQualityProgress(
+                        stage = ContentQualityStage.ContentCleaning,
+                        processedBooks = processedBooks,
+                        totalBooks = totalBooks,
+                        currentBookName = book.name,
+                    )
+                )
+                val chapter = toc[chapterIndex - 1]
+                val nextChapterUrl = toc.getOrNull(chapterIndex)?.url
+                // 单章获取失败(网络抖动等瞬时错误)只跳过该章,不把整个书源判为排除
+                val content = runCatching {
+                    chapterContentUseCase.getContent(
+                        book = book.toBook(),
+                        chapter = chapter,
+                        nextChapterUrl = nextChapterUrl,
+                    )
+                }.getOrNull() ?: return@forEachIndexed
+                sampledChapterCount++
+                val cleaned = cleanContent(content, config.skipHeadChars)
+                onProgress(
+                    ContentQualityProgress(
+                        stage = ContentQualityStage.KeywordMatching,
+                        processedBooks = processedBooks,
+                        totalBooks = totalBooks,
+                        currentBookName = book.name,
+                    )
+                )
+                val chapterHits = config.keywords.sumOf { keyword ->
+                    countOccurrences(cleaned, keyword)
+                }
+                keywordHits += chapterHits
+                if (chapterHits > 0) {
+                    matchedChapterCount++
+                }
+            }
+
+            val excluded = sampledChapterCount > 0 && keywordHits == 0
+            onProgress(ContentQualityProgress(ContentQualityStage.Excluding, processedBooks, totalBooks, book.name))
+            ContentQualityResult(
+                bookKey = bookKey,
+                sourceName = sourceName,
+                sampledChapterCount = sampledChapterCount,
+                matchedChapterCount = matchedChapterCount,
+                keywordHits = keywordHits,
+                excluded = excluded,
+            )
+        }.getOrElse { error ->
+            // 整本检测失败(如目录拉取异常):不算“排除”,只记录原因,避免瞬时错误误杀书源
+            ContentQualityResult(
+                bookKey = bookKey,
+                sourceName = sourceName,
+                sampledChapterCount = 0,
+                matchedChapterCount = 0,
+                keywordHits = 0,
+                excluded = false,
+                errorMessage = error.localizedMessage?.takeIf { it.isNotBlank() }
+                    ?: error.javaClass.simpleName,
+            )
+        }
+    }
+
+    companion object {
+        fun parseChapterIndices(spec: String, totalChapterCount: Int): List<Int> {
+            if (totalChapterCount <= 0) return emptyList()
+            return Regex("\\d+(?:\\s*[-~至到]\\s*\\d+)?")
+                .findAll(spec)
+                .flatMap { match ->
+                    val parts = match.value.split(Regex("\\s*[-~至到]\\s*"))
+                    val start = parts.first().toIntOrNull() ?: return@flatMap emptySequence()
+                    val end = parts.getOrNull(1)?.toIntOrNull() ?: start
+                    (minOf(start, end)..maxOf(start, end)).asSequence()
+                }
+                .filter { it in 1..totalChapterCount }
+                .distinct()
+                .toList()
+        }
+
+        internal fun cleanContent(content: String, skipHeadChars: Int): String {
+            return content
+                .replace(Regex("<[^>]*>"), " ")
+                .replace(Regex("\\s+"), " ")
+                .trim()
+                .drop(skipHeadChars.coerceAtLeast(0))
+        }
+
+        internal fun countOccurrences(content: String, keyword: String): Int {
+            if (keyword.isBlank()) return 0
+            var count = 0
+            var start = 0
+            while (start < content.length) {
+                val index = content.indexOf(keyword, start, ignoreCase = true)
+                if (index < 0) break
+                count++
+                start = index + keyword.length.coerceAtLeast(1)
+            }
+            return count
+        }
+
+        private fun SearchBook.qualityKey(): String = "$origin:$bookUrl"
+    }
+}

+ 1 - 0
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -76,6 +76,7 @@ object ReadBookConfig {
     val useZhLayout get() = readSettings.useZhLayout
     val readMenuIconShowText get() = readSettings.readMenuIconShowText
     val showMenuIcon get() = readSettings.showMenuIcon
+    val readingAnchorEnabled get() = readSettings.readingAnchorEnabled
     val titleBarCompact get() = readSettings.titleBarCompact
     val readMenuFloatingBottomBar get() = readSettings.readMenuFloatingBottomBar
     val readMenuTopBarLiquidGlassButtons get() = readSettings.readMenuTopBarLiquidGlassButtons

+ 10 - 0
app/src/main/java/io/legado/app/model/ReadAloudSessionStore.kt

@@ -25,6 +25,16 @@ class ReadAloudSessionStore {
         _state.update { it.copy(timerMinutes = minutes) }
     }
 
+    /** 用户手动导航(翻页/跳章/拖动进度)导致页面脱离朗读位置。 */
+    fun detachReadAloudFollow() {
+        _state.update { it.copy(followReadAloudPosition = false) }
+    }
+
+    /** 回到朗读位置或新朗读会话开始时恢复跟随。 */
+    fun restoreReadAloudFollow() {
+        _state.update { it.copy(followReadAloudPosition = true) }
+    }
+
     fun stop() {
         _state.value = ReadAloudSessionState()
     }

+ 85 - 12
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -145,6 +145,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     var bookSource: BookSource? = null
     var msg: String? = null
     private val readRecordRepository: ReadRecordRepository by inject()
+    private val readAloudSessionStore: ReadAloudSessionStore by inject()
     private var lastReadLength: Long = 0
     private val loadingChapters = arrayListOf<Int>()
     private val readRecord = ReadRecord()
@@ -165,6 +166,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     /* 跳转进度前进度记录 */
     var lastBookProgress: BookProgress? = null
+    private var readingAnchorJumpCount = 0
 
     /* web端阅读进度记录 */
     var webBookProgress: BookProgress? = null
@@ -223,6 +225,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
     private fun publishSnapshot() {
         _snapshot.value = buildSnapshot()
+        restoreReadAloudFollowIfBackOnPosition()
+    }
+
+    /**
+     * 页面回到朗读所在位置时自动恢复跟随:手动翻页脱离后,翻回朗读位置(同章同页)即视为重新跟随,
+     * 悬浮条随之消失;同时重绘朗读高亮(翻页时旧页高亮已被移除)。朗读服务自身驱动的页面移动不参与。
+     */
+    private fun restoreReadAloudFollowIfBackOnPosition() {
+        if (!BaseReadAloudService.isRun || BaseReadAloudService.speechDrivingNavigation) return
+        val speakingChapterIndex = BaseReadAloudService.currentChapterIndex
+        if (speakingChapterIndex < 0 || speakingChapterIndex != durChapterIndex) return
+        val speakingPage = curTextChapter?.getPageIndexByCharIndex(
+            BaseReadAloudService.currentProgress.coerceAtLeast(0)
+        ) ?: return
+        if (speakingPage == durPageIndex) {
+            if (!readAloudSessionStore.state.value.followReadAloudPosition) {
+                readAloudSessionStore.restoreReadAloudFollow()
+            }
+            upTextChapterAloudSpan(BaseReadAloudService.currentProgress.coerceAtLeast(0))
+        }
     }
 
     /** 当前会话是否正指向该 URL 的书。 */
@@ -273,6 +295,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         renderCallBack?.upPageAnim()
         upWebBook(book)
         lastBookProgress = null
+        readingAnchorJumpCount = 0
         webBookProgress = null
         TextFile.clear()
         requestWholeBookPageEstimate()
@@ -584,6 +607,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun setProgress(progress: BookProgress) {
+        detachReadAloudFollowIfManual()
         if (progress.durChapterIndex < chapterSize &&
             (durChapterIndex != progress.durChapterIndex
                     || durChapterPos != progress.durChapterPos)
@@ -604,11 +628,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         lastBookProgress = book?.let { BookProgress(it) }
     }
 
+    fun saveReadingAnchorBeforeChapterJump(targetChapterIndex: Int, targetChapterPos: Int = 0) {
+        if (!ReadBookConfig.readingAnchorEnabled) return
+        if (targetChapterIndex == durChapterIndex && targetChapterPos == durChapterPos) return
+        saveCurrentBookProgress()
+        if (lastBookProgress != null) readingAnchorJumpCount++
+    }
+
+    fun hasReadingAnchor(): Boolean =
+        ReadBookConfig.readingAnchorEnabled && lastBookProgress != null && readingAnchorJumpCount >= 2
+
+    fun discardReadingAnchor() {
+        lastBookProgress = null
+        readingAnchorJumpCount = 0
+    }
+
     //恢复跳转前进度
     fun restoreLastBookProgress() {
         lastBookProgress?.let {
             setProgress(it)
-            lastBookProgress = null
+            discardReadingAnchor()
         }
     }
 
@@ -822,7 +861,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
+    /**
+     * 用户手动导航(翻页/跳章/拖动进度/滚动)时,页面脱离朗读位置。
+     * 朗读服务自身驱动的页面移动([BaseReadAloudService.speechDrivingNavigation])不计入。
+     */
+    private fun detachReadAloudFollowIfManual() {
+        if (BaseReadAloudService.isRun && !BaseReadAloudService.speechDrivingNavigation) {
+            readAloudSessionStore.detachReadAloudFollow()
+        }
+    }
+
     fun moveToNextPage(): Boolean {
+        detachReadAloudFollowIfManual()
         var hasNextPage = false
         curTextChapter?.let {
             val nextPagePos = it.getNextPageLength(durChapterPos)
@@ -840,6 +890,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun moveToPrevPage(): Boolean {
+        detachReadAloudFollowIfManual()
         var hasPrevPage = false
         curTextChapter?.let {
             val prevPagePos = it.getPrevPageLength(durChapterPos)
@@ -855,6 +906,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun moveToNextChapter(upContent: Boolean, upContentInPlace: Boolean = true): Boolean {
+        detachReadAloudFollowIfManual()
         if (durChapterIndex < simulatedChapterSize - 1) {
             durChapterPos = 0
             durChapterIndex++
@@ -920,6 +972,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         toLast: Boolean = true,
         upContentInPlace: Boolean = true
     ): Boolean {
+        detachReadAloudFollowIfManual()
         if (durChapterIndex > 0) {
             durChapterPos = if (toLast) prevTextChapter?.lastReadLength ?: Int.MAX_VALUE else 0
             durChapterIndex--
@@ -945,6 +998,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun skipToPage(index: Int, success: (() -> Unit)? = null) {
+        detachReadAloudFollowIfManual()
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
         renderCallBack?.upContent {
             success?.invoke()
@@ -955,6 +1009,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     fun setPageIndex(index: Int) {
+        detachReadAloudFollowIfManual()
         recycleRecorders(durPageIndex, index)
         durChapterPos = curTextChapter?.getReadLength(index) ?: index
         saveRead(true)
@@ -983,6 +1038,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         upContent: Boolean = true,
         success: (() -> Unit)? = null
     ) {
+        detachReadAloudFollowIfManual()
         // 实时读取章节数而不是依赖缓存 chapterSize:更新目录后 chapterSize 若未同步,
         // 新增章节的 index 会超出旧值而被下方守卫静默吞掉,表现为「点击新章节无法跳转」。
         val chapterCount = book?.bookUrl?.let { appDb.bookChapterDao.getChapterCount(it) }
@@ -1010,19 +1066,22 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         renderCallBack?.pageChanged()
         curTextChapter?.let {
             if (BaseReadAloudService.isRun && it.isCompleted) {
-                if (shouldRestartReadAloudAfterContentLoad(
-                        preserveReadAloudPosition = preserveReadAloudPosition,
-                        serviceChapterIndex = BaseReadAloudService.currentChapterIndex,
-                        loadedChapterIndex = it.chapter.index,
-                    )
-                ) {
-                    if (isScroll && pageChanged) {
-                        ReadAloud.pause(appCtx)
+                // 页面已脱离朗读位置(用户手动导航):既不重启朗读到当前页,也不把页面拉回朗读位置
+                if (readAloudSessionStore.state.value.followReadAloudPosition) {
+                    if (shouldRestartReadAloudAfterContentLoad(
+                            preserveReadAloudPosition = preserveReadAloudPosition,
+                            serviceChapterIndex = BaseReadAloudService.currentChapterIndex,
+                            loadedChapterIndex = it.chapter.index,
+                        )
+                    ) {
+                        if (isScroll && pageChanged) {
+                            ReadAloud.pause(appCtx)
+                        } else {
+                            readAloud(!BaseReadAloudService.pause)
+                        }
                     } else {
-                        readAloud(!BaseReadAloudService.pause)
+                        ReadAloud.syncLayout()
                     }
-                } else {
-                    ReadAloud.syncLayout()
                 }
             }
         }
@@ -1049,6 +1108,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         publishSnapshot()
     }
 
+    /**
+     * 把显示页定位到章内字符位置并绘制朗读高亮("回到朗读位置"跳转后调用)。
+     */
+    fun upTextChapterAloudSpan(chapterStart: Int) {
+        if (chapterStart < 0) return
+        val textChapter = curTextChapter ?: return
+        if (textChapter.chapter.index != BaseReadAloudService.currentChapterIndex) return
+        val pageIndex = textChapter.getPageIndexByCharIndex(chapterStart)
+        if (pageIndex < 0) return
+        val aloudSpanStart = chapterStart - textChapter.getReadLength(pageIndex)
+        textChapter.getPage(pageIndex)?.upPageAloudSpan(aloudSpanStart)
+        renderCallBack?.upContent(resetPageOffset = false)
+    }
+
     /**
      * 当前页数
      */

+ 45 - 14
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -111,6 +111,27 @@ abstract class BaseReadAloudService : BaseService(),
         var currentProgress: Int = 0
             private set
 
+        /**
+         * 朗读服务自身驱动页面移动(按页朗读、进度翻页、换章)时为 true;
+         * 供 [io.legado.app.model.ReadBook] 区分「朗读驱动翻页」与「用户手动导航」,
+         * 避免朗读推进被误判为手动脱离。
+         */
+        @JvmStatic
+        @Volatile
+        var speechDrivingNavigation: Boolean = false
+            private set
+
+        /** 在朗读驱动的页面移动/同步外侧调用,期间 [speechDrivingNavigation] 为 true。 */
+        @JvmStatic
+        fun <T> withSpeechNavigation(block: () -> T): T {
+            speechDrivingNavigation = true
+            return try {
+                block()
+            } finally {
+                speechDrivingNavigation = false
+            }
+        }
+
         fun isPlay(): Boolean {
             return isRun && !pause
         }
@@ -215,6 +236,8 @@ abstract class BaseReadAloudService : BaseService(),
         super.onCreate()
         isRun = true
         pause = false
+        // 新朗读会话默认跟随当前显示页(用户手动翻页脱离后由阅读界面负责恢复)
+        sessionStore.restoreReadAloudFollow()
         observeLiveBus()
         initMediaSession()
         observeMediaControlSettings()
@@ -314,6 +337,8 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) {
+        // 每次"从指定位置开始朗读"都是新会话:恢复页面跟随朗读(手动脱离后点"从此处朗读"等)
+        sessionStore.restoreReadAloudFollow()
         clearFinishChapterTimerIfChapterChanged(ReadBook.durChapterIndex)
         val generation = ++prepareReadAloudGeneration
         prepareReadAloudJob?.cancel()
@@ -537,9 +562,13 @@ abstract class BaseReadAloudService : BaseService(),
             pageStart = chapter::getReadLength,
         )
         if (targetPageIndex == pageIndex) return false
+        // 页面脱离朗读位置(用户手动翻页)后不再驱动可见页面,仅推进朗读内部页游标
+        val follow = sessionStore.state.value.followReadAloudPosition
         repeat(targetPageIndex - pageIndex) {
             pageIndex++
-            ReadBook.moveToNextPage()
+            if (follow) {
+                withSpeechNavigation { ReadBook.moveToNextPage() }
+            }
         }
         return true
     }
@@ -556,10 +585,12 @@ abstract class BaseReadAloudService : BaseService(),
         if (latestPageIndex < 0) return
         textChapter = latestChapter
         pageIndex = latestPageIndex
-        ReadBook.syncReadAloudPage(
-            chapterIndex = latestChapter.chapter.index,
-            chapterPos = latestChapter.getReadLength(latestPageIndex),
-        )
+        if (sessionStore.state.value.followReadAloudPosition) {
+            ReadBook.syncReadAloudPage(
+                chapterIndex = latestChapter.chapter.index,
+                chapterPos = latestChapter.getReadLength(latestPageIndex),
+            )
+        }
         upTtsProgress(currentProgress)
     }
 
@@ -573,7 +604,7 @@ abstract class BaseReadAloudService : BaseService(),
                 play()
             } ?: run {
                 toLast = true
-                ReadBook.moveToPrevChapter(true)
+                withSpeechNavigation { ReadBook.moveToPrevChapter(true) }
             }
             return
         }
@@ -589,7 +620,7 @@ abstract class BaseReadAloudService : BaseService(),
             } while (!foundPreviousReadableParagraph && nowSpeak > 0)
             if (!foundPreviousReadableParagraph) {
                 toLast = true
-                ReadBook.moveToPrevChapter(true)
+                withSpeechNavigation { ReadBook.moveToPrevChapter(true) }
                 return
             }
             textChapter?.let {
@@ -599,7 +630,7 @@ abstract class BaseReadAloudService : BaseService(),
                 }
                 if (readAloudNumber < it.getReadLength(pageIndex)) {
                     pageIndex--
-                    ReadBook.moveToPrevPage()
+                    withSpeechNavigation { ReadBook.moveToPrevPage() }
                 }
             }
             upTtsProgress(readAloudNumber + 1)
@@ -607,7 +638,7 @@ abstract class BaseReadAloudService : BaseService(),
             play()
         } else {
             toLast = true
-            ReadBook.moveToPrevChapter(true)
+            withSpeechNavigation { ReadBook.moveToPrevChapter(true) }
         }
     }
 
@@ -635,7 +666,7 @@ abstract class BaseReadAloudService : BaseService(),
                     && readAloudNumber >= it.getReadLength(pageIndex + 1)
                 ) {
                     pageIndex++
-                    ReadBook.moveToNextPage()
+                    withSpeechNavigation { ReadBook.moveToNextPage() }
                 }
             }
             upTtsProgress(readAloudNumber + 1)
@@ -658,11 +689,11 @@ abstract class BaseReadAloudService : BaseService(),
             val targetPage = chapter.getPageIndexByCharIndex(targetPosition)
             while (pageIndex < targetPage) {
                 pageIndex++
-                ReadBook.moveToNextPage()
+                withSpeechNavigation { ReadBook.moveToNextPage() }
             }
             while (pageIndex > targetPage) {
                 pageIndex--
-                ReadBook.moveToPrevPage()
+                withSpeechNavigation { ReadBook.moveToPrevPage() }
             }
             upTtsProgress(targetPosition + 1)
         }
@@ -1209,7 +1240,7 @@ abstract class BaseReadAloudService : BaseService(),
         ReadBook.upReadTime()
         toLast = false
         resumeReadAloudInternal()
-        ReadBook.moveToPrevChapter(true, toLast = false)
+        withSpeechNavigation { ReadBook.moveToPrevChapter(true, toLast = false) }
     }
 
     open fun nextChapter() {
@@ -1217,7 +1248,7 @@ abstract class BaseReadAloudService : BaseService(),
         ReadBook.upReadTime()
         AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
-        if (!ReadBook.moveToNextChapter(true)) {
+        if (!withSpeechNavigation { ReadBook.moveToNextChapter(true) }) {
             stopSelf()
         }
     }

+ 4 - 4
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -1076,12 +1076,12 @@ class HttpReadAloudService : BaseReadAloudService(),
             }
             val sleep = exoPlayer.duration / speakTextLength
             val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
-            upTtsProgress(readAloudNumber + start.toInt() + 1)
-            for (i in start..contentList[nowSpeak].length) {
+            upTtsProgress(readAloudNumber + start.toInt())
+            for (i in start until contentList[nowSpeak].length) {
                 val chapterPosition = readAloudNumber + i.toInt()
-                updateReadAloudProgressSnapshot(chapterPosition + 1)
+                updateReadAloudProgressSnapshot(chapterPosition)
                 if (moveToReadAloudPage(chapterPosition)) {
-                    upTtsProgress(chapterPosition + 1)
+                    upTtsProgress(chapterPosition)
                 }
                 delay(sleep)
             }

+ 5 - 4
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -374,13 +374,14 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             super.onRangeStart(utteranceId, start, end, frame)
             if (!isCurrentUtterance(utteranceId)) return
             paragraphStartPos = utteranceStartPos + start
-            readAloudNumber = currentRangePosition(utteranceStartReadAloudNumber, start)
-            updateReadAloudProgressSnapshot(readAloudNumber + 1)
+            // 正在朗读的精确章内位置(段起点 + 段内偏移),保持 readAloudNumber 的"段起点"语义不被污染
+            val position = currentRangePosition(utteranceStartReadAloudNumber, start)
+            updateReadAloudProgressSnapshot(position)
             val msg =
                 "onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
             LogUtils.d(TAG, msg)
-            if (moveToReadAloudPage(readAloudNumber)) {
-                upTtsProgress(readAloudNumber + 1)
+            if (moveToReadAloudPage(position)) {
+                upTtsProgress(position)
             }
         }
 

+ 0 - 107
app/src/main/java/io/legado/app/ui/book/read/ActionReminder.kt

@@ -1,107 +0,0 @@
-package io.legado.app.ui.book.read
-
-import androidx.compose.animation.AnimatedVisibility
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInVertically
-import androidx.compose.animation.slideOutVertically
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.navigationBarsPadding
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.Button
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.text.style.TextOverflow
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.unit.sp
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.card.NormalCard
-import kotlinx.coroutines.delay
-import kotlin.time.Duration.Companion.seconds
-
-@Composable
-fun ActionReminder(
-    modifier: Modifier = Modifier,
-    reminder: ReminderUiState?,
-    onAction: (ReminderUiState) -> Unit,
-    onDismiss: () -> Unit,
-) {
-    val show = reminder != null
-    var currentReminder by remember { mutableStateOf(reminder) }
-    if (reminder != null) {
-        currentReminder = reminder
-    }
-
-    LaunchedEffect(show, reminder?.id) {
-        if (show) {
-            delay(5.seconds)
-            onDismiss()
-        }
-    }
-
-    Box(
-        modifier = modifier
-            .fillMaxSize()
-            .navigationBarsPadding(),
-        contentAlignment = Alignment.BottomCenter,
-    ) {
-        AnimatedVisibility(
-            visible = show,
-            enter = slideInVertically(initialOffsetY = { it }) + fadeIn(),
-            exit = slideOutVertically(targetOffsetY = { it }) + fadeOut(),
-        ) {
-            currentReminder?.let { target ->
-                NormalCard(
-                    modifier = Modifier
-                        .fillMaxWidth()
-                        .padding(horizontal = 16.dp, vertical = 8.dp),
-                ) {
-                    Row(
-                        modifier = Modifier
-                            .fillMaxWidth()
-                            .padding(start = 16.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
-                        verticalAlignment = Alignment.CenterVertically,
-                        horizontalArrangement = Arrangement.SpaceBetween,
-                    ) {
-                        Text(
-                            text = target.message,
-                            fontSize = 13.sp,
-                            style = LegadoTheme.typography.bodyMedium,
-                            color = LegadoTheme.colorScheme.onSurface,
-                            maxLines = 2,
-                            overflow = TextOverflow.Ellipsis,
-                            modifier = Modifier
-                                .weight(1f)
-                                .padding(end = 12.dp),
-                        )
-                        if (target.actionText != null) {
-                            Button(
-                                onClick = {
-                                    onDismiss()
-                                    onAction(target)
-                                },
-                                modifier = Modifier.height(32.dp),
-                                contentPadding = PaddingValues(horizontal = 14.dp, vertical = 0.dp),
-                            ) {
-                                Text(target.actionText, fontSize = 12.sp)
-                            }
-                        }
-                    }
-                }
-            }
-        }
-    }
-}

+ 7 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -166,9 +166,14 @@ class ReadAloudDelegate(
 
     fun nextParagraph() = ReadAloud.nextParagraph(context)
 
-    fun prevChapter() = ReadBook.moveToPrevChapter(upContent = true, toLast = false)
+    /** 朗读面板换章:朗读驱动的章节移动,页面跟随朗读,不视为手动脱离。 */
+    fun prevChapter() = BaseReadAloudService.withSpeechNavigation {
+        ReadBook.moveToPrevChapter(upContent = true, toLast = false)
+    }
 
-    fun nextChapter() = ReadBook.moveToNextChapter(true)
+    fun nextChapter() = BaseReadAloudService.withSpeechNavigation {
+        ReadBook.moveToNextChapter(true)
+    }
 
     // --- 界面入口 ---
 

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

@@ -228,6 +228,8 @@ data class ReadBookUiState(
     // Read aloud / auto page
     val isReadAloudRunning: Boolean = false,
     val isReadAloudPaused: Boolean = false,
+    /** 朗读位置是否跟随当前显示页;手动翻页/跳章后为 false(显示"回到朗读位置"悬浮条)。 */
+    val readAloudFollow: Boolean = true,
     val readAloudEngineName: String = "",
     val readAloudCharacterName: String = "",
     val readAloudRoleType: SpeechRoleType = SpeechRoleType.Narrator,
@@ -237,6 +239,7 @@ data class ReadBookUiState(
     // Seek bar
     val seekProgress: Int = 0,
     val seekMax: Int = 0,
+    val readingAnchorAvailable: Boolean = false,
     // Replace rules
     val replaceRuleEnabled: Boolean = false,
     val effectiveReplaceCount: Int = 0,
@@ -778,6 +781,10 @@ sealed interface ReadBookIntent {
     data object ReadAloudNextParagraph : ReadBookIntent
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
+    /** 页面脱离朗读位置后,跳回朗读所在位置并恢复跟随。 */
+    data object BackToSpeakingPosition : ReadBookIntent
+    /** 页面脱离朗读位置后,从当前显示页重新开始朗读。 */
+    data object ReadAloudFromHere : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
     data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
@@ -1596,6 +1603,9 @@ sealed interface ConfigUpdate {
     data class AutoSuggestDayNight(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }
+    data class ReadingAnchorEnabled(val value: Boolean) : ConfigUpdate {
+        override val actions = emptySet<ConfigUpdateAction>()
+    }
     data class SelectText(val value: Boolean) : ConfigUpdate {
         override val actions = emptySet<ConfigUpdateAction>()
     }

+ 146 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookFloatingActionBar.kt

@@ -0,0 +1,146 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.DarkMode
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.LightMode
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.VolumeUp
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import kotlinx.coroutines.delay
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * 阅读界面底部悬浮胶囊组:独立悬浮窗纵向堆叠(朗读脱离提示、阅读锚点、主题切换提醒)。
+ *
+ * 每个胶囊为「按钮组」形态;多个胶囊同时出现时上下排列,
+ * 各自条件独立、可并存;都为空时不显示。
+ */
+@Composable
+fun ReadBookFloatingActionBar(
+    state: ReadBookUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    val anchorVisible = state.readingAnchorAvailable &&
+        !state.menuVisible && !state.isShowingSearchResult
+    val reminder = state.activeReminder?.takeIf { !state.menuVisible }
+    val readAloudDetached = state.isReadAloudRunning && !state.readAloudFollow &&
+        !state.menuVisible && !state.isShowingSearchResult
+    Box(Modifier.fillMaxSize()) {
+        AnimatedVisibility(
+            visible = anchorVisible || reminder != null || readAloudDetached,
+            enter = fadeIn(),
+            exit = fadeOut(),
+            modifier = Modifier
+                .align(Alignment.BottomCenter)
+                .padding(bottom = 88.dp),
+        ) {
+            Column(
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+                horizontalAlignment = Alignment.CenterHorizontally,
+            ) {
+                if (readAloudDetached) {
+                    ReadAloudDetachedCapsule(onIntent = onIntent)
+                }
+                if (anchorVisible) {
+                    ReadingAnchorCapsule(onIntent = onIntent)
+                }
+                if (reminder != null) {
+                    ThemeReminderCapsule(reminder = reminder, onIntent = onIntent)
+                }
+            }
+        }
+    }
+}
+
+/** 朗读位置脱离当前显示页时:跳回朗读位置,或从当前页重新朗读。 */
+@Composable
+private fun ReadAloudDetachedCapsule(onIntent: (ReadBookIntent) -> Unit) {
+    Row(
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        MediumTonalButton(
+            onClick = { onIntent(ReadBookIntent.BackToSpeakingPosition) },
+            icon = Icons.Default.VolumeUp,
+            text = stringResource(R.string.back_to_speaking_position),
+            contentDescription = stringResource(R.string.back_to_speaking_position),
+        )
+        MediumTonalButton(
+            onClick = { onIntent(ReadBookIntent.ReadAloudFromHere) },
+            icon = Icons.Default.PlayArrow,
+            text = stringResource(R.string.read_aloud_from_here),
+            contentDescription = stringResource(R.string.read_aloud_from_here),
+        )
+    }
+}
+
+@Composable
+private fun ReadingAnchorCapsule(onIntent: (ReadBookIntent) -> Unit) {
+    Row(
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        MediumTonalButton(
+            onClick = { onIntent(ReadBookIntent.RestoreLastBookProgress) },
+            icon = Icons.Default.History,
+            text = stringResource(R.string.return_reading_position),
+            contentDescription = stringResource(R.string.return_reading_position),
+        )
+        MediumTonalButton(
+            onClick = { onIntent(ReadBookIntent.KeepCurrentBookProgress) },
+            icon = Icons.Default.Close,
+            text = stringResource(R.string.dismiss_reading_anchor),
+            contentDescription = stringResource(R.string.dismiss_reading_anchor),
+        )
+    }
+}
+
+@Composable
+private fun ThemeReminderCapsule(
+    reminder: ReminderUiState,
+    onIntent: (ReadBookIntent) -> Unit,
+) {
+    val isDark = (reminder.type as? ReminderType.DayNightReminder)?.targetIsNight == true
+    val actionText = stringResource(
+        if (isDark) R.string.switch_to_dark_mode_action else R.string.switch_to_light_mode_action
+    )
+    LaunchedEffect(reminder.id) {
+        delay(5.seconds)
+        onIntent(ReadBookIntent.DismissReminder)
+    }
+    Row(
+        horizontalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        MediumTonalButton(
+            onClick = {
+                onIntent(ReadBookIntent.DismissReminder)
+                reminder.actionIntent?.let { onIntent(it) }
+            },
+            icon = if (isDark) Icons.Default.DarkMode else Icons.Default.LightMode,
+            text = actionText,
+            contentDescription = actionText,
+        )
+        MediumTonalButton(
+            onClick = { onIntent(ReadBookIntent.DismissReminder) },
+            icon = Icons.Default.Close,
+            text = stringResource(R.string.dismiss_reading_anchor),
+            contentDescription = stringResource(R.string.dismiss_reading_anchor),
+        )
+    }
+}

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

@@ -567,6 +567,7 @@ fun ReadBookRouteScreen(
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
             )
             ReadBookSearchBar(state = state, onIntent = viewModel::onIntent)
+            ReadBookFloatingActionBar(state = state, onIntent = viewModel::onIntent)
             AnimatedVisibility(
                 visible = state.translationStatus == TranslationChapterStatus.Thinking,
                 enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),

+ 0 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -709,10 +709,4 @@ fun ReadBookScreen(
         // Sheets using AppModalBottomSheet are composed unconditionally above
         else -> {}
     }
-
-    ActionReminder(
-        reminder = if (state.menuVisible) null else state.activeReminder,
-        onAction = { reminder -> reminder.actionIntent?.let { onIntent(it) } },
-        onDismiss = { onIntent(ReadBookIntent.DismissReminder) },
-    )
 }

+ 43 - 6
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -742,7 +742,10 @@ class ReadBookViewModel(
             is ReadBookIntent.PrevPage -> ReadBook.moveToPrevPage()
             is ReadBookIntent.NextChapter -> ReadBook.moveToNextChapter(upContent = true)
             is ReadBookIntent.PrevChapter -> ReadBook.moveToPrevChapter(upContent = true, toLast = false)
-            is ReadBookIntent.OpenChapter -> openChapter(intent.index, intent.pos)
+            is ReadBookIntent.OpenChapter -> {
+                ReadBook.saveReadingAnchorBeforeChapterJump(intent.index, intent.pos)
+                openChapter(intent.index, intent.pos)
+            }
             is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex)
             is ReadBookIntent.ToggleMenu -> _uiState.update {
                 if (it.menuVisible) {
@@ -851,13 +854,17 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.RestoreLastBookProgress -> {
-                _uiState.update { it.copy(activeDialog = null) }
                 ReadBook.restoreLastBookProgress()
+                _uiState.update {
+                    syncFromReadBook(it).copy(activeDialog = null)
+                }
             }
 
             is ReadBookIntent.KeepCurrentBookProgress -> {
-                ReadBook.lastBookProgress = null
-                _uiState.update { it.copy(activeDialog = null) }
+                ReadBook.discardReadingAnchor()
+                _uiState.update {
+                    syncFromReadBook(it).copy(activeDialog = null)
+                }
             }
 
             is ReadBookIntent.ToggleReadAloud -> {
@@ -928,7 +935,10 @@ class ReadBookViewModel(
             is ReadBookIntent.ChangeSourceBook -> changeTo(intent.book)
             is ReadBookIntent.ChangeSource -> changeTo(intent.book, intent.toc)
             is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc)
-            is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos)
+            is ReadBookIntent.OpenChapterResult -> {
+                ReadBook.saveReadingAnchorBeforeChapterJump(intent.index, intent.chapterPos)
+                openChapter(intent.index, intent.chapterPos)
+            }
             is ReadBookIntent.SourceEditResult -> upBookSource()
             is ReadBookIntent.ReplaceRuleResult -> replaceRuleDelegate.rulesChanged()
             is ReadBookIntent.BookInfoResult -> {
@@ -973,7 +983,7 @@ class ReadBookViewModel(
                 )
             }
             is ReadBookIntent.SeekToChapter -> {
-                ReadBook.saveCurrentBookProgress()
+                ReadBook.saveReadingAnchorBeforeChapterJump(intent.index)
                 openChapter(intent.index)
             }
 
@@ -1335,6 +1345,8 @@ class ReadBookViewModel(
             is ReadBookIntent.ReadAloudNextParagraph -> readAloudDelegate.nextParagraph()
             is ReadBookIntent.ReadAloudPrevChapter -> readAloudDelegate.prevChapter()
             is ReadBookIntent.ReadAloudNextChapter -> readAloudDelegate.nextChapter()
+            ReadBookIntent.BackToSpeakingPosition -> backToSpeakingPosition()
+            ReadBookIntent.ReadAloudFromHere -> ReadBook.readAloud()
             is ReadBookIntent.SetReadAloudTtsTimer -> readAloudDelegate.setTtsTimer(intent.value)
             is ReadBookIntent.SetFinishCurrentChapterAfterTimer ->
                 readAloudDelegate.setFinishCurrentChapterAfterTimer(intent.value)
@@ -1758,6 +1770,7 @@ class ReadBookViewModel(
                     state.copy(
                         isReadAloudRunning = status != ReadAloudSessionStatus.Idle,
                         isReadAloudPaused = status == ReadAloudSessionStatus.Paused,
+                        readAloudFollow = session.followReadAloudPosition,
                         readAloudEngineName = info.engineName,
                         readAloudCharacterName = info.characterName,
                         readAloudRoleType = info.roleType,
@@ -1993,6 +2006,7 @@ class ReadBookViewModel(
             curTextChapter = textChapter,
             seekProgress = calculateSeekProgress(),
             seekMax = calculateSeekMax(),
+            readingAnchorAvailable = ReadBook.hasReadingAnchor(),
             replaceRuleEnabled = book?.getUseReplaceRule(
                 otherSettingsGateway.currentSettings.replaceEnableDefault
             ) ?: false,
@@ -2123,6 +2137,29 @@ class ReadBookViewModel(
         return bookRepository.getChapter(book.bookUrl, ReadBook.durChapterIndex)
     }
 
+    /**
+     * 回到朗读位置:恢复页面跟随朗读,并跳到朗读所在章节/字符位置。全程不打断当前朗读。
+     */
+    private fun backToSpeakingPosition() {
+        readAloudSessionStore.restoreReadAloudFollow()
+        val speakingChapterIndex = BaseReadAloudService.currentChapterIndex
+        val speakingChapterStart = BaseReadAloudService.currentProgress
+        if (speakingChapterIndex < 0) return
+        // currentProgress 为正在朗读的精确章内位置(onRangeStart 段内偏移上报),整段高亮落在当前段
+        val chapterStart = speakingChapterStart.coerceAtLeast(0)
+        if (speakingChapterIndex != ReadBook.durChapterIndex) {
+            // 跳到朗读位置属于朗读相关的页面移动,不能触发手动脱离
+            BaseReadAloudService.withSpeechNavigation {
+                ReadBook.openChapter(speakingChapterIndex, chapterStart) {
+                    ReadBook.upTextChapterAloudSpan(chapterStart)
+                }
+            }
+        } else {
+            ReadBook.syncReadAloudPage(speakingChapterIndex, chapterStart)
+            ReadBook.upTextChapterAloudSpan(chapterStart)
+        }
+    }
+
     // 开书 / 目录 / 换源 / 进度同步已迁入 [ReadBookLoadDelegate],这里只留外部入口的转发。
 
     suspend fun initReadBookConfig(request: ReadBookInitRequest) =

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

@@ -681,6 +681,11 @@ class ReadConfigUpdateDelegate(
                     readSettingsRepository.setAutoSuggestDayNight(update.value)
                 }
             }
+            is ConfigUpdate.ReadingAnchorEnabled -> {
+                scope.launch {
+                    readSettingsRepository.setReadingAnchorEnabled(update.value)
+                }
+            }
             is ConfigUpdate.SelectText -> {
                 scope.launch {
                     readSettingsRepository.setSelectText(update.value)

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/MoreConfigSheet.kt

@@ -144,6 +144,9 @@ fun MoreConfigSheet(
                 onAutoSuggestDayNightChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.AutoSuggestDayNight(it)))
                 },
+                onReadingAnchorEnabledChange = {
+                    onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.ReadingAnchorEnabled(it)))
+                },
                 onSelectTextChange = {
                     onIntent(ReadBookIntent.UpdateConfig(ConfigUpdate.SelectText(it)))
                 },
@@ -333,6 +336,7 @@ private fun OtherSettings(
     onAutoChangeSourceChange: (Boolean) -> Unit,
     onDefaultSourceChangeAllChange: (Boolean) -> Unit,
     onAutoSuggestDayNightChange: (Boolean) -> Unit,
+    onReadingAnchorEnabledChange: (Boolean) -> Unit,
     onSelectTextChange: (Boolean) -> Unit,
     onNoAnimScrollPageChange: (Boolean) -> Unit,
     onOptimizeRenderChange: (Boolean) -> Unit,
@@ -394,6 +398,12 @@ private fun OtherSettings(
         checked = preferences.autoSuggestDayNight,
         onCheckedChange = onAutoSuggestDayNightChange,
     )
+    TinySwitchSettingItem(
+        title = stringResource(R.string.reading_anchor),
+        description = stringResource(R.string.reading_anchor_summary),
+        checked = preferences.readingAnchorEnabled,
+        onCheckedChange = onReadingAnchorEnabledChange,
+    )
     TinySwitchSettingItem(
         title = stringResource(R.string.selectText),
         checked = preferences.selectText,

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

@@ -5,11 +5,14 @@ import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.ContentQualityStage
 import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookshelf.BookShelfItem
 import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.ImmutableMap
 import kotlinx.collections.immutable.ImmutableSet
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.persistentMapOf
 import kotlinx.collections.immutable.persistentSetOf
 
 @Stable
@@ -18,6 +21,30 @@ data class SearchResultItemUi(
     val shelfState: BookShelfState = BookShelfState.NOT_IN_SHELF,
 )
 
+@Stable
+data class ContentQualityResultUi(
+    val sourceName: String,
+    val sampledChapterCount: Int,
+    val matchedChapterCount: Int,
+    val keywordHits: Int,
+    val excluded: Boolean,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class ContentQualityUiState(
+    val showSheet: Boolean = false,
+    val chapterSpec: String = "1,3,5",
+    val keywords: String = "主角",
+    val skipHeadChars: String = "300",
+    val isRunning: Boolean = false,
+    val stage: ContentQualityStage? = null,
+    val processedBooks: Int = 0,
+    val totalBooks: Int = 0,
+    val currentBookName: String = "",
+    val results: ImmutableMap<String, ContentQualityResultUi> = persistentMapOf(),
+)
+
 @Stable
 data class SearchUiState(
     val query: String = "",
@@ -57,6 +84,7 @@ data class SearchUiState(
     val layoutMode: Int = 0,
     val expandedSourceSavedScrollIndex: Int = 0,
     val expandedSourceSavedScrollOffset: Int = 0,
+    val contentQuality: ContentQualityUiState = ContentQualityUiState(),
 )
 
 data class SearchEmptyScopeAction(
@@ -87,6 +115,12 @@ sealed interface SearchIntent {
     data object ConfirmClearHistory : SearchIntent
     data class SetScopeSheetVisible(val visible: Boolean) : SearchIntent
     data class SetSettingsSheetVisible(val visible: Boolean) : SearchIntent
+    data object OpenContentQuality : SearchIntent
+    data object CloseContentQuality : SearchIntent
+    data class UpdateContentQualityChapterSpec(val value: String) : SearchIntent
+    data class UpdateContentQualityKeywords(val value: String) : SearchIntent
+    data class UpdateContentQualitySkipHeadChars(val value: String) : SearchIntent
+    data object StartContentQuality : SearchIntent
     data class SetLayoutMode(val mode: Int) : SearchIntent
     data class ToggleSourceType(val type: Int) : SearchIntent
     data object ClearAllSourceTypes : SearchIntent

+ 151 - 6
app/src/main/java/io/legado/app/ui/book/search/SearchScreen.kt

@@ -28,6 +28,7 @@ import androidx.compose.material.icons.filled.Layers
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TextField
 import androidx.compose.material3.Text
 import androidx.compose.material3.TopAppBarDefaults
 import androidx.compose.runtime.Composable
@@ -58,6 +59,7 @@ import io.legado.app.R
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.SearchKeyword
 import io.legado.app.domain.model.BookShelfState
+import io.legado.app.domain.model.ContentQualityStage
 import io.legado.app.domain.model.MatchMode
 import io.legado.app.ui.main.bookCoverSharedElementKey
 import io.legado.app.ui.main.bookshelf.BookShelfItem
@@ -82,6 +84,7 @@ import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.settingItem.CompactDropdownSettingItem
+import io.legado.app.ui.widget.components.settingItem.CompactClickableSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -505,6 +508,9 @@ fun SearchScreen(
                                                     sharedTransitionScope = sharedTransitionScope,
                                                     animatedVisibilityScope = animatedVisibilityScope,
                                                     sourceSectionIndex = groupIndex,
+                                                    qualityMaskForBook = { book ->
+                                                        state.contentQuality.results[qualityKey(book)]?.excluded == true
+                                                    },
                                                 )
                                             }
                                         }
@@ -537,16 +543,22 @@ fun SearchScreen(
                                                 item.book.bookUrl,
                                                 "search:${item.book.origin}"
                                             )
+                                            val qualityMasked =
+                                                state.contentQuality.results[qualityKey(item.book)]?.excluded == true
                                             SearchBookListItem(
                                                 book = item.book,
                                                 shelfState = item.shelfState,
-                                                onClick = {
-                                                    onIntent(
-                                                        SearchIntent.OpenSearchBook(
-                                                            item.book,
-                                                            sharedCoverKey
+                                                onClick = if (qualityMasked) {
+                                                    null
+                                                } else {
+                                                    {
+                                                        onIntent(
+                                                            SearchIntent.OpenSearchBook(
+                                                                item.book,
+                                                                sharedCoverKey
+                                                            )
                                                         )
-                                                    )
+                                                    }
                                                 },
                                                 onLongClick = { book, coverKey ->
                                                     previewBook = book
@@ -556,6 +568,7 @@ fun SearchScreen(
                                                 animatedVisibilityScope = animatedVisibilityScope,
                                                 sharedCoverKey = sharedCoverKey,
                                                 sourceCount = item.book.origins.size,
+                                                qualityMasked = qualityMasked,
                                             )
                                         }
 
@@ -691,6 +704,13 @@ fun SearchScreen(
                 }
             )
 
+            CompactClickableSettingItem(
+                title = stringResource(R.string.content_quality_detection),
+                description = stringResource(R.string.content_quality_detection_summary),
+                imageVector = Icons.Default.Book,
+                onClick = { onIntent(SearchIntent.OpenContentQuality) },
+            )
+
             Row(
                 modifier = Modifier
                     .fillMaxWidth()
@@ -743,6 +763,12 @@ fun SearchScreen(
         }
     }
 
+    ContentQualitySheet(
+        state = state.contentQuality,
+        resultCount = state.results.size,
+        onIntent = onIntent,
+    )
+
     val resultsByBookUrl = remember(state.results) {
         state.results.associateBy { it.book.bookUrl }
     }
@@ -787,6 +813,125 @@ fun SearchScreen(
     )
 }
 
+private fun qualityKey(book: SearchBook): String = "${book.origin}:${book.bookUrl}"
+
+@Composable
+private fun ContentQualitySheet(
+    state: ContentQualityUiState,
+    resultCount: Int,
+    onIntent: (SearchIntent) -> Unit,
+) {
+    AppModalBottomSheet(
+        show = state.showSheet,
+        onDismissRequest = { onIntent(SearchIntent.CloseContentQuality) },
+        title = stringResource(R.string.content_quality_detection),
+        endAction = {
+            SmallPlainButton(
+                onClick = { onIntent(SearchIntent.StartContentQuality) },
+                enabled = !state.isRunning && resultCount > 0,
+                icon = AppIcons.Check,
+                text = stringResource(R.string.content_quality_start),
+                contentDescription = stringResource(R.string.content_quality_start),
+            )
+        },
+    ) {
+        Column(
+            modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(10.dp),
+        ) {
+            TextField(
+                value = state.chapterSpec,
+                onValueChange = { onIntent(SearchIntent.UpdateContentQualityChapterSpec(it)) },
+                modifier = Modifier.fillMaxWidth(),
+                label = { Text(stringResource(R.string.content_quality_chapters)) },
+                supportingText = { Text(stringResource(R.string.content_quality_chapters_summary)) },
+                singleLine = true,
+            )
+            TextField(
+                value = state.keywords,
+                onValueChange = { onIntent(SearchIntent.UpdateContentQualityKeywords(it)) },
+                modifier = Modifier.fillMaxWidth(),
+                label = { Text(stringResource(R.string.content_quality_keywords)) },
+                supportingText = { Text(stringResource(R.string.content_quality_keywords_summary)) },
+                singleLine = true,
+            )
+            TextField(
+                value = state.skipHeadChars,
+                onValueChange = { onIntent(SearchIntent.UpdateContentQualitySkipHeadChars(it)) },
+                modifier = Modifier.fillMaxWidth(),
+                label = { Text(stringResource(R.string.content_quality_skip_chars)) },
+                supportingText = { Text(stringResource(R.string.content_quality_skip_chars_summary)) },
+                singleLine = true,
+            )
+
+            AppText(
+                text = stringResource(R.string.content_quality_pipeline),
+                style = LegadoTheme.typography.labelMediumEmphasized,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+            ContentQualityPipeline(stage = state.stage)
+
+            if (state.isRunning) {
+                val progress = if (state.totalBooks == 0) null else {
+                    state.processedBooks.toFloat() / state.totalBooks
+                }
+                AppCircularProgressIndicator(progress = progress)
+                AppText(
+                    text = stringResource(
+                        R.string.content_quality_progress,
+                        state.processedBooks,
+                        state.totalBooks,
+                        state.currentBookName,
+                    ),
+                    style = LegadoTheme.typography.bodySmall,
+                )
+            } else if (state.results.isNotEmpty()) {
+                val excludedCount = state.results.values.count { it.excluded }
+                AppText(
+                    text = stringResource(
+                        R.string.content_quality_result,
+                        state.results.size,
+                        excludedCount,
+                    ),
+                    style = LegadoTheme.typography.bodySmall,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ContentQualityPipeline(stage: ContentQualityStage?) {
+    val stages = listOf(
+        ContentQualityStage.ChapterCount to R.string.content_quality_stage_chapter_count,
+        ContentQualityStage.ContentCleaning to R.string.content_quality_stage_cleaning,
+        ContentQualityStage.KeywordMatching to R.string.content_quality_stage_matching,
+        ContentQualityStage.Excluding to R.string.content_quality_stage_excluding,
+    )
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(4.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        stages.forEachIndexed { index, (itemStage, labelRes) ->
+            val color = when {
+                stage == ContentQualityStage.Completed || stage == itemStage -> LegadoTheme.colorScheme.primary
+                stage != null && stage.ordinal > itemStage.ordinal -> LegadoTheme.colorScheme.secondary
+                else -> LegadoTheme.colorScheme.onSurfaceVariant
+            }
+            AppText(
+                text = stringResource(labelRes),
+                color = color,
+                style = LegadoTheme.typography.labelSmall,
+                maxLines = 1,
+            )
+            if (index < stages.lastIndex) {
+                AppText(text = "→", color = LegadoTheme.colorScheme.outline)
+            }
+        }
+    }
+}
+
 private data class SearchFloatingSummary(
     val resultText: String?,
     val sourceText: String?,

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

@@ -46,6 +46,7 @@ fun SearchSourceSection(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sourceSectionIndex: Int = 0,
+    qualityMaskForBook: (SearchBook) -> Boolean = { false },
 ) {
     if (items.isEmpty()) return
 
@@ -98,15 +99,17 @@ fun SearchSourceSection(
                     item.book.bookUrl,
                     "search_source:$sourceSectionIndex:$index"
                 )
+                val qualityMasked = qualityMaskForBook(item.book)
                 SearchBookGridItem(
                     book = item.book,
                     shelfState = item.shelfState,
-                    onClick = { onClickBook(item.book, sharedCoverKey) },
+                    onClick = { if (!qualityMasked) onClickBook(item.book, sharedCoverKey) },
                     onLongClick = onLongClickBook,
                     modifier = Modifier.width(100.dp),
                     sharedTransitionScope = sharedTransitionScope,
                     animatedVisibilityScope = animatedVisibilityScope,
                     sharedCoverKey = sharedCoverKey,
+                    qualityMasked = qualityMasked,
                 )
             }
         }

+ 112 - 0
app/src/main/java/io/legado/app/ui/book/search/SearchViewModel.kt

@@ -8,11 +8,14 @@ import io.legado.app.data.local.preferences.LocalPreferencesKeys
 import io.legado.app.data.repository.SettingsRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.domain.model.BookSearchScope
+import io.legado.app.domain.model.ContentQualityConfig
+import io.legado.app.domain.model.ContentQualityProgress
 import io.legado.app.domain.model.MatchMode
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.BookSearchControl
 import io.legado.app.domain.usecase.BookSearchRequest
 import io.legado.app.domain.usecase.BookShelfKey
+import io.legado.app.domain.usecase.CheckBookContentQualityUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
@@ -21,6 +24,7 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
 import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.persistentSetOf
 import kotlinx.collections.immutable.toImmutableList
+import kotlinx.collections.immutable.toImmutableMap
 import kotlinx.collections.immutable.toImmutableSet
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CompletableDeferred
@@ -49,6 +53,7 @@ class SearchViewModel(
     private val searchBooksUseCase: SearchBooksUseCase,
     private val exploreBooksUseCase: ExploreBooksUseCase,
     private val addToBookshelfUseCase: AddToBookshelfUseCase,
+    private val checkBookContentQualityUseCase: CheckBookContentQualityUseCase,
     private val localPreferencesRepository: SettingsRepository,
 ) : ViewModel() {
 
@@ -101,6 +106,7 @@ class SearchViewModel(
     private var currentSearchPage = 1
     private var resultCountBeforeCurrentPage = 0
     private var wasSearching = false
+    private var contentQualityJob: Job? = null
 
     init {
         syncScopeState()
@@ -203,6 +209,36 @@ class SearchViewModel(
                 _uiState.update { it.copy(showSettingsSheet = intent.visible) }
             }
 
+            SearchIntent.OpenContentQuality -> {
+                _uiState.update {
+                    it.copy(
+                        showSettingsSheet = false,
+                        contentQuality = it.contentQuality.copy(showSheet = true),
+                    )
+                }
+            }
+
+            SearchIntent.CloseContentQuality -> {
+                contentQualityJob?.cancel()
+                _uiState.update {
+                    it.copy(contentQuality = it.contentQuality.copy(showSheet = false, isRunning = false))
+                }
+            }
+
+            is SearchIntent.UpdateContentQualityChapterSpec -> _uiState.update {
+                it.copy(contentQuality = it.contentQuality.copy(chapterSpec = intent.value))
+            }
+
+            is SearchIntent.UpdateContentQualityKeywords -> _uiState.update {
+                it.copy(contentQuality = it.contentQuality.copy(keywords = intent.value))
+            }
+
+            is SearchIntent.UpdateContentQualitySkipHeadChars -> _uiState.update {
+                it.copy(contentQuality = it.contentQuality.copy(skipHeadChars = intent.value))
+            }
+
+            SearchIntent.StartContentQuality -> startContentQuality()
+
             is SearchIntent.SetLayoutMode -> {
                 _uiState.update { it.copy(layoutMode = intent.mode) }
                 viewModelScope.launch {
@@ -342,6 +378,7 @@ class SearchViewModel(
 
     override fun onCleared() {
         stopSearch(manualStop = false)
+        contentQualityJob?.cancel()
         super.onCleared()
     }
 
@@ -511,6 +548,7 @@ class SearchViewModel(
                 showExpandedSource = false,
                 expandedSourceSavedScrollIndex = 0,
                 expandedSourceSavedScrollOffset = 0,
+                contentQuality = ContentQualityUiState(),
             )
         }
 
@@ -630,6 +668,7 @@ class SearchViewModel(
 
     private fun clearSearchResults() {
         stopSearch(manualStop = true)
+        contentQualityJob?.cancel()
         searchResultBooks.clear()
         queryFlow.value = ""
         _uiState.update {
@@ -654,6 +693,79 @@ class SearchViewModel(
                 showExpandedSource = false,
                 expandedSourceSavedScrollIndex = 0,
                 expandedSourceSavedScrollOffset = 0,
+                contentQuality = ContentQualityUiState(),
+            )
+        }
+    }
+
+    private fun startContentQuality() {
+        val state = _uiState.value
+        if (state.isSearching || state.results.isEmpty()) return
+        val configState = state.contentQuality
+        val keywords = configState.keywords
+            .split(',', ',', ';', ';', '\n', ' ', '\t')
+            .map(String::trim)
+            .filter(String::isNotBlank)
+            .distinct()
+        if (keywords.isEmpty()) return
+        contentQualityJob?.cancel()
+        _uiState.update {
+            it.copy(
+                contentQuality = configState.copy(
+                    isRunning = true,
+                    stage = null,
+                    processedBooks = 0,
+                    totalBooks = state.results.size,
+                    currentBookName = "",
+                    results = kotlinx.collections.immutable.persistentMapOf(),
+                )
+            )
+        }
+        contentQualityJob = viewModelScope.launch(Dispatchers.Default) {
+            val config = ContentQualityConfig(
+                chapterSpec = configState.chapterSpec,
+                keywords = keywords,
+                skipHeadChars = configState.skipHeadChars.toIntOrNull()?.coerceAtLeast(0) ?: 300,
+            )
+            runCatching {
+                checkBookContentQualityUseCase.execute(
+                    books = state.results.map { it.book },
+                    config = config,
+                    onProgress = ::updateContentQualityProgress,
+                )
+            }.onSuccess { results ->
+                val resultMap = results.associate { result ->
+                    result.bookKey to ContentQualityResultUi(
+                        sourceName = result.sourceName,
+                        sampledChapterCount = result.sampledChapterCount,
+                        matchedChapterCount = result.matchedChapterCount,
+                        keywordHits = result.keywordHits,
+                        excluded = result.excluded,
+                        errorMessage = result.errorMessage,
+                    )
+                }.toImmutableMap()
+                _uiState.update {
+                    it.copy(contentQuality = it.contentQuality.copy(isRunning = false, results = resultMap))
+                }
+            }.onFailure {
+                if (it !is CancellationException) {
+                    _uiState.update { state ->
+                        state.copy(contentQuality = state.contentQuality.copy(isRunning = false))
+                    }
+                }
+            }
+        }
+    }
+
+    private fun updateContentQualityProgress(progress: ContentQualityProgress) {
+        _uiState.update { state ->
+            state.copy(
+                contentQuality = state.contentQuality.copy(
+                    stage = progress.stage,
+                    processedBooks = progress.processedBooks,
+                    totalBooks = progress.totalBooks,
+                    currentBookName = progress.currentBookName,
+                )
             )
         }
     }

+ 2 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigContract.kt

@@ -37,6 +37,7 @@ data class ReadConfigUiState(
     val selectVibrator: Boolean = false,
     val autoChangeSource: Boolean = true,
     val autoSuggestDayNight: Boolean = false,
+    val readingAnchorEnabled: Boolean = true,
     val selectText: Boolean = true,
     val noAnimScrollPage: Boolean = false,
     val clickImgWay: String = "2",
@@ -106,6 +107,7 @@ sealed interface ReadConfigIntent {
     data class ShowReadTitleAdditionChanged(val value: Boolean) : ReadConfigIntent
     data class ShowMenuIconChanged(val value: Boolean) : ReadConfigIntent
     data class AutoSuggestDayNightChanged(val value: Boolean) : ReadConfigIntent
+    data class ReadingAnchorChanged(val value: Boolean) : ReadConfigIntent
     data class PageKeysChanged(val prevKeys: String, val nextKeys: String) : ReadConfigIntent
 }
 

+ 9 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigScreen.kt

@@ -344,6 +344,15 @@ fun ReadConfigScreen(
                     }
                 )
 
+                SwitchSettingItem(
+                    title = stringResource(R.string.reading_anchor),
+                    description = stringResource(R.string.reading_anchor_summary),
+                    checked = settings.readingAnchorEnabled,
+                    onCheckedChange = {
+                        onIntent(ReadConfigIntent.ReadingAnchorChanged(it))
+                    }
+                )
+
                 SwitchSettingItem(
                     title = stringResource(R.string.selectText),
                     checked = settings.selectText,

+ 2 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfigViewModel.kt

@@ -136,6 +136,7 @@ private fun ReadConfigIntent.toSettingsTransform(): (ReadSettings) -> ReadSettin
     is ReadConfigIntent.SelectVibratorChanged -> { settings -> settings.copy(selectVibrator = value) }
     is ReadConfigIntent.AutoChangeSourceChanged -> { settings -> settings.copy(autoChangeSource = value) }
     is ReadConfigIntent.AutoSuggestDayNightChanged -> { settings -> settings.copy(autoSuggestDayNight = value) }
+    is ReadConfigIntent.ReadingAnchorChanged -> { settings -> settings.copy(readingAnchorEnabled = value) }
     is ReadConfigIntent.SelectTextChanged -> { settings -> settings.copy(selectText = value) }
     is ReadConfigIntent.NoAnimScrollPageChanged -> { settings -> settings.copy(noAnimScrollPage = value) }
     is ReadConfigIntent.ClickImgWayChanged -> { settings -> settings.copy(clickImgWay = value) }
@@ -205,6 +206,7 @@ private fun ReadSettings.toUiState(
         selectVibrator = selectVibrator,
         autoChangeSource = autoChangeSource,
         autoSuggestDayNight = autoSuggestDayNight,
+        readingAnchorEnabled = readingAnchorEnabled,
         selectText = selectText,
         noAnimScrollPage = noAnimScrollPage,
         clickImgWay = clickImgWay,

+ 49 - 2
app/src/main/java/io/legado/app/ui/widget/components/book/SearchBookItem.kt

@@ -5,6 +5,7 @@ import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.foundation.ExperimentalFoundationApi
 import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.background
 import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -27,9 +28,11 @@ import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.blur
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
@@ -57,6 +60,7 @@ fun SearchBookListItem(
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
     sourceCount: Int? = null,
+    qualityMasked: Boolean = false,
 ) {
     Row(
         modifier = modifier
@@ -76,7 +80,9 @@ fun SearchBookListItem(
                 name = book.name,
                 author = book.author,
                 path = book.coverUrl,
-                modifier = Modifier.fillMaxSize(),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .then(if (qualityMasked) Modifier.blur(8.dp) else Modifier),
                 sourceOrigin = book.origin,
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = animatedVisibilityScope,
@@ -84,6 +90,25 @@ fun SearchBookListItem(
                 showLoadingPlaceholder = sharedCoverKey == null
             )
 
+            if (qualityMasked) {
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .background(LegadoTheme.colorScheme.scrim.copy(alpha = 0.58f)),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    AppText(
+                        text = stringResource(R.string.content_quality_excluded),
+                        color = LegadoTheme.colorScheme.onSurface,
+                        style = LegadoTheme.typography.labelSmallEmphasized,
+                        modifier = Modifier.padding(horizontal = 2.dp),
+                        textAlign = TextAlign.Center,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+
             val shelfIcon = when (shelfState) {
                 BookShelfState.IN_SHELF -> Icons.Default.Check
                 BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle
@@ -201,6 +226,7 @@ fun SearchBookGridItem(
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
+    qualityMasked: Boolean = false,
 ) {
     Column(
         modifier = modifier
@@ -220,7 +246,9 @@ fun SearchBookGridItem(
                 name = book.name,
                 author = book.author,
                 path = book.coverUrl,
-                modifier = Modifier.fillMaxSize(),
+                modifier = Modifier
+                    .fillMaxSize()
+                    .then(if (qualityMasked) Modifier.blur(8.dp) else Modifier),
                 sourceOrigin = book.origin,
                 sharedTransitionScope = sharedTransitionScope,
                 animatedVisibilityScope = animatedVisibilityScope,
@@ -228,6 +256,25 @@ fun SearchBookGridItem(
                 showLoadingPlaceholder = sharedCoverKey == null
             )
 
+            if (qualityMasked) {
+                Box(
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .background(LegadoTheme.colorScheme.scrim.copy(alpha = 0.58f)),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    AppText(
+                        text = stringResource(R.string.content_quality_excluded),
+                        color = LegadoTheme.colorScheme.onSurface,
+                        style = LegadoTheme.typography.labelSmallEmphasized,
+                        modifier = Modifier.padding(horizontal = 2.dp),
+                        textAlign = TextAlign.Center,
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+
             val shelfIcon = when (shelfState) {
                 BookShelfState.IN_SHELF -> Icons.Default.Check
                 BookShelfState.SAME_NAME_AUTHOR -> Icons.Default.Shuffle

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

@@ -189,6 +189,8 @@
     <string name="read_aloud">朗读</string>
     <string name="read_aloud_t">正在朗读</string>
     <string name="read_aloud_s">点击打开阅读界面</string>
+    <string name="back_to_speaking_position">回到朗读位置</string>
+    <string name="read_aloud_from_here">从此处朗读</string>
     <string name="audio_play">播放</string>
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">点击打开播放界面</string>
@@ -1331,6 +1333,8 @@
     <string name="source_edit_text_max_line">源编辑框最大行数</string>
     <string name="source_edit_max_line_summary">%s,设置行数小于屏幕可显示的最大行数可以更方便的滑动到其他的字段进行编辑</string>
     <string name="restore_last_book_process">是否恢复到跳转前的阅读进度?</string>
+    <string name="return_reading_position">返回原阅读位置</string>
+    <string name="dismiss_reading_anchor">关闭</string>
     <string name="confirm_skip_to_chapter">是否跳转到该章节?</string>
     <string name="restore_progress">恢复进度</string>
     <string name="sync_progress">同步进度</string>
@@ -1906,6 +1910,23 @@
     <string name="search_book_source_count">%1$d</string>
     <string name="search_layout_list">列表布局</string>
     <string name="search_layout_source_grouped">来源分组</string>
+    <string name="content_quality_detection">内容质量检测</string>
+    <string name="content_quality_detection_summary">过滤章节内容异常或与目标书籍无关的书源</string>
+    <string name="content_quality_chapters">指定检测章节</string>
+    <string name="content_quality_chapters_summary">例如:1,3,5 或 1-5;若书源章节数不足(少于指定的最大章节号),该书源将被自动排除</string>
+    <string name="content_quality_keywords">主角名或关键词</string>
+    <string name="content_quality_keywords_summary">多个关键词可用逗号或空格分隔</string>
+    <string name="content_quality_skip_chars">跳过章节头部字数</string>
+    <string name="content_quality_skip_chars_summary">忽略广告、版权声明等头部内容</string>
+    <string name="content_quality_pipeline">章节数量 → 内容清洗 → 多关键词匹配 → 自动排除</string>
+    <string name="content_quality_start">开始检测</string>
+    <string name="content_quality_progress">已检测 %1$d/%2$d:%3$s</string>
+    <string name="content_quality_result">已检测 %1$d 本,排除 %2$d 个书源</string>
+    <string name="content_quality_excluded">内容质量检测已排除</string>
+    <string name="content_quality_stage_chapter_count">章节数</string>
+    <string name="content_quality_stage_cleaning">清洗</string>
+    <string name="content_quality_stage_matching">匹配</string>
+    <string name="content_quality_stage_excluding">排除</string>
     <string name="download_cache_config">下载与缓存</string>
     <string name="http_cache">HTTP缓存</string>
     <string name="cover_cache">封面缓存</string>
@@ -2514,8 +2535,12 @@
     <!-- 日夜间/深浅色主题切换提醒 -->
     <string name="auto_switch_theme_reminder_title">主题模式切换提醒</string>
     <string name="auto_switch_theme_reminder_desc">根据环境光提醒切换浅色/深色模式</string>
+    <string name="reading_anchor">阅读锚点</string>
+    <string name="reading_anchor_summary">跳转章节两次及以上后,显示返回原阅读位置的悬浮按钮</string>
     <string name="switch_to_dark_mode_tip">当前环境光较暗,是否切换至深色模式?</string>
     <string name="switch_to_light_mode_tip">当前环境光较亮,是否切换至浅色模式?</string>
+    <string name="switch_to_dark_mode_action">切换到深色模式</string>
+    <string name="switch_to_light_mode_action">切换到浅色模式</string>
     <string name="switch_action">切换</string>
     <string name="cover_albums">封面图集</string>
     <string name="manage_cover_albums">管理封面图集</string>

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

@@ -144,6 +144,8 @@
     <string name="read_aloud">朗讀</string>
     <string name="read_aloud_t">正在朗讀</string>
     <string name="read_aloud_s">點擊打開閲讀介面</string>
+    <string name="back_to_speaking_position">回到朗讀位置</string>
+    <string name="read_aloud_from_here">從此處朗讀</string>
     <string name="audio_play">播放</string>
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">點擊打開播放介面</string>
@@ -1160,6 +1162,25 @@
     <string name="source_edit_text_max_line">源编辑框最大行数</string>
     <string name="source_edit_max_line_summary">%s,设置行数小于屏幕可显示的最大行数可以更方便的滑动到其他的字段进行编辑</string>
     <string name="restore_last_book_process">是否恢复到跳转前的阅读进度?</string>
+    <string name="return_reading_position">返回原閱讀位置</string>
+    <string name="dismiss_reading_anchor">關閉</string>
+    <string name="content_quality_detection">內容質量檢測</string>
+    <string name="content_quality_detection_summary">過濾章節內容異常或與目標書籍無關的書源</string>
+    <string name="content_quality_chapters">指定檢測章節</string>
+    <string name="content_quality_chapters_summary">例如:1,3,5 或 1-5;若書源章節數不足(少於指定的最大章節號),該書源將被自動排除</string>
+    <string name="content_quality_keywords">主角名或關鍵詞</string>
+    <string name="content_quality_keywords_summary">多個關鍵詞可用逗號或空格分隔</string>
+    <string name="content_quality_skip_chars">跳過章節頭部字數</string>
+    <string name="content_quality_skip_chars_summary">忽略廣告、版權聲明等頭部內容</string>
+    <string name="content_quality_pipeline">章節數量 → 內容清洗 → 多關鍵詞匹配 → 自動排除</string>
+    <string name="content_quality_start">開始檢測</string>
+    <string name="content_quality_progress">已檢測 %1$d/%2$d:%3$s</string>
+    <string name="content_quality_result">已檢測 %1$d 本,排除 %2$d 個書源</string>
+    <string name="content_quality_excluded">內容質量檢測已排除</string>
+    <string name="content_quality_stage_chapter_count">章節數</string>
+    <string name="content_quality_stage_cleaning">清洗</string>
+    <string name="content_quality_stage_matching">匹配</string>
+    <string name="content_quality_stage_excluding">排除</string>
     <string name="search_scope">搜索范围</string>
     <string name="toggle_search_scope">切换</string>
     <string name="sure_clear_search_history">是否确认清除所有搜索历史记录</string>
@@ -2397,8 +2418,12 @@
     <!-- 日夜間/深淺色主題切換提醒 -->
     <string name="auto_switch_theme_reminder_title">主題模式切換提醒</string>
     <string name="auto_switch_theme_reminder_desc">根據環境光提醒切換淺色/深色模式</string>
+    <string name="reading_anchor">閱讀錨點</string>
+    <string name="reading_anchor_summary">跳轉章節兩次或以上後,顯示返回原閱讀位置的懸浮按鈕</string>
     <string name="switch_to_dark_mode_tip">當前環境光較暗,是否切換至深色模式?</string>
     <string name="switch_to_light_mode_tip">當前環境光較亮,是否切換至淺色模式?</string>
+    <string name="switch_to_dark_mode_action">切換至深色模式</string>
+    <string name="switch_to_light_mode_action">切換至淺色模式</string>
     <string name="switch_action">切換</string>
     <string name="cover_albums">封面圖集</string>
     <string name="manage_cover_albums">管理封面圖集</string>

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

@@ -146,6 +146,8 @@
     <string name="read_aloud">朗讀</string>
     <string name="read_aloud_t">正在朗讀</string>
     <string name="read_aloud_s">點擊打開閱讀介面</string>
+    <string name="back_to_speaking_position">回到朗讀位置</string>
+    <string name="read_aloud_from_here">從此處朗讀</string>
     <string name="audio_play">播放</string>
     <string name="audio_play_t">正在播放</string>
     <string name="audio_play_s">點擊打開播放介面</string>
@@ -1163,6 +1165,25 @@
     <string name="source_edit_text_max_line">源編輯框最大行數</string>
     <string name="source_edit_max_line_summary">%s,設定行數小於螢幕可顯示的最大行數可以更方便的滑動到其他的欄位進行編輯</string>
     <string name="restore_last_book_process">是否復原到跳轉前的閱讀進度?</string>
+    <string name="return_reading_position">返回原閱讀位置</string>
+    <string name="dismiss_reading_anchor">關閉</string>
+    <string name="content_quality_detection">內容品質檢測</string>
+    <string name="content_quality_detection_summary">過濾章節內容異常或與目標書籍無關的書源</string>
+    <string name="content_quality_chapters">指定檢測章節</string>
+    <string name="content_quality_chapters_summary">例如:1,3,5 或 1-5;若書源章節數不足(少於指定的最大章節號),該書源將被自動排除</string>
+    <string name="content_quality_keywords">主角名或關鍵詞</string>
+    <string name="content_quality_keywords_summary">多個關鍵詞可用逗號或空格分隔</string>
+    <string name="content_quality_skip_chars">跳過章節頭部字數</string>
+    <string name="content_quality_skip_chars_summary">忽略廣告、版權聲明等頭部內容</string>
+    <string name="content_quality_pipeline">章節數量 → 內容清洗 → 多關鍵詞匹配 → 自動排除</string>
+    <string name="content_quality_start">開始檢測</string>
+    <string name="content_quality_progress">已檢測 %1$d/%2$d:%3$s</string>
+    <string name="content_quality_result">已檢測 %1$d 本,排除 %2$d 個書源</string>
+    <string name="content_quality_excluded">內容品質檢測已排除</string>
+    <string name="content_quality_stage_chapter_count">章節數</string>
+    <string name="content_quality_stage_cleaning">清洗</string>
+    <string name="content_quality_stage_matching">匹配</string>
+    <string name="content_quality_stage_excluding">排除</string>
     <string name="search_scope">搜尋範圍</string>
     <string name="toggle_search_scope">切換</string>
     <string name="sure_clear_search_history">是否確認清除所有搜尋歷史記錄</string>
@@ -2402,8 +2423,12 @@
     <!-- 日夜間/深淺色主題切換提醒 -->
     <string name="auto_switch_theme_reminder_title">主題模式切換提醒</string>
     <string name="auto_switch_theme_reminder_desc">根據環境光提醒切換淺色/深色模式</string>
+    <string name="reading_anchor">閱讀錨點</string>
+    <string name="reading_anchor_summary">跳轉章節兩次或以上後,顯示返回原閱讀位置的懸浮按鈕</string>
     <string name="switch_to_dark_mode_tip">目前環境光較暗,是否切換至深色模式?</string>
     <string name="switch_to_light_mode_tip">目前環境光較亮,是否切換至淺色模式?</string>
+    <string name="switch_to_dark_mode_action">切換至深色模式</string>
+    <string name="switch_to_light_mode_action">切換至淺色模式</string>
     <string name="switch_action">切換</string>
     <string name="cover_albums">封面圖集</string>
     <string name="manage_cover_albums">管理封面圖集</string>

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

@@ -219,6 +219,8 @@
     <string name="read_aloud">Speech</string>
     <string name="read_aloud_t">Speaking</string>
     <string name="read_aloud_s">Click to open the Reading</string>
+    <string name="back_to_speaking_position">Back to speaking position</string>
+    <string name="read_aloud_from_here">Read aloud from here</string>
     <string name="audio_play">Play</string>
     <string name="audio_play_t">Playing</string>
     <string name="audio_play_s">Click to open the Playing</string>
@@ -1388,6 +1390,8 @@
     <string name="source_edit_text_max_line">Maximum Number of Rows in the Source Edit Box</string>
     <string name="source_edit_max_line_summary">%s,Setting the number of rows less than the maximum number of rows that can be displayed on the screen makes it easier to slide to other fields for editing.</string>
     <string name="restore_last_book_process">Whether to return to the reading progress before the jump?</string>
+    <string name="return_reading_position">Return to reading position</string>
+    <string name="dismiss_reading_anchor">Dismiss</string>
     <string name="confirm_skip_to_chapter">Skip to this chapter?</string>
     <string name="restore_progress">Restore Progress</string>
     <string name="sync_progress">Sync Progress</string>
@@ -1965,6 +1969,23 @@
     <string name="search_book_source_count">%1$d</string>
     <string name="search_layout_list">List layout</string>
     <string name="search_layout_source_grouped">Source grouped</string>
+    <string name="content_quality_detection">Content quality detection</string>
+    <string name="content_quality_detection_summary">Filter sources with suspicious or unrelated chapter content</string>
+    <string name="content_quality_chapters">Chapters to sample</string>
+    <string name="content_quality_chapters_summary">Examples: 1,3,5 or 1-5; sources with fewer chapters than the largest specified number are excluded</string>
+    <string name="content_quality_keywords">Protagonist or keywords</string>
+    <string name="content_quality_keywords_summary">Separate multiple keywords with commas or spaces</string>
+    <string name="content_quality_skip_chars">Characters to skip at chapter head</string>
+    <string name="content_quality_skip_chars_summary">Ignore boilerplate and ads before matching</string>
+    <string name="content_quality_pipeline">Chapter count → content cleaning → keyword matching → auto exclude</string>
+    <string name="content_quality_start">Run detection</string>
+    <string name="content_quality_progress">Checked %1$d/%2$d: %3$s</string>
+    <string name="content_quality_result">Checked %1$d books, excluded %2$d sources</string>
+    <string name="content_quality_excluded">Excluded by quality check</string>
+    <string name="content_quality_stage_chapter_count">Chapters</string>
+    <string name="content_quality_stage_cleaning">Clean</string>
+    <string name="content_quality_stage_matching">Match</string>
+    <string name="content_quality_stage_excluding">Exclude</string>
     <string name="download_cache_config">Download &amp; Cache</string>
     <string name="http_cache">HTTP Cache</string>
     <string name="cover_cache">Cover Cache</string>
@@ -2519,8 +2540,12 @@
     <!-- Day/Night Theme Switch Reminder -->
     <string name="auto_switch_theme_reminder_title">Theme Mode Switch Reminder</string>
     <string name="auto_switch_theme_reminder_desc">Remind to switch light/dark mode based on ambient light</string>
+    <string name="reading_anchor">Reading anchor</string>
+    <string name="reading_anchor_summary">Show a floating button to return to the original reading position after jumping chapters twice or more</string>
     <string name="switch_to_dark_mode_tip">Ambient light is low. Switch to dark mode?</string>
     <string name="switch_to_light_mode_tip">Ambient light is bright. Switch to light mode?</string>
+    <string name="switch_to_dark_mode_action">Switch to dark mode</string>
+    <string name="switch_to_light_mode_action">Switch to light mode</string>
     <string name="switch_action">Switch</string>
     <string name="cover_albums">Cover albums</string>
     <string name="manage_cover_albums">Manage cover albums</string>

+ 4 - 2
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -14,11 +14,11 @@ import kotlin.reflect.full.primaryConstructor
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化映射覆盖 ReadSettings 全部 109 个字段`() {
+    fun `gateway 持久化映射覆盖 ReadSettings 全部 110 个字段`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(109, actualKeys.size)
+        assertEquals(110, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
@@ -211,6 +211,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(selectVibrator = true),
         base.copy(autoChangeSource = false),
         base.copy(autoSuggestDayNight = true),
+        base.copy(readingAnchorEnabled = false),
         base.copy(selectText = false),
         base.copy(noAnimScrollPage = true),
         base.copy(optimizeRender = true),
@@ -257,6 +258,7 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.selectVibrator to selectVibrator,
     PreferKey.autoChangeSource to autoChangeSource,
     PreferKey.autoSuggestDayNight to autoSuggestDayNight,
+    PreferKey.readingAnchorEnabled to readingAnchorEnabled,
     PreferKey.selectText to selectText,
     PreferKey.noAnimScrollPage to noAnimScrollPage,
     PreferKey.clickImgWay to clickImgWay,

+ 54 - 0
app/src/test/java/io/legado/app/domain/usecase/CheckBookContentQualityUseCaseTest.kt

@@ -0,0 +1,54 @@
+package io.legado.app.domain.usecase
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class CheckBookContentQualityUseCaseTest {
+
+    @Test
+    fun parseChapterIndicesSupportsRangesAndIgnoresOutOfBounds() {
+        assertEquals(
+            listOf(1, 3, 4, 5),
+            CheckBookContentQualityUseCase.parseChapterIndices("1, 3-5, 99", 5),
+        )
+    }
+
+    @Test
+    fun parseChapterIndicesSupportsChineseRangeSeparators() {
+        assertEquals(
+            listOf(2, 3, 4),
+            CheckBookContentQualityUseCase.parseChapterIndices("第4至2章", 4),
+        )
+    }
+
+    @Test
+    fun parseChapterIndicesReturnsEmptyForEmptySpecOrZeroToc() {
+        assertEquals(emptyList<Int>(), CheckBookContentQualityUseCase.parseChapterIndices("", 10))
+        assertEquals(emptyList<Int>(), CheckBookContentQualityUseCase.parseChapterIndices("1-5", 0))
+        assertEquals(emptyList<Int>(), CheckBookContentQualityUseCase.parseChapterIndices("abc", 10))
+    }
+
+    @Test
+    fun cleanContentStripsHtmlCollapsesWhitespaceAndDropsHead() {
+        val useCase = CheckBookContentQualityUseCase
+        val content = "<p>第一章 起点</p>   正文开始\n主角登场"
+        assertEquals("第一章 起点 正文开始 主角登场", useCase.cleanContent(content, 0))
+        // skipHeadChars 掐掉头部
+        assertEquals("主角登场", useCase.cleanContent(content, 12))
+        // 负数按 0 处理
+        assertEquals("第一章 起点 正文开始 主角登场", useCase.cleanContent(content, -5))
+    }
+
+    @Test
+    fun countOccurrencesCountsNonOverlappingCaseInsensitive() {
+        val useCase = CheckBookContentQualityUseCase
+        assertEquals(2, useCase.countOccurrences("主角 与 主角 对话", "主角"))
+        // 大小写不敏感
+        assertEquals(2, useCase.countOccurrences("aAa A aA", "aa"))
+        // 非重叠:连排关键词只计一次位置
+        assertEquals(2, useCase.countOccurrences("aaaa", "aa"))
+        // 空关键词
+        assertEquals(0, useCase.countOccurrences("任意文本", ""))
+        assertEquals(0, useCase.countOccurrences("任意文本", "  "))
+    }
+}

+ 15 - 0
app/src/test/java/io/legado/app/model/ReadAloudSessionStoreTest.kt

@@ -4,6 +4,8 @@ import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
 import io.legado.app.domain.model.readaloud.ReadAloudSessionState
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class ReadAloudSessionStoreTest {
@@ -42,4 +44,17 @@ class ReadAloudSessionStoreTest {
 
         assertEquals(ReadAloudSessionState(), store.state.value)
     }
+
+    @Test
+    fun `detach and restore read aloud follow`() {
+        val store = ReadAloudSessionStore()
+
+        assertTrue(store.state.value.followReadAloudPosition)
+
+        store.detachReadAloudFollow()
+        assertFalse(store.state.value.followReadAloudPosition)
+
+        store.restoreReadAloudFollow()
+        assertTrue(store.state.value.followReadAloudPosition)
+    }
 }