Explorar o código

feat: 新增定时读完当前章节选项,优化朗读媒体控制通知

lingran7031 hai 3 meses
pai
achega
370db88bad
Modificáronse 21 ficheiros con 302 adicións e 84 borrados
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 5 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  3. 1 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  4. 151 41
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  5. 11 7
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  6. 12 8
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  7. 7 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  8. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  9. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  10. 9 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudScreen.kt
  11. 2 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  12. 16 2
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  13. 43 19
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  14. 8 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  15. 1 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  16. 3 1
      app/src/main/res/values-zh-rCN/strings.xml
  17. 3 1
      app/src/main/res/values-zh-rHK/strings.xml
  18. 3 1
      app/src/main/res/values-zh-rTW/strings.xml
  19. 3 1
      app/src/main/res/values/strings.xml
  20. 11 2
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  21. 8 0
      app/src/test/java/io/legado/app/service/TTSReadAloudProgressTest.kt

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

@@ -327,6 +327,7 @@ object PreferKey {
     const val mediaButtonPerNext = "mediaButtonPerNext"
     const val systemMediaControlCompatibilityChange = "systemMediaControlCompatibilityChange"
     const val readAloudAndroidMediaControl = "readAloudAndroidMediaControl"
+    const val finishCurrentChapterAfterTimer = "finishCurrentChapterAfterTimer"
     const val isPredictiveBackEnabled = "isPredictiveBackEnabled"
     const val readRecordDisplayMode = "readRecordDisplayMode"
     const val replaceSortMode = "desc"

+ 5 - 0
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -62,6 +62,8 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
         compatDsValue(ReadAloudKeys.SystemMediaControlCompatibilityChange, true),
     streamReadAloudAudio = compatDsValue(ReadAloudKeys.StreamReadAloudAudio, false),
     ttsTimer = PlaybackTimer.normalize(compatDsValue(ReadAloudKeys.TtsTimer, 0)),
+    finishCurrentChapterAfterTimer =
+        compatDsValue(ReadAloudKeys.FinishCurrentChapterAfterTimer, false),
     ttsFollowSys = compatDsValue(ReadAloudKeys.TtsFollowSys, true),
     ttsSpeechRate = compatDsValue(ReadAloudKeys.TtsSpeechRate, 5),
     speechAnalysisMode = compatDsValue(ReadAloudKeys.SpeechAnalysisMode, "rule"),
@@ -94,6 +96,7 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,
     PreferKey.ttsTimer to ttsTimer,
+    PreferKey.finishCurrentChapterAfterTimer to finishCurrentChapterAfterTimer,
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,
@@ -125,6 +128,8 @@ private object ReadAloudKeys {
         booleanPreferencesKey(PreferKey.systemMediaControlCompatibilityChange)
     val StreamReadAloudAudio = booleanPreferencesKey(PreferKey.streamReadAloudAudio)
     val TtsTimer = intPreferencesKey(PreferKey.ttsTimer)
+    val FinishCurrentChapterAfterTimer =
+        booleanPreferencesKey(PreferKey.finishCurrentChapterAfterTimer)
     val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
     val TtsSpeechRate = intPreferencesKey(PreferKey.ttsSpeechRate)
     val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)

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

@@ -19,6 +19,7 @@ data class ReadAloudSettings(
     val systemMediaControlCompatibilityChange: Boolean = true,
     val streamReadAloudAudio: Boolean = false,
     val ttsTimer: Int = 0,
+    val finishCurrentChapterAfterTimer: Boolean = false,
     val ttsFollowSys: Boolean = true,
     val ttsSpeechRate: Int = 5,
     val speechAnalysisMode: String = "rule",

+ 151 - 41
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -115,6 +115,9 @@ abstract class BaseReadAloudService : BaseService(),
         }
 
         private const val TAG = "BaseReadAloudService"
+        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 NO_FINISH_CHAPTER = -1
 
         private const val READ_ALOUD_MEDIA_SESSION_ACTIONS =
             (PlaybackStateCompat.ACTION_PLAY
@@ -172,6 +175,8 @@ abstract class BaseReadAloudService : BaseService(),
         ReadConfig.systemMediaControlCompatibilityChange
     @Volatile
     private var androidMediaControlEnabled = ReadConfig.androidMediaControlEnabled
+    private val finishChapterTimerLock = Any()
+    private var finishChapterAtIndex = NO_FINISH_CHAPTER
     private var prepareReadAloudJob: Coroutine<*>? = null
     private var prepareReadAloudGeneration = 0L
     private var cover: Bitmap =
@@ -299,6 +304,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) {
+        clearFinishChapterTimerIfChapterChanged(ReadBook.durChapterIndex)
         val generation = ++prepareReadAloudGeneration
         prepareReadAloudJob?.cancel()
         prepareReadAloudJob = execute(executeContext = IO) {
@@ -493,6 +499,7 @@ abstract class BaseReadAloudService : BaseService(),
                     text = contentList.getOrNull(nowSpeak).orEmpty(),
                 )
             )
+            refreshMediaSessionPlaybackState()
         }
         updateReadAloudProgressSnapshot(progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
@@ -654,14 +661,17 @@ abstract class BaseReadAloudService : BaseService(),
             characterName = speechPlan.getOrNull(cursor.cueIndex)?.segment?.characterName.orEmpty(),
             roleType = cue.roleType,
         ))
+        refreshMediaSessionPlaybackState()
     }
 
     private fun setTimer(minute: Int) {
+        clearFinishChapterTimer()
         timeMinute = minute
         doDs()
     }
 
     private fun addTimer() {
+        clearFinishChapterTimer()
         timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
     }
@@ -682,11 +692,24 @@ abstract class BaseReadAloudService : BaseService(),
                 delay(60000)
                 if (timeMinute == PlaybackTimer.MIN_MINUTES) break
                 if (!pause) {
-                    timeMinute--
+                    val finishChapter = synchronized(finishChapterTimerLock) {
+                        timeMinute--
+                        if (timeMinute == PlaybackTimer.MIN_MINUTES &&
+                            ReadConfig.finishCurrentChapterAfterTimer
+                        ) {
+                            finishChapterAtIndex = ReadBook.durChapterIndex
+                            finishChapterAtIndex != NO_FINISH_CHAPTER
+                        } else {
+                            false
+                        }
+                    }
                     if (timeMinute == PlaybackTimer.MIN_MINUTES) {
-                        ReadAloud.stop(this@BaseReadAloudService)
+                        if (!finishChapter) {
+                            ReadAloud.stop(this@BaseReadAloudService)
+                        }
                         sessionStore.updateTimer(timeMinute)
                         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
+                        upReadAloudNotification()
                         break
                     }
                 }
@@ -724,12 +747,34 @@ abstract class BaseReadAloudService : BaseService(),
      * 更新媒体状态
      */
     private fun upMediaSessionPlaybackState(state: Int) {
-        val playbackSpeed = if (state == PlaybackStateCompat.STATE_PLAYING) 1f else 0f
-        mediaSessionCompat.setPlaybackState(
-            PlaybackStateCompat.Builder()
-                .setActions(READ_ALOUD_MEDIA_SESSION_ACTIONS)
-                .setState(state, PlaybackStateCompat.PLAYBACK_POSITION_UNKNOWN, playbackSpeed)
-                .build()
+        val playbackState = PlaybackStateCompat.Builder()
+            .setActions(READ_ALOUD_MEDIA_SESSION_ACTIONS)
+            // This is a normalized chapter-percentage scale, not a real time axis.
+            .setState(state, mediaProgressPositionMs(), 0f)
+        if (androidMediaControlEnabled) {
+            playbackState.addCustomAction(
+                PlaybackStateCompat.CustomAction.Builder(
+                    ACTION_ADD_TIMER,
+                    getString(R.string.set_timer),
+                    R.drawable.ic_time_add_24dp,
+                ).build()
+            )
+        }
+        mediaSessionCompat.setPlaybackState(playbackState.build())
+    }
+
+    private fun refreshMediaSessionPlaybackState() {
+        upMediaSessionPlaybackState(
+            if (pause) PlaybackStateCompat.STATE_PAUSED else PlaybackStateCompat.STATE_PLAYING
+        )
+    }
+
+    private fun mediaProgressPositionMs(): Long {
+        val playback = sessionStore.state.value.playback
+        return normalizedMediaProgressMs(
+            chapterPosition = playback.chapterPosition,
+            chapterLength = playback.chapterLength,
+            durationMs = MEDIA_PROGRESS_DURATION_MS,
         )
     }
 
@@ -749,13 +794,11 @@ abstract class BaseReadAloudService : BaseService(),
             .putText(MediaMetadataCompat.METADATA_KEY_ARTIST, textChapter?.title ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_ALBUM, ReadBook.book?.author ?: "")
             .putText(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentContent ?: "")
+            .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, MEDIA_PROGRESS_DURATION_MS)
             .build()
         mediaSessionCompat.setMetadata(metadata)
     }
 
-    /**
-     * 初始化MediaSession, 注册多媒体按钮
-     */
     /**
      * 初始化MediaSession, 注册多媒体按钮
      */
@@ -792,7 +835,7 @@ abstract class BaseReadAloudService : BaseService(),
             }
 
             override fun onCustomAction(action: String, extras: Bundle?) {
-                if (action == "ACTION_ADD_TIMER") addTimer()
+                if (action == ACTION_ADD_TIMER) addTimer()
             }
 
             override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
@@ -820,7 +863,14 @@ abstract class BaseReadAloudService : BaseService(),
         lifecycleScope.launch {
             AppConfigStore.observeBoolean(PreferKey.mediaButtonPerNext)
                 .drop(1)
-                .collect { upAndroidMediaControlNotification() }
+                .collect { upReadAloudNotification() }
+        }
+        lifecycleScope.launch {
+            AppConfigStore.observeBoolean(PreferKey.finishCurrentChapterAfterTimer)
+                .drop(1)
+                .collect { enabled ->
+                    if (enabled != true) clearFinishChapterTimer()
+                }
         }
     }
 
@@ -828,19 +878,13 @@ abstract class BaseReadAloudService : BaseService(),
         val changed = androidMediaControlEnabled != enabled
         androidMediaControlEnabled = enabled
         updateMediaSessionActivation()
+        notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         if (enabled) {
             upMediaMetadata()
-            upMediaSessionPlaybackState(
-                if (pause) PlaybackStateCompat.STATE_PAUSED
-                else PlaybackStateCompat.STATE_PLAYING
-            )
-        } else {
-            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         }
-        if (changed) {
+        refreshMediaSessionPlaybackState()
+        if (changed || enabled) {
             upReadAloudNotification()
-        } else if (enabled) {
-            upAndroidMediaControlNotification()
         }
     }
 
@@ -851,6 +895,7 @@ abstract class BaseReadAloudService : BaseService(),
         }
         systemMediaCompatibilityEnabled = enabled
         updateMediaSessionActivation()
+        refreshMediaSessionPlaybackState()
         upReadAloudNotification()
     }
 
@@ -906,10 +951,10 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun upReadAloudNotification() {
-        upAndroidMediaControlNotification()
         upNotificationJob = lifecycleScope.launch(Main.immediate) {
             try {
-                val notification = createNotification()
+                notificationManager.cancel(NotificationId.ReadAloudMediaControl)
+                val notification = createForegroundNotification()
                 notificationManager.notify(NotificationId.ReadAloudService, notification.build())
             } catch (e: Exception) {
                 AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true)
@@ -994,15 +1039,12 @@ abstract class BaseReadAloudService : BaseService(),
         return builder
     }
 
-    private fun upAndroidMediaControlNotification() {
-        lifecycleScope.launch(Main.immediate) {
-            if (!androidMediaControlEnabled) return@launch
-            notificationManager.notify(
-                NotificationId.ReadAloudMediaControl,
-                createAndroidMediaControlNotification().build(),
-            )
+    private fun createForegroundNotification(): NotificationCompat.Builder =
+        if (androidMediaControlEnabled) {
+            createAndroidMediaControlNotification()
+        } else {
+            createNotification()
         }
-    }
 
     private fun createAndroidMediaControlNotification(): NotificationCompat.Builder {
         val navigateByChapter = ReadConfig.mediaButtonPerNext
@@ -1023,6 +1065,7 @@ abstract class BaseReadAloudService : BaseService(),
             ?: getString(R.string.read_aloud_s)
         return NotificationCompat.Builder(this, AppConst.channelIdReadAloud)
             .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
             .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
             .setSmallIcon(R.drawable.ic_volume_up)
             .setSubText(ReadBook.book?.author ?: getString(R.string.read_aloud))
@@ -1052,6 +1095,11 @@ abstract class BaseReadAloudService : BaseService(),
                 nextLabel,
                 aloudServicePendingIntent(nextAction),
             )
+            .addAction(
+                R.drawable.ic_time_add_24dp,
+                getString(R.string.set_timer),
+                aloudServicePendingIntent(IntentAction.addTimer),
+            )
             .setStyle(
                 androidx.media.app.NotificationCompat.MediaStyle()
                     .setMediaSession(mediaSessionCompat.sessionToken)
@@ -1068,21 +1116,21 @@ abstract class BaseReadAloudService : BaseService(),
      * 更新通知
      */
     override fun startForegroundNotification() {
-        execute {
-            try {
-                val notification = createNotification()
-                startForeground(NotificationId.ReadAloudService, notification.build())
-            } catch (e: Exception) {
-                AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true)
-                //创建通知出错不结束服务就会崩溃,服务必须绑定通知
-                stopSelf()
-            }
+        try {
+            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
+            val notification = createForegroundNotification()
+            startForeground(NotificationId.ReadAloudService, notification.build())
+        } catch (e: Exception) {
+            AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true)
+            //创建通知出错不结束服务就会崩溃,服务必须绑定通知
+            stopSelf()
         }
     }
 
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
     open fun prevChapter() {
+        clearFinishChapterTimer()
         ReadBook.upReadTime()
         toLast = false
         resumeReadAloudInternal()
@@ -1090,6 +1138,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     open fun nextChapter() {
+        clearFinishChapterTimer()
         ReadBook.upReadTime()
         AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
@@ -1098,6 +1147,57 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    /** Handles a playback engine's natural chapter boundary atomically with timer expiry. */
+    protected fun completeCurrentChapter() {
+        synchronized(finishChapterTimerLock) {
+            val chapterIndex = textChapter?.chapter?.index ?: currentChapterIndex
+            if (ReadBook.durChapterIndex != chapterIndex) {
+                if (finishChapterAtIndex == chapterIndex) {
+                    finishChapterAtIndex = NO_FINISH_CHAPTER
+                }
+                return
+            }
+            val shouldStop = when {
+                finishChapterAtIndex == NO_FINISH_CHAPTER -> false
+                finishChapterAtIndex != chapterIndex -> {
+                    finishChapterAtIndex = NO_FINISH_CHAPTER
+                    false
+                }
+                !ReadConfig.finishCurrentChapterAfterTimer -> {
+                    finishChapterAtIndex = NO_FINISH_CHAPTER
+                    false
+                }
+                else -> {
+                    finishChapterAtIndex = NO_FINISH_CHAPTER
+                    true
+                }
+            }
+            if (shouldStop) {
+                pause = true
+                stopSelf()
+            } else {
+                // synchronized is reentrant, so nextChapter() may clear the same state safely.
+                nextChapter()
+            }
+        }
+    }
+
+    private fun clearFinishChapterTimer() {
+        synchronized(finishChapterTimerLock) {
+            finishChapterAtIndex = NO_FINISH_CHAPTER
+        }
+    }
+
+    private fun clearFinishChapterTimerIfChapterChanged(chapterIndex: Int) {
+        synchronized(finishChapterTimerLock) {
+            if (finishChapterAtIndex != NO_FINISH_CHAPTER &&
+                finishChapterAtIndex != chapterIndex
+            ) {
+                finishChapterAtIndex = NO_FINISH_CHAPTER
+            }
+        }
+    }
+
     private fun initPhoneStateListener() {
         val needRegister = ReadConfig.ignoreAudioFocus && ReadConfig.pauseReadAloudWhilePhoneCalls
         if (needRegister && registeredPhoneStateListener) {
@@ -1177,6 +1277,16 @@ abstract class BaseReadAloudService : BaseService(),
 
 }
 
+internal fun normalizedMediaProgressMs(
+    chapterPosition: Int,
+    chapterLength: Int,
+    durationMs: Long,
+): Long {
+    if (chapterLength <= 0 || durationMs <= 0) return 0L
+    val position = chapterPosition.coerceIn(0, chapterLength)
+    return position.toLong() * durationMs / chapterLength
+}
+
 internal inline fun findReadAloudPageIndex(
     currentPageIndex: Int,
     chapterPosition: Int,

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

@@ -204,10 +204,14 @@ class HttpReadAloudService : BaseReadAloudService(),
         preDownloadJob?.cancel()
     }
 
-    private fun updateNextPos() {
+    private fun updateNextPos(naturalCompletion: Boolean = false) {
         if (!playbackQueue.isEmpty) {
             val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
-            playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: nextChapter()
+            playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: if (naturalCompletion) {
+                completeCurrentChapter()
+            } else {
+                nextChapter()
+            }
             return
         }
         readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
@@ -215,7 +219,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         if (nowSpeak < contentList.lastIndex) {
             nowSpeak++
         } else {
-            nextChapter()
+            if (naturalCompletion) completeCurrentChapter() else nextChapter()
         }
     }
 
@@ -1120,7 +1124,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                 val interval = readAloudSettings.ttsParagraphInterval.toLong()
                 if (interval > 0) {
                     val isLastParagraph = nowSpeak >= contentList.lastIndex
-                    updateNextPos()
+                    updateNextPos(naturalCompletion = true)
                     exoPlayer.stop()
                     exoPlayer.clearMediaItems()
                     if (!pause && !isLastParagraph) {
@@ -1138,7 +1142,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                         }
                     }
                 } else {
-                    updateNextPos()
+                    updateNextPos(naturalCompletion = true)
                     exoPlayer.stop()
                     exoPlayer.clearMediaItems()
                 }
@@ -1163,7 +1167,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
             playErrorNo = 0
         }
-        updateNextPos()
+        updateNextPos(naturalCompletion = reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO)
         upPlayPos()
         upMediaMetadata(showContent = true)
     }
@@ -1183,7 +1187,7 @@ class HttpReadAloudService : BaseReadAloudService(),
                 exoPlayer.prepare()
             } else {
                 exoPlayer.clearMediaItems()
-                updateNextPos()
+                updateNextPos(naturalCompletion = true)
             }
         }
     }

+ 12 - 8
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -229,7 +229,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 if (!isAddedText) {
                     playStop()
                     delay(1000)
-                    nextChapter()
+                    completeCurrentChapter()
                 }
             }
         }.onError {
@@ -343,7 +343,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
             utteranceStartReadAloudNumber = readAloudNumber
             textChapter?.let {
                 if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
-                    nextParagraph()
+                    nextParagraph(naturalCompletion = true)
                 }
                 if (pageIndex + 1 < it.pageSize
                     && readAloudNumber + 1 > it.getReadLength(pageIndex + 1)
@@ -359,7 +359,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         override fun onDone(s: String) {
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onDone utteranceId:$s")
-            nextParagraph()
+            nextParagraph(naturalCompletion = true)
             if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
@@ -386,18 +386,22 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 TAG,
                 "onError nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId errorCode:$errorCode"
             )
-            nextParagraph()
+            nextParagraph(naturalCompletion = true)
             if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
-        private fun nextParagraph() {
+        private fun nextParagraph(naturalCompletion: Boolean = false) {
             if (hasSpeechPlaybackQueue) {
                 val current = playbackCursor
                     ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
-                playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: nextChapter()
+                playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: if (naturalCompletion) {
+                    completeCurrentChapter()
+                } else {
+                    nextChapter()
+                }
                 return
             }
             //跳过全标点段落
@@ -410,7 +414,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
                 paragraphStartPos = 0
                 nowSpeak++
                 if (nowSpeak >= contentList.size) {
-                    nextChapter()
+                    if (naturalCompletion) completeCurrentChapter() else nextChapter()
                     return
                 }
             } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
@@ -420,7 +424,7 @@ class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
         override fun onError(s: String) {
             if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex s:$s")
-            nextParagraph()
+            nextParagraph(naturalCompletion = true)
             if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
                 needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()

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

@@ -93,6 +93,8 @@ class ReadAloudDelegate(
                         readAloudTtsFollowSys = prefs.ttsFollowSys,
                         readAloudTtsSpeechRate = prefs.ttsSpeechRate,
                         readAloudTtsTimer = prefs.ttsTimer,
+                        readAloudFinishCurrentChapterAfterTimer =
+                            prefs.finishCurrentChapterAfterTimer,
                         speechAnalysisMode = prefs.speechAnalysisMode,
                         useMultiSpeaker = prefs.useMultiSpeaker,
                         defaultReadAloudInterface = prefs.defaultInterface,
@@ -361,6 +363,11 @@ class ReadAloudDelegate(
         host.updateState { it.copy(readAloudTtsTimer = timer) }
     }
 
+    fun setFinishCurrentChapterAfterTimer(value: Boolean) {
+        updateSettings { it.copy(finishCurrentChapterAfterTimer = value) }
+        host.updateState { it.copy(readAloudFinishCurrentChapterAfterTimer = value) }
+    }
+
     fun setTtsSpeechRate(value: Int) {
         scope.launch {
             readAloudSettingsRepository.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }

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

@@ -288,6 +288,7 @@ data class ReadBookUiState(
     val readAloudTtsFollowSys: Boolean = false,
     val readAloudTtsSpeechRate: Int = 10,
     val readAloudTtsTimer: Int = 0,
+    val readAloudFinishCurrentChapterAfterTimer: Boolean = false,
     val speechAnalysisMode: String = "rule",
     val useMultiSpeaker: Boolean = true,
     val defaultReadAloudInterface: String = ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC,
@@ -775,6 +776,7 @@ sealed interface ReadBookIntent {
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
+    data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data class SetSpeechAnalysisMode(val value: String) : ReadBookIntent

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -1331,6 +1331,8 @@ class ReadBookViewModel(
             is ReadBookIntent.ReadAloudPrevChapter -> readAloudDelegate.prevChapter()
             is ReadBookIntent.ReadAloudNextChapter -> readAloudDelegate.nextChapter()
             is ReadBookIntent.SetReadAloudTtsTimer -> readAloudDelegate.setTtsTimer(intent.value)
+            is ReadBookIntent.SetFinishCurrentChapterAfterTimer ->
+                readAloudDelegate.setFinishCurrentChapterAfterTimer(intent.value)
             is ReadBookIntent.SetReadAloudTtsFollowSys ->
                 readAloudDelegate.setTtsFollowSys(intent.value)
             is ReadBookIntent.SetReadAloudTtsSpeechRate ->

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

@@ -262,6 +262,15 @@ fun ReadAloudContent(
             },
         )
 
+        TinySwitchSettingItem(
+            title = stringResource(R.string.finish_current_chapter_after_timer),
+            description = stringResource(R.string.finish_current_chapter_after_timer_summary),
+            checked = state.readAloudFinishCurrentChapterAfterTimer,
+            onCheckedChange = {
+                onIntent(ReadBookIntent.SetFinishCurrentChapterAfterTimer(it))
+            },
+        )
+
         Spacer(Modifier.height(8.dp))
 
         Row(

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -40,6 +40,7 @@ data class ReadAloudPlayerUiState(
     val isPaused: Boolean = false,
     val speed: Int = 10,
     val timerMinutes: Int = 0,
+    val finishCurrentChapterAfterTimer: Boolean = false,
     val bgMode: Int = 0,
 )
 
@@ -57,6 +58,7 @@ sealed interface ReadAloudPlayerIntent {
     data class SetBgMode(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
+    data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadAloudPlayerIntent
     data class SeekTo(val chapterPosition: Int) : ReadAloudPlayerIntent
 }
 

+ 16 - 2
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt

@@ -6,6 +6,7 @@ import com.jeremyliao.liveeventbus.LiveEventBus
 import io.legado.app.constant.EventBus
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
@@ -69,7 +70,8 @@ class ReadAloudPlayerCoordinator(
     val state: Flow<ReadAloudPlayerSourceState> = combine(
         sessionStore.state,
         bookWithChapters,
-    ) { session, (book, chapters) ->
+        readAloudSettingsGateway.settings,
+    ) { session, (book, chapters), settings ->
         val playback = session.playback
         ReadAloudPlayerSourceState(
             bookUrl = book.bookUrl,
@@ -90,6 +92,7 @@ class ReadAloudPlayerCoordinator(
             isPaused = session.status != ReadAloudSessionStatus.Playing,
             speed = ReadConfig.ttsSpeechRate,
             timerMinutes = session.timerMinutes,
+            finishCurrentChapterAfterTimer = settings.finishCurrentChapterAfterTimer,
         )
     }
 
@@ -116,6 +119,8 @@ class ReadAloudPlayerCoordinator(
             isPaused = session.status != ReadAloudSessionStatus.Playing,
             speed = ReadConfig.ttsSpeechRate,
             timerMinutes = session.timerMinutes,
+            finishCurrentChapterAfterTimer =
+                readAloudSettingsGateway.currentSettings.finishCurrentChapterAfterTimer,
         )
     }
 
@@ -163,7 +168,15 @@ class ReadAloudPlayerCoordinator(
         ReadAloud.upTtsSpeechRate(application)
     }
 
-    fun setTimer(minutes: Int) = ReadAloud.setTimer(application, minutes)
+    suspend fun setTimer(minutes: Int) {
+        val timer = PlaybackTimer.normalize(minutes)
+        readAloudSettingsGateway.update { it.copy(ttsTimer = timer) }
+        ReadAloud.setTimer(application, timer)
+    }
+
+    suspend fun setFinishCurrentChapterAfterTimer(value: Boolean) {
+        readAloudSettingsGateway.update { it.copy(finishCurrentChapterAfterTimer = value) }
+    }
 
     fun seekTo(chapterPosition: Int, chapterLength: Int) {
         val chapter = ReadBook.curTextChapter ?: return
@@ -222,4 +235,5 @@ data class ReadAloudPlayerSourceState(
     val isPaused: Boolean,
     val speed: Int,
     val timerMinutes: Int,
+    val finishCurrentChapterAfterTimer: Boolean,
 )

+ 43 - 19
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -113,6 +113,7 @@ import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import io.legado.app.ui.widget.components.image.cover.CoverBlurBackdrop
 import io.legado.app.ui.widget.components.pager.rememberPagerFlingPassThroughConnection
+import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.text.AppText
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
@@ -169,8 +170,13 @@ fun ReadAloudPlayerScreenContent(
     }
     val pageContentPadding = PaddingValues(
         top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 88.dp,
-        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() +
-                if (activeAdjustment == null) 216.dp else 264.dp,
+        bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + when (
+            activeAdjustment
+        ) {
+            null -> 216.dp
+            PlayerAdjustment.Speed -> 264.dp
+            PlayerAdjustment.Timer -> 344.dp
+        },
     )
     val overlayHazeStyle = HazeLegado.ultraThinPlus(
         containerColor = LegadoTheme.colorScheme.surface,
@@ -416,23 +422,41 @@ fun ReadAloudPlayerScreenContent(
                         )
                     }
                     AnimatedVisibility(activeAdjustment == PlayerAdjustment.Timer) {
-                        PlayerAdjustmentSlider(
-                            title = stringResource(R.string.set_timer),
-                            value = timerPreview.coerceIn(0f, 180f),
-                            valueLabel = if (timerPreview == 0f) {
-                                stringResource(R.string.close)
-                            } else {
-                                stringResource(R.string.timer_m, timerPreview.roundToInt())
-                            },
-                            startLabel = stringResource(R.string.close),
-                            endLabel = stringResource(R.string.timer_m, 180),
-                            onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
-                            onValueChangeFinished = {
-                                onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
-                            },
-                            valueRange = 0f..180f,
-                            steps = 17,
-                        )
+                        Column {
+                            PlayerAdjustmentSlider(
+                                title = stringResource(R.string.set_timer),
+                                value = timerPreview.coerceIn(0f, 180f),
+                                valueLabel = if (timerPreview == 0f) {
+                                    stringResource(R.string.close)
+                                } else {
+                                    stringResource(R.string.timer_m, timerPreview.roundToInt())
+                                },
+                                startLabel = stringResource(R.string.close),
+                                endLabel = stringResource(R.string.timer_m, 180),
+                                onValueChange = { timerPreview = (it / 10f).roundToInt() * 10f },
+                                onValueChangeFinished = {
+                                    onIntent(ReadAloudPlayerIntent.SetTimer(timerPreview.roundToInt()))
+                                },
+                                valueRange = 0f..180f,
+                                steps = 17,
+                            )
+                            TinySwitchSettingItem(
+                                title = stringResource(
+                                    R.string.finish_current_chapter_after_timer
+                                ),
+                                description = stringResource(
+                                    R.string.finish_current_chapter_after_timer_summary
+                                ),
+                                checked = state.finishCurrentChapterAfterTimer,
+                                modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+                                color = LegadoTheme.colorScheme.surfaceContainerHigh,
+                                onCheckedChange = {
+                                    onIntent(
+                                        ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer(it)
+                                    )
+                                },
+                            )
+                        }
                     }
                 }
             }

+ 8 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -51,7 +51,13 @@ class ReadAloudPlayerViewModel(
             is ReadAloudPlayerIntent.SetSpeed -> viewModelScope.launch {
                 coordinator.setSpeed(intent.value)
             }
-            is ReadAloudPlayerIntent.SetTimer -> coordinator.setTimer(intent.minutes)
+            is ReadAloudPlayerIntent.SetTimer -> viewModelScope.launch {
+                coordinator.setTimer(intent.minutes)
+            }
+            is ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer ->
+                viewModelScope.launch {
+                    coordinator.setFinishCurrentChapterAfterTimer(intent.value)
+                }
             is ReadAloudPlayerIntent.SeekTo -> coordinator.seekTo(
                 chapterPosition = intent.chapterPosition,
                 chapterLength = uiState.value.chapterLength,
@@ -105,6 +111,7 @@ class ReadAloudPlayerViewModel(
             isPaused = source.isPaused,
             speed = source.speed,
             timerMinutes = source.timerMinutes,
+            finishCurrentChapterAfterTimer = source.finishCurrentChapterAfterTimer,
             bgMode = bgMode,
         )
     }

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -85,6 +85,7 @@ object ReadConfig {
     val ttsFollowSys get() = aloud.ttsFollowSys
     val ttsSpeechRate get() = aloud.ttsSpeechRate
     val ttsTimer get() = aloud.ttsTimer
+    val finishCurrentChapterAfterTimer get() = aloud.finishCurrentChapterAfterTimer
     val ttsParagraphInterval get() = aloud.ttsParagraphInterval
     val ignoreAudioFocus get() = aloud.ignoreAudioFocus
     val pauseReadAloudWhilePhoneCalls get() = aloud.pauseReadAloudWhilePhoneCalls

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

@@ -1637,7 +1637,9 @@
     <string name="locate_current">定位至当前</string>
     <string name="padding_display_cutouts">填充刘海区域</string>
     <string name="read_aloud_android_media_control">安卓媒体控制</string>
-    <string name="read_aloud_android_media_control_summary">在保留原朗读通知的同时显示系统媒体控制</string>
+    <string name="read_aloud_android_media_control_summary">开启后,使用安卓系统媒体控制替换默认朗读通知</string>
+    <string name="finish_current_chapter_after_timer">定时到点后读完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定时结束后继续朗读,读完当前章节再停止</string>
     <string name="system_media_control_compatibility_change">使用媒体通道</string>
     <string name="system_media_control_compatibility_change_summary">使用OEM厂商提供的媒体通道,可能解决一些通知无法显示的问题</string>
     <string name="read_aloud_pause_resume">朗读暂停/继续</string>

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

@@ -2211,7 +2211,9 @@
     <string name="system_contrast_value">系統對比度:%1$s</string>
     <string name="system_engine">系統引擎</string>
     <string name="read_aloud_android_media_control">Android 媒體控制</string>
-    <string name="read_aloud_android_media_control_summary">保留原有朗讀通知,並同時顯示系統媒體控制</string>
+    <string name="read_aloud_android_media_control_summary">啟用後,以 Android 系統媒體控制取代預設朗讀通知</string>
+    <string name="finish_current_chapter_after_timer">定時到點後讀完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定時結束後繼續朗讀,讀完目前章節再停止</string>
     <string name="system_media_control_compatibility_change">系統媒體控制相容性</string>
     <string name="system_media_control_compatibility_change_summary">若鎖定螢幕缺少系統媒體控制項時啟用(例如 OneUI 7.0、OriginOS)</string>
     <string name="tabletInterface">平板介面</string>

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

@@ -1778,7 +1778,9 @@
     <string name="firebase_enable_summary">允許應用使用 Google Firebase 進行崩潰和效能報告</string>
     <string name="locate_current">定位目前位置</string>
     <string name="read_aloud_android_media_control">Android 媒體控制</string>
-    <string name="read_aloud_android_media_control_summary">在保留原朗讀通知的同時顯示系統媒體控制</string>
+    <string name="read_aloud_android_media_control_summary">啟用後,以 Android 系統媒體控制取代預設朗讀通知</string>
+    <string name="finish_current_chapter_after_timer">定時到點後讀完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定時結束後繼續朗讀,讀完目前章節再停止</string>
     <string name="system_media_control_compatibility_change">系統媒體控制相容性</string>
     <string name="system_media_control_compatibility_change_summary">如果鎖定畫面上缺少系統媒體控制項,請啟用此選項(例如 OneUI 7.0、OriginOS)</string>
     <string name="read_aloud_pause_resume">暫停/繼續朗讀</string>

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

@@ -1692,7 +1692,9 @@
     <string name="locate_current">Locate Current</string>
     <string name="padding_display_cutouts">Fill Display Cutout</string>
     <string name="read_aloud_android_media_control">Android media controls</string>
-    <string name="read_aloud_android_media_control_summary">Show Android system media controls while keeping the original read-aloud notification</string>
+    <string name="read_aloud_android_media_control_summary">Replace the default read-aloud notification with Android system media controls when enabled</string>
+    <string name="finish_current_chapter_after_timer">Finish current chapter after timer</string>
+    <string name="finish_current_chapter_after_timer_summary">When the timer expires, continue reading until the current chapter finishes</string>
     <string name="system_media_control_compatibility_change">System Media Control Compatibility</string>
     <string name="system_media_control_compatibility_change_summary">Enable if system media controls are missing on lock screen (e.g. OneUI 7.0, OriginOS)</string>
     <string name="read_aloud_pause_resume">Pause/Resume Reading Aloud</string>

+ 11 - 2
app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt

@@ -8,14 +8,14 @@ import org.junit.Test
 class ReadAloudSettingsMappingTest {
 
     @Test
-    fun `朗读设置 26 键写映射逐字段对应`() {
+    fun `朗读设置 27 键写映射逐字段对应`() {
         readAloudMappingSamples().forEach { settings ->
             assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
     }
 
     @Test
-    fun `朗读设置 26 键读映射逐字段对应`() {
+    fun `朗读设置 27 键读映射逐字段对应`() {
         readAloudMappingSamples().forEach { expected ->
             assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toReadAloudSettings())
         }
@@ -52,6 +52,13 @@ class ReadAloudSettingsMappingTest {
 
         assertEquals(false, settings.androidMediaControlEnabled)
     }
+
+    @Test
+    fun `定时到点后读完本章默认关闭`() {
+        val settings = emptyMap<String, Any?>().toTestPreferences().toReadAloudSettings()
+
+        assertEquals(false, settings.finishCurrentChapterAfterTimer)
+    }
 }
 
 private const val CAPSULE_OFFSET_X = "read_aloud_capsule_offset_x"
@@ -87,6 +94,7 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         base.copy(androidMediaControlEnabled = true),
         base.copy(systemMediaControlCompatibilityChange = false),
         base.copy(streamReadAloudAudio = true),
+        base.copy(finishCurrentChapterAfterTimer = true),
         base.copy(ttsFollowSys = false),
         base.copy(useMultiSpeaker = false),
     )
@@ -111,6 +119,7 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.systemMediaControlCompatibilityChange to systemMediaControlCompatibilityChange,
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,
     PreferKey.ttsTimer to ttsTimer,
+    PreferKey.finishCurrentChapterAfterTimer to finishCurrentChapterAfterTimer,
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,

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

@@ -87,4 +87,12 @@ class TTSReadAloudProgressTest {
         )
     }
 
+    @Test
+    fun mediaProgressUsesTheSameChapterPercentageAsThePlayer() {
+        assertEquals(25_000L, normalizedMediaProgressMs(670, 2_680, 100_000L))
+        assertEquals(0L, normalizedMediaProgressMs(-10, 2_680, 100_000L))
+        assertEquals(100_000L, normalizedMediaProgressMs(3_000, 2_680, 100_000L))
+        assertEquals(0L, normalizedMediaProgressMs(10, 0, 100_000L))
+    }
+
 }