Browse Source

fix: 听书换章复读与停止

- TTS onDone 章节自然完结后不再用旧队列补 play(),消除章末复读最后一句的自循环
- utteranceId 编码播放会话号,暂停/停止/清引擎/换章后迟到的旧回调一律拒收
- 朗读重启依赖 Compose 渲染的章节分页快照,但快照落地晚于正文加载,
  newReadAloud 会在"章节分页未完成"处静默放弃且无重试;
  分页批次落地时若服务仍停在旧章节则补一次重启
HapeLee 2 tháng trước cách đây
mục cha
commit
e42d75499b

+ 25 - 3
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -280,10 +280,17 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         publishSnapshot()
     }
 
-    /** 更新当前章节内的阅读位置。 */
-    fun updateReadingPosition(position: Int) {
+    /**
+     * 更新当前章节内的阅读位置。
+     *
+     * @param publish 滚动/点按翻页热路径传 false:只写字段不发布快照。快照里的
+     * chapterPos 没有 UI 消费方(进度显示走 composePagePosition,持久化读字段),
+     * 每次跨页发布的唯一效果是一次全量 UiState 重建落在翻页动画帧上;快照位置随
+     * 下一次结构性发布(切章/换书,重建时读字段)自然刷新。
+     */
+    fun updateReadingPosition(position: Int, publish: Boolean = true) {
         durChapterPos = position
-        publishSnapshot()
+        if (publish) publishSnapshot()
     }
 
     // endregion
@@ -596,6 +603,21 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             )
         }
         publishSnapshot()
+        resumeReadAloudWaitingForChapter(snapshots)
+    }
+
+    /**
+     * 朗读换章恢复:curPageChanged 在正文加载完成时机触发朗读重启,但 Compose 渲染
+     * 的章节分页快照要等渲染层排版批次落地才可用(换章时还会先 clearReaderPagination),
+     * newReadAloud 会在"章节分页未完成"处静默放弃且无重试,表现为换章后朗读停止。
+     * 分页快照落地是目标章节就绪的可靠信号:服务仍停在旧章节时在此补一次重启。
+     * 服务章节与当前章节一致的同章重排不受影响。
+     */
+    private fun resumeReadAloudWaitingForChapter(snapshots: List<ReaderChapterPaginationSnapshot>) {
+        if (!BaseReadAloudService.isRun) return
+        if (snapshots.none { it.chapterIndex == durChapterIndex }) return
+        if (BaseReadAloudService.currentChapterIndex == durChapterIndex) return
+        readAloud(play = !BaseReadAloudService.pause)
     }
 
     fun publishReaderPaginationEnvironment(

+ 7 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -225,6 +225,12 @@ abstract class BaseReadAloudService : BaseService(),
     protected fun paragraphChapterPositionAt(index: Int): Int? =
         readerReadAloudChapter?.paragraphs(readAloudByPage)?.getOrNull(index)?.chapterPosition
     protected open val useSpeechPlaybackQueue: Boolean = false
+
+    /**
+     * newReadAloud 整体替换本章播放状态(队列/游标/段落)后回调,
+     * 引擎实现用它作废旧章节发言的迟到回调
+     */
+    protected open fun onPlaybackStateReplaced() {}
     protected val hasSpeechPlaybackQueue: Boolean
         get() = useSpeechPlaybackQueue && !playbackQueue.isEmpty
 
@@ -445,6 +451,7 @@ abstract class BaseReadAloudService : BaseService(),
             readAloudNumber = preparedReadAloudNumber
             paragraphStartPos = preparedParagraphStartPos
             updateReadAloudProgressSnapshot(preparedReadAloudNumber + 1)
+            onPlaybackStateReplaced()
             if (moveToLast) toLast = false
             preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
             launch(Main) {

+ 54 - 19
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -58,6 +58,10 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
     private var activeVoiceName = ""
     private var defaultVoiceName = ""
     private var initGeneration = 0
+
+    /** 播放会话号: 每次真正发声、停止、暂停、清理或整体换章时递增, 用于拒收旧会话的回调 */
+    @Volatile
+    private var speakSession = 0
     private val TAG = "TTSReadAloudService"
 
     override fun onCreate() {
@@ -103,6 +107,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         activeVoiceName = ""
         defaultVoiceName = ""
         initGeneration++
+        speakSession++
     }
 
     private fun onTtsInitialized(status: Int, generation: Int) {
@@ -160,8 +165,9 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                     AppLog.putDebug("TTS延迟结束,准备播放")
                 }
                 ensureActive()
-                
+
                 LogUtils.d(TAG, "朗读列表大小 ${contentList.size}")
+                val session = ++speakSession
                 val tts = textToSpeech ?: throw NoStackTraceException("tts is null")
                 var text = contentList[nowSpeak]
                 if (paragraphStartPos > 0) {
@@ -169,12 +175,12 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 }
                 if (text.matches(AppPattern.notReadAloudRegex)) {
                     AppLog.putDebug("TTS段落全标点跳过: nowSpeak=$nowSpeak")
-                    ttsUtteranceListener.onDone(AppConst.APP_TAG + nowSpeak)
+                    ttsUtteranceListener.onDone(ttsUtteranceId(AppConst.APP_TAG, session, nowSpeak))
                     return@execute
                 }
                 AppLog.putDebug("TTS开始Speak: $text")
                 val result = tts.runCatching {
-                    speak(text, TextToSpeech.QUEUE_FLUSH, null, AppConst.APP_TAG + nowSpeak)
+                    speak(text, TextToSpeech.QUEUE_FLUSH, null, ttsUtteranceId(AppConst.APP_TAG, session, nowSpeak))
                 }.getOrElse {
                     AppLog.put("tts出错\n${it.localizedMessage}", it, true)
                     TextToSpeech.ERROR
@@ -190,6 +196,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 // 无间隔模式:保持原有的队列式连续播放,确保无缝衔接
                 LogUtils.d(TAG, "朗读列表大小 ${contentList.size}")
                 LogUtils.d(TAG, "朗读页数 ${readerReadAloudChapter?.pageCount}")
+                val session = ++speakSession
                 val tts = textToSpeech ?: throw NoStackTraceException("tts is null")
                 val contentList = contentList
                 var isAddedText = false
@@ -204,7 +211,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                     }
                     if (!isAddedText) {
                         val result = tts.runCatching {
-                            speak(text, TextToSpeech.QUEUE_FLUSH, null, AppConst.APP_TAG + i)
+                            speak(text, TextToSpeech.QUEUE_FLUSH, null, ttsUtteranceId(AppConst.APP_TAG, session, i))
                         }.getOrElse {
                             AppLog.put("tts出错\n${it.localizedMessage}", it, true)
                             TextToSpeech.ERROR
@@ -217,7 +224,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                         }
                     } else {
                         val result = tts.runCatching {
-                            speak(text, TextToSpeech.QUEUE_ADD, null, AppConst.APP_TAG + i)
+                            speak(text, TextToSpeech.QUEUE_ADD, null, ttsUtteranceId(AppConst.APP_TAG, session, i))
                         }.getOrElse {
                             AppLog.put("tts出错\n${it.localizedMessage}", it, true)
                             TextToSpeech.ERROR
@@ -290,6 +297,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
     }
 
     override fun playStop() {
+        speakSession++
         textToSpeech?.runCatching {
             stop()
         }
@@ -319,6 +327,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
      */
     override fun pauseReadAloud(abandonFocus: Boolean) {
         super.pauseReadAloud(abandonFocus)
+        speakSession++
         speakJob?.cancel()
         textToSpeech?.runCatching {
             stop()
@@ -333,6 +342,11 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         play()
     }
 
+    override fun onPlaybackStateReplaced() {
+        // 换章/重新定位后, 旧章节发言的迟到回调不得再改新队列状态
+        speakSession++
+    }
+
     /**
      * 朗读监听
      */
@@ -365,8 +379,13 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         override fun onDone(s: String) {
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onDone utteranceId:$s")
-            nextParagraph(naturalCompletion = true)
-            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+            // 章节自然完结时本章播放状态已作废, 恢复播放由换章后的 newReadAloud 负责;
+            // 若此处仍用旧队列 play(), 会把本章最后一段反复重新入队
+            val paragraphAdvanced = nextParagraph(naturalCompletion = true)
+            if (paragraphAdvanced &&
+                !pause &&
+                (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)
+            ) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
@@ -393,23 +412,27 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 TAG,
                 "onError nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId errorCode:$errorCode"
             )
-            nextParagraph(naturalCompletion = true)
-            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+            val paragraphAdvanced = nextParagraph(naturalCompletion = true)
+            if (paragraphAdvanced &&
+                !pause &&
+                (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)
+            ) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
-        private fun nextParagraph(naturalCompletion: Boolean = false) {
+        private fun nextParagraph(naturalCompletion: Boolean = false): Boolean {
             if (hasSpeechPlaybackQueue) {
                 val current = playbackCursor
                     ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
-                playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: if (naturalCompletion) {
-                    completeCurrentChapter()
-                } else {
-                    nextChapter()
+                val next = playbackQueue.next(current)
+                if (next != null) {
+                    moveToPlaybackCursor(next)
+                    return true
                 }
-                return
+                if (naturalCompletion) completeCurrentChapter() else nextChapter()
+                return false
             }
             //跳过全标点段落
             do {
@@ -422,26 +445,31 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 nowSpeak++
                 if (nowSpeak >= contentList.size) {
                     if (naturalCompletion) completeCurrentChapter() else nextChapter()
-                    return
+                    return false
                 }
             } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
             // 页内切段不引入换行符,累加会漂移,用段落绝对位置重算
             paragraphChapterPositionAt(nowSpeak)?.let { readAloudNumber = it }
+            return true
         }
 
         @Deprecated("Deprecated in Java")
         override fun onError(s: String) {
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex s:$s")
-            nextParagraph(naturalCompletion = true)
-            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+            val paragraphAdvanced = nextParagraph(naturalCompletion = true)
+            if (paragraphAdvanced &&
+                !pause &&
+                (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)
+            ) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
         private fun isCurrentUtterance(utteranceId: String?): Boolean =
-            !hasSpeechPlaybackQueue || utteranceId == AppConst.APP_TAG + nowSpeak
+            utteranceId != null &&
+                utteranceId == ttsUtteranceId(AppConst.APP_TAG, speakSession, nowSpeak)
 
     }
 
@@ -461,3 +489,10 @@ internal fun currentRangePosition(
     utteranceStartPosition: Int,
     rangeStart: Int,
 ): Int = utteranceStartPosition + rangeStart
+
+/**
+ * TTS 回调只回传 utteranceId, 播放会话号必须编码进 id,
+ * 否则旧会话(暂停/停止/换章后迟到)的回调无法与当前队列区分
+ */
+internal fun ttsUtteranceId(appTag: String, session: Int, index: Int): String =
+    "$appTag$session:$index"

+ 16 - 0
app/src/test/java/io/legado/app/service/TTSReadAloudProgressTest.kt

@@ -2,6 +2,7 @@ package io.legado.app.service
 
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertFalse
+import org.junit.Assert.assertNotEquals
 import org.junit.Assert.assertTrue
 import org.junit.Test
 
@@ -18,6 +19,21 @@ class TTSReadAloudProgressTest {
         assertFalse(BaseReadAloudService.speechDrivingNavigation)
     }
 
+    @Test
+    fun utteranceIdBindsPlaybackSessionAndParagraphIndex() {
+        assertEquals("Legado3:7", ttsUtteranceId("Legado", 3, 7))
+    }
+
+    @Test
+    fun utteranceIdChangesWithSessionSoStaleCallbacksAreRejections() {
+        // 同一段落下标, 会话号递增后 id 不同:
+        // 旧会话(暂停/停止/换章后迟到)的 onDone 不会命中新队列
+        val current = ttsUtteranceId("Legado", 5, 0)
+        assertNotEquals(current, ttsUtteranceId("Legado", 6, 0))
+        // 不同段落下标在同会话内仍然可区分
+        assertNotEquals(current, ttsUtteranceId("Legado", 5, 1))
+    }
+
     @Test
     fun nextParagraphAdvancesFromCurrentRangePosition() {
         assertEquals(