Przeglądaj źródła

fix: 完善朗读媒体控制并抽取读完本章状态机

- 移除从未用于发布的 NotificationId.ReadAloudMediaControl 常量及多余的 cancel
- 老"使用媒体通道"路径恢复完整 MediaHelp.MEDIA_SESSION_ACTIONS,避免锁屏控件行为变化
- 播放状态速度按状态设置(播放 1f / 暂停 0f)
- 将 completeCurrentChapter 的决策逻辑抽成纯函数 decideChapterCompletion 并补单元测试

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
HapeLee 2 miesięcy temu
rodzic
commit
d5ae779236

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

@@ -13,7 +13,6 @@ object NotificationId {
     const val WebService = 105
     const val WebService = 105
     const val DownloadService = 106
     const val DownloadService = 106
     const val BookSourceCheckService = 107
     const val BookSourceCheckService = 107
-    const val ReadAloudMediaControl = 108
     const val Download = 10000
     const val Download = 10000
     const val ExportBook = 201
     const val ExportBook = 201
 
 

+ 75 - 32
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -117,7 +117,6 @@ abstract class BaseReadAloudService : BaseService(),
         private const val TAG = "BaseReadAloudService"
         private const val TAG = "BaseReadAloudService"
         private const val ACTION_ADD_TIMER = "io.legado.app.action.ADD_READ_ALOUD_TIMER"
         private const val ACTION_ADD_TIMER = "io.legado.app.action.ADD_READ_ALOUD_TIMER"
         private const val MEDIA_PROGRESS_DURATION_MS = 100_000L
         private const val MEDIA_PROGRESS_DURATION_MS = 100_000L
-        private const val NO_FINISH_CHAPTER = -1
 
 
         private const val READ_ALOUD_MEDIA_SESSION_ACTIONS =
         private const val READ_ALOUD_MEDIA_SESSION_ACTIONS =
             (PlaybackStateCompat.ACTION_PLAY
             (PlaybackStateCompat.ACTION_PLAY
@@ -262,7 +261,6 @@ abstract class BaseReadAloudService : BaseService(),
         unregisterReceiver(broadcastReceiver)
         unregisterReceiver(broadcastReceiver)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
         notificationManager.cancel(NotificationId.ReadAloudService)
         notificationManager.cancel(NotificationId.ReadAloudService)
-        notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED)
         systemMediaCompatibilityEnabled = false
         systemMediaCompatibilityEnabled = false
         androidMediaControlEnabled = false
         androidMediaControlEnabled = false
@@ -276,7 +274,6 @@ abstract class BaseReadAloudService : BaseService(),
         }
         }
         upNotificationJob?.invokeOnCompletion {
         upNotificationJob?.invokeOnCompletion {
             notificationManager.cancel(NotificationId.ReadAloudService)
             notificationManager.cancel(NotificationId.ReadAloudService)
-            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         }
         }
     }
     }
 
 
@@ -748,9 +745,20 @@ abstract class BaseReadAloudService : BaseService(),
      */
      */
     private fun upMediaSessionPlaybackState(state: Int) {
     private fun upMediaSessionPlaybackState(state: Int) {
         val playbackState = PlaybackStateCompat.Builder()
         val playbackState = PlaybackStateCompat.Builder()
-            .setActions(READ_ALOUD_MEDIA_SESSION_ACTIONS)
+            .setActions(
+                if (androidMediaControlEnabled) {
+                    READ_ALOUD_MEDIA_SESSION_ACTIONS
+                } else {
+                    // 老的"使用媒体通道"路径保持原有行为,避免锁屏媒体控件功能变化。
+                    MediaHelp.MEDIA_SESSION_ACTIONS
+                }
+            )
             // This is a normalized chapter-percentage scale, not a real time axis.
             // This is a normalized chapter-percentage scale, not a real time axis.
-            .setState(state, mediaProgressPositionMs(), 0f)
+            .setState(
+                state,
+                mediaProgressPositionMs(),
+                if (state == PlaybackStateCompat.STATE_PLAYING) 1f else 0f,
+            )
         if (androidMediaControlEnabled) {
         if (androidMediaControlEnabled) {
             playbackState.addCustomAction(
             playbackState.addCustomAction(
                 PlaybackStateCompat.CustomAction.Builder(
                 PlaybackStateCompat.CustomAction.Builder(
@@ -878,7 +886,6 @@ abstract class BaseReadAloudService : BaseService(),
         val changed = androidMediaControlEnabled != enabled
         val changed = androidMediaControlEnabled != enabled
         androidMediaControlEnabled = enabled
         androidMediaControlEnabled = enabled
         updateMediaSessionActivation()
         updateMediaSessionActivation()
-        notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         if (enabled) {
         if (enabled) {
             upMediaMetadata()
             upMediaMetadata()
         }
         }
@@ -953,7 +960,6 @@ abstract class BaseReadAloudService : BaseService(),
     private fun upReadAloudNotification() {
     private fun upReadAloudNotification() {
         upNotificationJob = lifecycleScope.launch(Main.immediate) {
         upNotificationJob = lifecycleScope.launch(Main.immediate) {
             try {
             try {
-                notificationManager.cancel(NotificationId.ReadAloudMediaControl)
                 val notification = createForegroundNotification()
                 val notification = createForegroundNotification()
                 notificationManager.notify(NotificationId.ReadAloudService, notification.build())
                 notificationManager.notify(NotificationId.ReadAloudService, notification.build())
             } catch (e: Exception) {
             } catch (e: Exception) {
@@ -1117,7 +1123,6 @@ abstract class BaseReadAloudService : BaseService(),
      */
      */
     override fun startForegroundNotification() {
     override fun startForegroundNotification() {
         try {
         try {
-            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
             val notification = createForegroundNotification()
             val notification = createForegroundNotification()
             startForeground(NotificationId.ReadAloudService, notification.build())
             startForeground(NotificationId.ReadAloudService, notification.build())
         } catch (e: Exception) {
         } catch (e: Exception) {
@@ -1151,33 +1156,27 @@ abstract class BaseReadAloudService : BaseService(),
     protected fun completeCurrentChapter() {
     protected fun completeCurrentChapter() {
         synchronized(finishChapterTimerLock) {
         synchronized(finishChapterTimerLock) {
             val chapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
             val chapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
-            if (ReadBook.durChapterIndex != chapterIndex) {
-                if (finishChapterAtIndex == chapterIndex) {
-                    finishChapterAtIndex = NO_FINISH_CHAPTER
-                }
-                return
+            val decision = decideChapterCompletion(
+                durChapterIndex = ReadBook.durChapterIndex,
+                finishedChapterIndex = chapterIndex,
+                finishChapterAtIndex = finishChapterAtIndex,
+                finishChapterSettingEnabled = ReadConfig.finishCurrentChapterAfterTimer,
+            )
+            if (decision.clearTimer) {
+                finishChapterAtIndex = NO_FINISH_CHAPTER
             }
             }
-            val shouldStop = when {
-                finishChapterAtIndex == NO_FINISH_CHAPTER -> false
-                finishChapterAtIndex != chapterIndex -> {
-                    finishChapterAtIndex = NO_FINISH_CHAPTER
-                    false
-                }
-                !ReadConfig.finishCurrentChapterAfterTimer -> {
-                    finishChapterAtIndex = NO_FINISH_CHAPTER
-                    false
+            when (decision.action) {
+                ChapterCompletionAction.STOP -> {
+                    pause = true
+                    stopSelf()
                 }
                 }
-                else -> {
-                    finishChapterAtIndex = NO_FINISH_CHAPTER
-                    true
+
+                ChapterCompletionAction.ADVANCE -> {
+                    // synchronized is reentrant, so nextChapter() may clear the same state safely.
+                    nextChapter()
                 }
                 }
-            }
-            if (shouldStop) {
-                pause = true
-                stopSelf()
-            } else {
-                // synchronized is reentrant, so nextChapter() may clear the same state safely.
-                nextChapter()
+
+                ChapterCompletionAction.SKIP -> Unit
             }
             }
         }
         }
     }
     }
@@ -1287,6 +1286,50 @@ internal fun normalizedMediaProgressMs(
     return position.toLong() * durationMs / chapterLength
     return position.toLong() * durationMs / chapterLength
 }
 }
 
 
+/** Sentinel for "finish current chapter" timer not being armed. */
+internal const val NO_FINISH_CHAPTER = -1
+
+internal enum class ChapterCompletionAction {
+    /** Timer expired during this chapter — stop read-aloud now. */
+    STOP,
+    /** No finish-chapter intent — continue to the next chapter. */
+    ADVANCE,
+    /** The chapter already advanced concurrently — do nothing. */
+    SKIP,
+}
+
+internal data class ChapterCompletionDecision(
+    val action: ChapterCompletionAction,
+    val clearTimer: Boolean,
+)
+
+/**
+ * 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.
+ */
+internal fun decideChapterCompletion(
+    durChapterIndex: Int,
+    finishedChapterIndex: Int,
+    finishChapterAtIndex: Int,
+    finishChapterSettingEnabled: Boolean,
+): ChapterCompletionDecision {
+    if (durChapterIndex != finishedChapterIndex) {
+        // The chapter already advanced (race) — only clear a stale arm for the finished chapter.
+        return ChapterCompletionDecision(
+            action = ChapterCompletionAction.SKIP,
+            clearTimer = finishChapterAtIndex == finishedChapterIndex,
+        )
+    }
+    return when {
+        finishChapterAtIndex == NO_FINISH_CHAPTER ->
+            ChapterCompletionDecision(ChapterCompletionAction.ADVANCE, clearTimer = false)
+        finishChapterAtIndex != finishedChapterIndex || !finishChapterSettingEnabled ->
+            ChapterCompletionDecision(ChapterCompletionAction.ADVANCE, clearTimer = true)
+        else ->
+            ChapterCompletionDecision(ChapterCompletionAction.STOP, clearTimer = true)
+    }
+}
+
 internal inline fun findReadAloudPageIndex(
 internal inline fun findReadAloudPageIndex(
     currentPageIndex: Int,
     currentPageIndex: Int,
     chapterPosition: Int,
     chapterPosition: Int,

+ 99 - 0
app/src/test/java/io/legado/app/service/ReadAloudChapterCompletionTest.kt

@@ -0,0 +1,99 @@
+package io.legado.app.service
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ReadAloudChapterCompletionTest {
+
+    @Test
+    fun timerArmedForThisChapterStopsAtChapterEnd() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = 5,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.STOP, decision.action)
+        assertEquals(true, decision.clearTimer)
+    }
+
+    @Test
+    fun noTimerArmedContinuesToNextChapter() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
+        assertEquals(false, decision.clearTimer)
+    }
+
+    @Test
+    fun timerArmedForDifferentChapterClearsAndContinues() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = 3,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
+        assertEquals(true, decision.clearTimer)
+    }
+
+    @Test
+    fun timerDisabledAfterArmingClearsAndContinues() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = 5,
+            finishChapterSettingEnabled = false,
+        )
+
+        assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
+        assertEquals(true, decision.clearTimer)
+    }
+
+    @Test
+    fun chapterAlreadyAdvancedSkipsWithMatchingArmCleared() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 6,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = 5,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.SKIP, decision.action)
+        assertEquals(true, decision.clearTimer)
+    }
+
+    @Test
+    fun chapterAlreadyAdvancedSkipsWithoutTouchingOtherArms() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 6,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = 3,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.SKIP, decision.action)
+        assertEquals(false, decision.clearTimer)
+    }
+
+    @Test
+    fun chapterIndexZeroDoesNotCollideWithSentinel() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 0,
+            finishedChapterIndex = 0,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            finishChapterSettingEnabled = true,
+        )
+
+        assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
+        assertEquals(false, decision.clearTimer)
+    }
+
+}