فهرست منبع

Merge pull request #2024 from lingran7031/main

修复听书播放器蒙版点击穿透,添加Android媒体控制通知,朗读定时添加读完本章功能
HapeLee 2 ماه پیش
والد
کامیت
0c2534a4cb
24فایلهای تغییر یافته به همراه505 افزوده شده و 115 حذف شده
  1. 1 0
      app/src/main/java/io/legado/app/constant/NotificationId.kt
  2. 3 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 11 4
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  4. 2 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  5. 3 2
      app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt
  6. 306 69
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  7. 11 7
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  8. 12 8
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  9. 13 1
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  10. 4 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  11. 4 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 8 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  13. 9 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudScreen.kt
  14. 2 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  15. 16 2
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  16. 46 19
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  17. 8 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  18. 2 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  19. 4 0
      app/src/main/res/values-zh-rCN/strings.xml
  20. 4 0
      app/src/main/res/values-zh-rHK/strings.xml
  21. 4 0
      app/src/main/res/values-zh-rTW/strings.xml
  22. 4 0
      app/src/main/res/values/strings.xml
  23. 20 2
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  24. 8 0
      app/src/test/java/io/legado/app/service/TTSReadAloudProgressTest.kt

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

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

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

@@ -337,7 +337,10 @@ object PreferKey {
     const val menuAlpha = "menuAlpha"
     const val colorImage = "colorImage"
     const val bookshelfRefreshingLimit = "bookshelfRefreshingLimit"
+    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"

+ 11 - 4
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -57,10 +57,13 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     capsuleOffsetY = compatDsValue(ReadAloudKeys.CapsuleOffsetY, 0f),
     mediaButtonPerNext = compatDsValue(ReadAloudKeys.MediaButtonPerNext, false),
     readAloudByPage = compatDsValue(ReadAloudKeys.ReadAloudByPage, false),
+    androidMediaControlEnabled = compatDsValue(ReadAloudKeys.AndroidMediaControlEnabled, false),
     systemMediaControlCompatibilityChange =
         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"),
@@ -87,11 +90,13 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.capsuleAutoCollapse to capsuleAutoCollapse,
     ReadAloudKeys.CapsuleOffsetX.name to capsuleOffsetX,
     ReadAloudKeys.CapsuleOffsetY.name to capsuleOffsetY,
-    MEDIA_BUTTON_PER_NEXT to mediaButtonPerNext,
+    PreferKey.mediaButtonPerNext to mediaButtonPerNext,
     PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.readAloudAndroidMediaControl to androidMediaControlEnabled,
     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,
@@ -102,8 +107,6 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.ttsPreSynthesisConcurrency to ttsPreSynthesisConcurrency,
 )
 
-private const val MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
-
 private object ReadAloudKeys {
     val TtsParagraphInterval = intPreferencesKey(PreferKey.ttsParagraphInterval)
     val AudioCacheCleanTime = intPreferencesKey(PreferKey.audioCacheCleanTime)
@@ -117,12 +120,16 @@ private object ReadAloudKeys {
     val CapsuleAutoCollapse = booleanPreferencesKey(PreferKey.capsuleAutoCollapse)
     val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
     val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
-    val MediaButtonPerNext = booleanPreferencesKey(MEDIA_BUTTON_PER_NEXT)
+    val MediaButtonPerNext = booleanPreferencesKey(PreferKey.mediaButtonPerNext)
     val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
+    val AndroidMediaControlEnabled =
+        booleanPreferencesKey(PreferKey.readAloudAndroidMediaControl)
     val SystemMediaControlCompatibilityChange =
         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)

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

@@ -15,9 +15,11 @@ data class ReadAloudSettings(
     val capsuleOffsetY: Float = 0f,
     val mediaButtonPerNext: Boolean = false,
     val readAloudByPage: Boolean = false,
+    val androidMediaControlEnabled: Boolean = false,
     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",

+ 3 - 2
app/src/main/java/io/legado/app/receiver/MediaButtonReceiver.kt

@@ -5,6 +5,7 @@ import android.content.Context
 import android.content.Intent
 import android.view.KeyEvent
 import io.legado.app.constant.EventBus
+import io.legado.app.constant.PreferKey
 import io.legado.app.data.appDb
 import io.legado.app.help.LifecycleHelp
 import io.legado.app.help.config.AppConfig
@@ -48,7 +49,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
                     LogUtils.d(TAG, "Receive mediaButton event, keycode:$keycode")
                     when (keycode) {
                         KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
-                            if (context.getPrefBoolean("mediaButtonPerNext", false)) {
+                            if (context.getPrefBoolean(PreferKey.mediaButtonPerNext, false)) {
                                 ReadBook.moveToPrevChapter(true)
                             } else {
                                 ReadAloud.prevParagraph(context)
@@ -56,7 +57,7 @@ class MediaButtonReceiver : BroadcastReceiver() {
                         }
 
                         KeyEvent.KEYCODE_MEDIA_NEXT -> {
-                            if (context.getPrefBoolean("mediaButtonPerNext", false)) {
+                            if (context.getPrefBoolean(PreferKey.mediaButtonPerNext, false)) {
                                 ReadBook.moveToNextChapter(true)
                             } else {
                                 ReadAloud.nextParagraph(context)

+ 306 - 69
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -115,6 +115,17 @@ 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
+                    or PlaybackStateCompat.ACTION_PAUSE
+                    or PlaybackStateCompat.ACTION_PLAY_PAUSE
+                    or PlaybackStateCompat.ACTION_STOP
+                    or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
+                    or PlaybackStateCompat.ACTION_SKIP_TO_NEXT)
 
     }
 
@@ -158,7 +169,14 @@ abstract class BaseReadAloudService : BaseService(),
     private var needResumeOnCallStateIdle = false
     private var registeredPhoneStateListener = false
     private var dsJob: Job? = null
-    private var upNotificationJob: Coroutine<*>? = null
+    private var upNotificationJob: Job? = null
+    @Volatile
+    private var systemMediaCompatibilityEnabled =
+        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 =
@@ -187,6 +205,7 @@ abstract class BaseReadAloudService : BaseService(),
         pause = false
         observeLiveBus()
         initMediaSession()
+        observeMediaControlSettings()
         initBroadcastReceiver()
         initPhoneStateListener()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
@@ -243,7 +262,11 @@ abstract class BaseReadAloudService : BaseService(),
         unregisterReceiver(broadcastReceiver)
         postEvent(EventBus.ALOUD_STATE, Status.STOP)
         notificationManager.cancel(NotificationId.ReadAloudService)
+        notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED)
+        systemMediaCompatibilityEnabled = false
+        androidMediaControlEnabled = false
+        mediaSessionCompat.isActive = false
         mediaSessionCompat.release()
         ReadBook.uploadProgress()
         unregisterPhoneStateListener(phoneStateListener)
@@ -253,6 +276,7 @@ abstract class BaseReadAloudService : BaseService(),
         }
         upNotificationJob?.invokeOnCompletion {
             notificationManager.cancel(NotificationId.ReadAloudService)
+            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
         }
     }
 
@@ -280,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) {
@@ -403,6 +428,7 @@ abstract class BaseReadAloudService : BaseService(),
         pause = false
         needResumeOnAudioFocusGain = false
         needResumeOnCallStateIdle = false
+        upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         upReadAloudNotification()
         sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
@@ -473,6 +499,7 @@ abstract class BaseReadAloudService : BaseService(),
                     text = contentList.getOrNull(nowSpeak).orEmpty(),
                 )
             )
+            refreshMediaSessionPlaybackState()
         }
         updateReadAloudProgressSnapshot(progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
@@ -634,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()
     }
@@ -662,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
                     }
                 }
@@ -704,19 +747,34 @@ abstract class BaseReadAloudService : BaseService(),
      * 更新媒体状态
      */
     private fun upMediaSessionPlaybackState(state: Int) {
-        mediaSessionCompat.setPlaybackState(
-            PlaybackStateCompat.Builder()
-                .setActions(MediaHelp.MEDIA_SESSION_ACTIONS)
-                .setState(state, nowSpeak.toLong(), 1f)
-                // 为系统媒体控件添加定时按钮
-//                .addCustomAction(
-//                    PlaybackStateCompat.CustomAction.Builder(
-//                        "ACTION_ADD_TIMER",
-//                        getString(R.string.set_timer),
-//                        R.drawable.ic_time_add_24dp
-//                    ).build()
-//                )
-                .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,
         )
     }
 
@@ -736,69 +794,116 @@ 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, 注册多媒体按钮
      */
     @SuppressLint("UnspecifiedImmutableFlag")
     private fun initMediaSession() {
-        if (ReadConfig.systemMediaControlCompatibilityChange) {
-            mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
-                override fun onPlay() {
-                    resumeReadAloud()
-                }
+        mediaSessionCompat.setSessionActivity(readAloudActivityPendingIntent())
+        mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
+            override fun onPlay() {
+                resumeReadAloud()
+            }
 
-                override fun onPause() {
-                    pauseReadAloud()
-                }
+            override fun onPause() {
+                pauseReadAloud()
+            }
 
-                override fun onSkipToNext() {
-                    if (ReadConfig.mediaButtonPerNext) {
-                        nextChapter()
-                    } else {
-                        nextP()
-                    }
+            override fun onSkipToNext() {
+                if (ReadConfig.mediaButtonPerNext) {
+                    nextChapter()
+                } else {
+                    nextP()
                 }
+            }
 
-                override fun onSkipToPrevious() {
-                    if (ReadConfig.mediaButtonPerNext) {
-                        prevChapter()
-                    } else {
-                        prevP()
-                    }
+            override fun onSkipToPrevious() {
+                if (ReadConfig.mediaButtonPerNext) {
+                    prevChapter()
+                } else {
+                    prevP()
                 }
+            }
 
-                override fun onStop() {
-                    stopSelf()
-                }
+            override fun onStop() {
+                stopSelf()
+            }
 
-                override fun onCustomAction(action: String, extras: Bundle?) {
-                    if (action == "ACTION_ADD_TIMER") addTimer()
-                }
+            override fun onCustomAction(action: String, extras: Bundle?) {
+                if (action == ACTION_ADD_TIMER) addTimer()
+            }
 
-                override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
-                    return MediaButtonReceiver.handleIntent(
-                        this@BaseReadAloudService, mediaButtonEvent
-                    )
-                }
-            })
-        } else {
-            mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
-                override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
-                    return MediaButtonReceiver.handleIntent(
-                        this@BaseReadAloudService, mediaButtonEvent
-                    )
+            override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
+                return MediaButtonReceiver.handleIntent(
+                    this@BaseReadAloudService, mediaButtonEvent
+                )
+            }
+        })
+        updateMediaSessionActivation()
+    }
+
+    private fun observeMediaControlSettings() {
+        lifecycleScope.launch {
+            AppConfigStore.observeBoolean(PreferKey.readAloudAndroidMediaControl).collect {
+                setAndroidMediaControlEnabled(it == true)
+            }
+        }
+        lifecycleScope.launch {
+            AppConfigStore.observeBoolean(
+                PreferKey.systemMediaControlCompatibilityChange
+            ).collect {
+                setSystemMediaCompatibilityEnabled(it ?: true)
+            }
+        }
+        lifecycleScope.launch {
+            AppConfigStore.observeBoolean(PreferKey.mediaButtonPerNext)
+                .drop(1)
+                .collect { upReadAloudNotification() }
+        }
+        lifecycleScope.launch {
+            AppConfigStore.observeBoolean(PreferKey.finishCurrentChapterAfterTimer)
+                .drop(1)
+                .collect { enabled ->
+                    if (enabled != true) clearFinishChapterTimer()
                 }
-            })
         }
     }
 
+    private fun setAndroidMediaControlEnabled(enabled: Boolean) {
+        val changed = androidMediaControlEnabled != enabled
+        androidMediaControlEnabled = enabled
+        updateMediaSessionActivation()
+        notificationManager.cancel(NotificationId.ReadAloudMediaControl)
+        if (enabled) {
+            upMediaMetadata()
+        }
+        refreshMediaSessionPlaybackState()
+        if (changed || enabled) {
+            upReadAloudNotification()
+        }
+    }
+
+    private fun setSystemMediaCompatibilityEnabled(enabled: Boolean) {
+        if (systemMediaCompatibilityEnabled == enabled) {
+            updateMediaSessionActivation()
+            return
+        }
+        systemMediaCompatibilityEnabled = enabled
+        updateMediaSessionActivation()
+        refreshMediaSessionPlaybackState()
+        upReadAloudNotification()
+    }
+
+    private fun updateMediaSessionActivation() {
+        mediaSessionCompat.isActive =
+            systemMediaCompatibilityEnabled || androidMediaControlEnabled
+    }
+
     /**
      * 注册多媒体按钮监听
      */
@@ -846,9 +951,10 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun upReadAloudNotification() {
-        upNotificationJob = execute {
+        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)
@@ -859,8 +965,7 @@ abstract class BaseReadAloudService : BaseService(),
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
             .setShowActionsInCompactView(1, 2, 4)
-        if (ReadConfig.systemMediaControlCompatibilityChange) {
-            //fix #4090 android 14 can not show play control in lock screen
+        if (systemMediaCompatibilityEnabled && !androidMediaControlEnabled) {
             mediaStyle.setMediaSession(mediaSessionCompat.sessionToken)
         }
         return mediaStyle
@@ -891,12 +996,7 @@ abstract class BaseReadAloudService : BaseService(),
             .setOnlyAlertOnce(true)
             .setContentTitle(nTitle)
             .setContentText(nSubtitle)
-            .setContentIntent(
-                activityPendingIntent(
-                    MainActivity.createReadBookIntent(this, readAloud = true),
-                    "activity"
-                )
-            )
+            .setContentIntent(readAloudActivityPendingIntent())
             .setVibrate(null)
             .setSound(null)
             .setLights(0, 0, 0)
@@ -939,12 +1039,86 @@ abstract class BaseReadAloudService : BaseService(),
         return builder
     }
 
+    private fun createForegroundNotification(): NotificationCompat.Builder =
+        if (androidMediaControlEnabled) {
+            createAndroidMediaControlNotification()
+        } else {
+            createNotification()
+        }
+
+    private fun createAndroidMediaControlNotification(): NotificationCompat.Builder {
+        val navigateByChapter = ReadConfig.mediaButtonPerNext
+        val previousAction = if (navigateByChapter) {
+            IntentAction.prev
+        } else {
+            IntentAction.prevParagraph
+        }
+        val nextAction = if (navigateByChapter) IntentAction.next else IntentAction.nextParagraph
+        val previousLabel = getString(
+            if (navigateByChapter) R.string.previous_chapter else R.string.prev_sentence
+        )
+        val nextLabel = getString(
+            if (navigateByChapter) R.string.next_chapter else R.string.next_sentence
+        )
+        val chapterTitle = ReadBook.curTextChapter?.title
+            ?.takeIf { it.isNotBlank() }
+            ?: 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))
+            .setOngoing(true)
+            .setOnlyAlertOnce(true)
+            .setContentTitle(ReadBook.book?.name ?: getString(R.string.read_aloud))
+            .setContentText(chapterTitle)
+            .setContentIntent(readAloudActivityPendingIntent())
+            .setLargeIcon(cover)
+            .setVibrate(null)
+            .setSound(null)
+            .setLights(0, 0, 0)
+            .addAction(
+                R.drawable.ic_skip_previous,
+                previousLabel,
+                aloudServicePendingIntent(previousAction),
+            )
+            .addAction(
+                if (pause) R.drawable.ic_play else R.drawable.ic_pause,
+                getString(if (pause) R.string.resume else R.string.pause),
+                aloudServicePendingIntent(
+                    if (pause) IntentAction.resume else IntentAction.pause
+                ),
+            )
+            .addAction(
+                R.drawable.ic_skip_next,
+                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)
+                    .setShowActionsInCompactView(0, 1, 2)
+            )
+    }
+
+    private fun readAloudActivityPendingIntent(): PendingIntent? = activityPendingIntent(
+        MainActivity.createReadBookIntent(this, readAloud = true),
+        "activity",
+    )
+
     /**
      * 更新通知
      */
     override fun startForegroundNotification() {
         try {
-            val notification = createNotification()
+            notificationManager.cancel(NotificationId.ReadAloudMediaControl)
+            val notification = createForegroundNotification()
             startForeground(NotificationId.ReadAloudService, notification.build())
         } catch (e: Exception) {
             AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true)
@@ -956,6 +1130,7 @@ abstract class BaseReadAloudService : BaseService(),
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
     open fun prevChapter() {
+        clearFinishChapterTimer()
         ReadBook.upReadTime()
         toLast = false
         resumeReadAloudInternal()
@@ -963,6 +1138,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     open fun nextChapter() {
+        clearFinishChapterTimer()
         ReadBook.upReadTime()
         AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
@@ -971,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) {
@@ -1050,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()

+ 13 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -86,11 +86,15 @@ class ReadAloudDelegate(
                         readAloudCapsuleOffsetY = prefs.capsuleOffsetY,
                         readAloudMediaButtonPerNext = prefs.mediaButtonPerNext,
                         readAloudByPage = prefs.readAloudByPage,
-                        readAloudSystemMediaCompat = prefs.systemMediaControlCompatibilityChange,
+                        readAloudSystemMediaCompat =
+                            prefs.systemMediaControlCompatibilityChange,
+                        readAloudAndroidMediaControl = prefs.androidMediaControlEnabled,
                         readAloudStreamAudio = prefs.streamReadAloudAudio,
                         readAloudTtsFollowSys = prefs.ttsFollowSys,
                         readAloudTtsSpeechRate = prefs.ttsSpeechRate,
                         readAloudTtsTimer = prefs.ttsTimer,
+                        readAloudFinishCurrentChapterAfterTimer =
+                            prefs.finishCurrentChapterAfterTimer,
                         speechAnalysisMode = prefs.speechAnalysisMode,
                         useMultiSpeaker = prefs.useMultiSpeaker,
                         defaultReadAloudInterface = prefs.defaultInterface,
@@ -324,6 +328,9 @@ class ReadAloudDelegate(
     fun setSystemMediaCompat(value: Boolean) =
         updateSettings { it.copy(systemMediaControlCompatibilityChange = value) }
 
+    fun setAndroidMediaControl(value: Boolean) =
+        updateSettings { it.copy(androidMediaControlEnabled = value) }
+
     fun setByPage(value: Boolean) {
         updateSettings { it.copy(readAloudByPage = value) }
         if (value) postEvent(EventBus.MEDIA_BUTTON, false)
@@ -356,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)) }

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

@@ -283,10 +283,12 @@ data class ReadBookUiState(
     val readAloudMediaButtonPerNext: Boolean = false,
     val readAloudByPage: Boolean = false,
     val readAloudSystemMediaCompat: Boolean = true,
+    val readAloudAndroidMediaControl: Boolean = false,
     val readAloudStreamAudio: Boolean = false,
     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,
@@ -765,6 +767,7 @@ sealed interface ReadBookIntent {
     data class SetReadAloudMediaButtonPerNext(val value: Boolean) : ReadBookIntent
     data class SetReadAloudByPage(val value: Boolean) : ReadBookIntent
     data class SetReadAloudSystemMediaCompat(val value: Boolean) : ReadBookIntent
+    data class SetReadAloudAndroidMediaControl(val value: Boolean) : ReadBookIntent
     data class SetReadAloudStreamAudio(val value: Boolean) : ReadBookIntent
     data object ReadAloudPrevParagraph : ReadBookIntent
     data object ReadAloudTogglePause : ReadBookIntent
@@ -773,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

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

@@ -1320,6 +1320,8 @@ class ReadBookViewModel(
             is ReadBookIntent.SetReadAloudByPage -> readAloudDelegate.setByPage(intent.value)
             is ReadBookIntent.SetReadAloudSystemMediaCompat ->
                 readAloudDelegate.setSystemMediaCompat(intent.value)
+            is ReadBookIntent.SetReadAloudAndroidMediaControl ->
+                readAloudDelegate.setAndroidMediaControl(intent.value)
             is ReadBookIntent.SetReadAloudStreamAudio ->
                 readAloudDelegate.setStreamAudio(intent.value)
             is ReadBookIntent.ReadAloudPrevParagraph -> readAloudDelegate.prevParagraph()
@@ -1329,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 ->

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

@@ -154,6 +154,14 @@ fun ReadAloudConfigContent(
                             onIntent(ReadBookIntent.SetReadAloudByPage(it))
                         },
                     )
+                    TinySwitchSettingItem(
+                        title = stringResource(R.string.read_aloud_android_media_control),
+                        description = stringResource(R.string.read_aloud_android_media_control_summary),
+                        checked = state.readAloudAndroidMediaControl,
+                        onCheckedChange = {
+                            onIntent(ReadBookIntent.SetReadAloudAndroidMediaControl(it))
+                        },
+                    )
                     TinySwitchSettingItem(
                         title = stringResource(R.string.system_media_control_compatibility_change),
                         description = stringResource(R.string.system_media_control_compatibility_change_summary),

+ 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,
 )

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

@@ -17,6 +17,7 @@ import androidx.compose.foundation.clickable
 import androidx.compose.foundation.gestures.Orientation
 import androidx.compose.foundation.gestures.animateScrollBy
 import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -112,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
@@ -168,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,
@@ -195,6 +202,7 @@ fun ReadAloudPlayerScreenContent(
                     .fillMaxWidth()
                     .clip(RectangleShape)
                     .then(hazeModifier)
+                    .pointerInput(Unit) { detectTapGestures(onTap = {}) }
                     .windowInsetsPadding(WindowInsets.statusBars),
             ) {
                 Row(
@@ -256,6 +264,7 @@ fun ReadAloudPlayerScreenContent(
                         .fillMaxWidth()
                         .clip(RectangleShape)
                         .then(hazeModifier)
+                        .pointerInput(Unit) { detectTapGestures(onTap = {}) }
                         .windowInsetsPadding(WindowInsets.navigationBars)
                         .padding(vertical = 8.dp),
                     horizontalAlignment = Alignment.CenterHorizontally,
@@ -413,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,
         )
     }

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

@@ -86,12 +86,14 @@ 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
     val readAloudWakeLock get() = aloud.readAloudWakeLock
     val mediaButtonPerNext get() = aloud.mediaButtonPerNext
     val readAloudByPage get() = aloud.readAloudByPage
+    val androidMediaControlEnabled get() = aloud.androidMediaControlEnabled
     val systemMediaControlCompatibilityChange get() = aloud.systemMediaControlCompatibilityChange
     val streamReadAloudAudio get() = aloud.streamReadAloudAudio
     val contentSelectSpeakMod get() = aloud.contentSelectSpeakMode

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

@@ -1636,6 +1636,10 @@
     <string name="firebase_enable_summary">允许应用使用Google Firebase进行崩溃和性能统计</string>
     <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="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>

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

@@ -2210,6 +2210,10 @@
     <string name="system_contrast">系統對比度</string>
     <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">啟用後,以 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>

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

@@ -1777,6 +1777,10 @@
     <string name="firebase_enable_title">匿名統計</string>
     <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">啟用後,以 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>

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

@@ -1691,6 +1691,10 @@
     <string name="firebase_enable_summary">Allow the app to use Google Firebase for crash and performance reports</string>
     <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">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>

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

@@ -8,14 +8,14 @@ import org.junit.Test
 class ReadAloudSettingsMappingTest {
 
     @Test
-    fun `朗读设置 23 键写映射逐字段对应`() {
+    fun `朗读设置 27 键写映射逐字段对应`() {
         readAloudMappingSamples().forEach { settings ->
             assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
     }
 
     @Test
-    fun `朗读设置 23 键读映射逐字段对应`() {
+    fun `朗读设置 27 键读映射逐字段对应`() {
         readAloudMappingSamples().forEach { expected ->
             assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toReadAloudSettings())
         }
@@ -45,6 +45,20 @@ class ReadAloudSettingsMappingTest {
             values,
         )
     }
+
+    @Test
+    fun `安卓媒体控制默认关闭`() {
+        val settings = emptyMap<String, Any?>().toTestPreferences().toReadAloudSettings()
+
+        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"
@@ -77,8 +91,10 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         base.copy(showReadAloudCapsule = false),
         base.copy(mediaButtonPerNext = true),
         base.copy(readAloudByPage = true),
+        base.copy(androidMediaControlEnabled = true),
         base.copy(systemMediaControlCompatibilityChange = false),
         base.copy(streamReadAloudAudio = true),
+        base.copy(finishCurrentChapterAfterTimer = true),
         base.copy(ttsFollowSys = false),
         base.copy(useMultiSpeaker = false),
     )
@@ -99,9 +115,11 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     CAPSULE_OFFSET_Y to capsuleOffsetY,
     MEDIA_BUTTON_PER_NEXT to mediaButtonPerNext,
     PreferKey.readAloudByPage to readAloudByPage,
+    PreferKey.readAloudAndroidMediaControl to androidMediaControlEnabled,
     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))
+    }
+
 }