Sfoglia il codice sorgente

fix: 听书定时读完本章、脱离提示与翻页跟随

- 定时臂标锚定正在朗读的章节;臂标章节自然读完必停止,
  脱离浏览(页面翻到后章)不再被误判为章节已推进而静默失效
- Compose 手动翻页提交接入朗读脱离状态机(prepare/follow 对):
  提示开启时脱离显示悬浮条,关闭时跟随页面重启;提交锚点改用
  页边界偏移,跨页段落不再把进度与朗读定位拖回上一页末段
- updateReadAloudProgress 只更新朗读高亮锚点并在锚点变化时重发布
  窗口,不再按朗读位置回迁可见页;upContent 恢复按 durChapterPos
  重定位,朗读跨页经 moveToNextPage 正常推进页面
- resumeReadAloudWaitingForChapter 加脱离门禁,分页批次落地不追页
- 翻页 settle 同步返回页窗口、手势热路径经 rememberUpdatedState
  现读,修复翻页后选词与图片点击命中旧窗口
HapeLee 2 mesi fa
parent
commit
604d693ea8

+ 20 - 4
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -615,6 +615,8 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
      */
     private fun resumeReadAloudWaitingForChapter(snapshots: List<ReaderChapterPaginationSnapshot>) {
         if (!BaseReadAloudService.isRun) return
+        // 脱离后朗读位置归用户:分页批次落地不代表朗读要跟到页面所在章节
+        if (!readAloudSessionStore.state.value.followReadAloudPosition) return
         if (snapshots.none { it.chapterIndex == durChapterIndex }) return
         if (BaseReadAloudService.currentChapterIndex == durChapterIndex) return
         readAloud(play = !BaseReadAloudService.pause)
@@ -983,12 +985,26 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     }
 
     /**
-     * 手动导航后:脱离提示关闭时,朗读从新页面重新开始(跟随页面);开启时保持脱离状态,不重启朗读。
+     * 手动导航后:脱离提示关闭时,朗读从新页面重新开始(跟随页面)并返回 true;
+     * 开启时保持脱离状态,不重启朗读,返回 false。
      */
-    private fun followReadAloudAfterManualNavigation() {
-        if (!BaseReadAloudService.isRun || BaseReadAloudService.speechDrivingNavigation) return
-        if (ReadBookConfig.readAloudDetachReminderEnabled) return
+    private fun followReadAloudAfterManualNavigation(): Boolean {
+        if (!BaseReadAloudService.isRun || BaseReadAloudService.speechDrivingNavigation) return false
+        if (ReadBookConfig.readAloudDetachReminderEnabled) return false
         readAloud(play = !BaseReadAloudService.pause)
+        return true
+    }
+
+    /**
+     * Compose 手动翻页提交后的朗读联动(对照 moveToNextPage 的 prepare/follow 对)。
+     * commitManualReaderPage 曾无条件重启朗读并复位跟随,脱离提示永不出现;
+     * 这里统一走脱离状态机:脱离/翻回朗读位置/跟随重启都在同一处判定。
+     * 返回是否跟随页面重启了朗读,调用方据此锚定朗读高亮。
+     */
+    fun onComposeManualPageTurn(): Boolean {
+        prepareManualNavigation()
+        restoreReadAloudFollowIfBackOnPosition()
+        return followReadAloudAfterManualNavigation()
     }
 
     fun moveToNextPage(): Boolean {

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

@@ -772,7 +772,11 @@ abstract class BaseReadAloudService : BaseService(),
                         if (timeMinute == PlaybackTimer.MIN_MINUTES &&
                             ReadConfig.finishCurrentChapterAfterTimer
                         ) {
-                            finishChapterAtIndex = ReadBook.durChapterIndex
+                            // 臂标锚定正在朗读的章节:脱离(手动翻到后章)时 durChapterIndex
+                            // 已领先,"读完本章"指正在读的这章,不是页面停留的章节
+                            finishChapterAtIndex = BaseReadAloudService.currentChapterIndex
+                                .takeIf { it >= 0 }
+                                ?: ReadBook.durChapterIndex
                             finishChapterAtIndex != NO_FINISH_CHAPTER
                         } else {
                             false
@@ -1478,6 +1482,10 @@ internal data class ChapterCompletionDecision(
 /**
  * Decides what a natural chapter boundary should do against the "finish current
  * chapter after timer" state. Pure and thread-free so it can be unit tested.
+ *
+ * SKIP 只保留给"章已推进且臂标不属于已读完章节"的双重触发竞态(臂标消费后为
+ * NO_FINISH,自然落入该分支)。臂标锚定的章节自然读完时必须 STOP:
+ * 脱离浏览(durChapterIndex 领先于朗读章节)不算章节已推进。
  */
 internal fun decideChapterCompletion(
     durChapterIndex: Int,
@@ -1485,11 +1493,13 @@ internal fun decideChapterCompletion(
     finishChapterAtIndex: Int,
     finishChapterSettingEnabled: Boolean,
 ): ChapterCompletionDecision {
-    if (durChapterIndex != finishedChapterIndex) {
-        // The chapter already advanced (race) — only clear a stale arm for the finished chapter.
+    if (durChapterIndex != finishedChapterIndex &&
+        finishChapterAtIndex != finishedChapterIndex
+    ) {
+        // The chapter already advanced concurrently (race) — leave any foreign arm alone.
         return ChapterCompletionDecision(
             action = ChapterCompletionAction.SKIP,
-            clearTimer = finishChapterAtIndex == finishedChapterIndex,
+            clearTimer = false,
         )
     }
     return when {

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

@@ -688,17 +688,14 @@ class ReadBookController(
 
     private fun commitManualReaderPage(index: Int) {
         val page = directReaderPages.getOrNull(index) ?: return
-        val chapterPosition = directReaderPageContext(index)
-            ?.contentStartPosition
-            ?: ReaderPageNavigator.pageStart(page)
+        // 提交页边界偏移而非首段 chapterPosition:段落跨页时首段起点落在上一页,
+        // 会把持久化进度、朗读定位与 locate 全部拖回上一页末段。pageStart 与
+        // 分页快照的 pageStarts 同源(对照 moveToNextPage 的 nextPageStart 语义)。
+        val chapterPosition = ReaderPageNavigator.pageStart(page)
         // 热路径安静更新:不发布快照(否则每次跨页触发一次全量 UiState 重建落在动画帧上)。
         ReadBook.updateReadingPosition(chapterPosition, publish = false)
-        if (BaseReadAloudService.isRun) {
+        if (BaseReadAloudService.isRun && ReadBook.onComposeManualPageTurn()) {
             readAloudPosition = page.id.chapterIndex to chapterPosition
-            ReadBook.readAloud(
-                play = !BaseReadAloudService.pause,
-                chapterPosition = chapterPosition,
-            )
         }
     }
 
@@ -771,7 +768,13 @@ class ReadBookController(
     ): Boolean {
         val contentPadding = layoutController.viewport.value?.contentPadding ?: ReaderPadding()
         val inputWindow = ReadBook.readerChapterInputWindow
-        val chapter = inputWindow.current ?: return false
+        val chapter = inputWindow.current ?: run {
+            // 内容未装载(进入书籍/目录跳转装载中):发布"加载中"占位页窗口,让
+            // 阅读画布保持组合、点击分区与菜单照常可用,装载完成后由分页批次
+            // 整窗替换(对照 shutiao 的加载占位页正文渲染)。
+            publishLoadingReaderWindow()
+            return false
+        }
         val chapters = listOf(
             inputWindow.previous,
             chapter,
@@ -840,12 +843,14 @@ class ReadBookController(
             append('|').append(ReadBookConfig.durConfig.highlightRules.hashCode())
         }
         if (directReaderLayoutKey == key && directReaderPages.isNotEmpty()) {
-            val index = directReaderPageIndex?.coerceIn(directReaderPages.indices)
-                ?: ReaderPageNavigator.locate(
+            // upContent 语义是"按 durChapterPos 重新定位"(对照旧 View upContent 重绘):
+            // 朗读跨页走 moveToNextPage → upContent,只有重定位页面才会前进;缓存下标
+            // 会让这类发布变成空操作,页面跟随朗读随之失效。
+            val index = ReaderPageNavigator.locate(
                 directReaderPages,
                 chapter.chapter.index,
                 ReadBook.durChapterPos,
-                ).also { directReaderPageIndex = it }
+            ).also { directReaderPageIndex = it }
             publishDirectReaderWindow(index)
             return true
         }
@@ -1752,16 +1757,15 @@ class ReadBookController(
 
     fun updateReadAloudProgress(chapterStart: Int) {
         if (!BaseReadAloudService.isPlay()) return
-        val chapterIndex = BaseReadAloudService.currentChapterIndex
-        readAloudPosition = chapterIndex to chapterStart
-        if (directReaderPages.isNotEmpty()) {
-            directReaderPageIndex = ReaderPageNavigator.locate(
-                directReaderPages,
-                chapterIndex,
-                chapterStart,
-            )
-            directReaderPageIndex?.let(::publishDirectReaderWindow)
-        }
+        // 只更新朗读高亮锚点。可见页的移动由朗读服务驱动(moveToReadAloudPage →
+        // moveToNextPage → upContent)与用户导航负责;这里若再按朗读位置 locate
+        // 回迁可见页,进入阅读器时会把页面先拉回朗读所在的上一段(跨页段落的
+        // locate 落在段落起始页),随后 curPageChanged 的跟随重启又跳回当前页。
+        val anchor = BaseReadAloudService.currentChapterIndex to chapterStart
+        if (readAloudPosition == anchor) return
+        readAloudPosition = anchor
+        // 高亮在窗口发布时才计算绘制(directReaderWindow),锚点变化后必须重发布
+        directReaderPageIndex?.let(::publishDirectReaderWindow)
     }
 
     // ── Key handling ──
@@ -2006,6 +2010,15 @@ class ReadBookController(
         return window
     }
 
+    /** 内容装载前的整窗占位:窗口里只有"加载中"占位页,点击/菜单走画布正常路径。 */
+    private fun publishLoadingReaderWindow() {
+        val current = _readerPageWindow.value.current
+        if (current?.isPlaceholder == true && current.id.chapterIndex == ReadBook.durChapterIndex) return
+        placeholderReaderPage(ReadBook.durChapterIndex)?.let { page ->
+            updateReaderPageWindow(ReaderPageWindow(current = page))
+        }
+    }
+
     /** 未装载章节的占位页:一屏居中的"加载中"文字,几何与普通页一致以保持滚动连续。 */
     private fun placeholderReaderPage(chapterIndex: Int): ReaderPage? {
         val viewport = layoutController.viewport.value ?: return null

+ 19 - 2
app/src/test/java/io/legado/app/service/ReadAloudChapterCompletionTest.kt

@@ -58,7 +58,9 @@ class ReadAloudChapterCompletionTest {
     }
 
     @Test
-    fun chapterAlreadyAdvancedSkipsWithMatchingArmCleared() {
+    fun armedChapterFinishingWhileReaderBrowsedAheadStillStops() {
+        // 脱离浏览:页面已翻到第 6 章,朗读仍在第 5 章;定时臂标锚定的是
+        // 朗读中的第 5 章 —— 本章自然读完必须停止,不能被误判为"章节已推进"
         val decision = decideChapterCompletion(
             durChapterIndex = 6,
             finishedChapterIndex = 5,
@@ -66,7 +68,7 @@ class ReadAloudChapterCompletionTest {
             finishChapterSettingEnabled = true,
         )
 
-        assertEquals(ChapterCompletionAction.SKIP, decision.action)
+        assertEquals(ChapterCompletionAction.STOP, decision.action)
         assertEquals(true, decision.clearTimer)
     }
 
@@ -83,6 +85,21 @@ class ReadAloudChapterCompletionTest {
         assertEquals(false, decision.clearTimer)
     }
 
+    @Test
+    fun lateDuplicateCompletionAfterAdvanceSkipsInsteadOfDoubleAdvancing() {
+        // 双重触发竞态:首次完结已 ADVANCE 且臂标为空,迟到的同章完结
+        // 不得再次推进(否则跳过下一章)
+        val decision = decideChapterCompletion(
+            durChapterIndex = 6,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.SKIP, decision.action)
+        assertEquals(false, decision.clearTimer)
+    }
+
     @Test
     fun chapterIndexZeroDoesNotCollideWithSentinel() {
         val decision = decideChapterCompletion(