ソースを参照

feat: 听书增加按章节定时,默认后台播放,全局播放胶囊等神秘功能

HapeLee 1 ヶ月 前
親
コミット
6d23ad6ec2
43 ファイル変更、1674 行追加、480 行削除
  1. 3 0
      app/src/main/java/io/legado/app/constant/IntentAction.kt
  2. 3 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 25 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  4. 10 1
      app/src/main/java/io/legado/app/di/appModule.kt
  5. 12 0
      app/src/main/java/io/legado/app/domain/model/PlaybackTimer.kt
  6. 27 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  7. 10 0
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  8. 119 29
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  9. 65 20
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt
  10. 76 12
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  11. 26 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  12. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt
  13. 35 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  14. 2 25
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  15. 16 79
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  16. 15 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  17. 104 17
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  18. 35 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigUiMapping.kt
  19. 33 202
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudScreen.kt
  20. 81 0
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudShellHost.kt
  21. 121 0
      app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudTimerSheet.kt
  22. 56 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/PlayerThemeOverride.kt
  23. 74 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudConfigIntentMapper.kt
  24. 24 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  25. 52 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerCoordinator.kt
  26. 58 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerRouteScreen.kt
  27. 19 49
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  28. 152 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  29. 57 0
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudSettingsUiState.kt
  30. 2 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  31. 53 6
      app/src/main/java/io/legado/app/ui/main/MainActivity.kt
  32. 79 0
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  33. 7 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  34. 31 0
      app/src/main/java/io/legado/app/ui/main/MainNavRouteTracker.kt
  35. 46 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  36. 9 0
      app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt
  37. 9 2
      app/src/main/res/values-zh-rCN/strings.xml
  38. 9 2
      app/src/main/res/values-zh-rHK/strings.xml
  39. 9 2
      app/src/main/res/values-zh-rTW/strings.xml
  40. 9 2
      app/src/main/res/values/strings.xml
  41. 20 3
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  42. 64 20
      app/src/test/java/io/legado/app/service/ReadAloudChapterCompletionTest.kt
  43. 15 3
      app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

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

@@ -12,6 +12,9 @@ object IntentAction {
     const val pause = "pause"
     const val addTimer = "addTimer"
     const val setTimer = "setTimer"
+
+    /** 章节定时剩余章数(不是分钟);翻页/换章都不应清除它。 */
+    const val setTimerChapters = "setTimerChapters"
     const val prevParagraph = "prevParagraph"
     const val nextParagraph = "nextParagraph"
     const val upTtsSpeechRate = "upTtsSpeechRate"

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

@@ -162,11 +162,14 @@ object PreferKey {
     const val progressBarBehavior = "progressBarBehavior"
     const val sourceEditMaxLine = "sourceEditMaxLine"
     const val ttsTimer = "ttsTimer"
+    const val readAloudTimerMode = "readAloudTimerMode"
+    const val readAloudTimerChapters = "readAloudTimerChapters"
     const val noAnimScrollPage = "noAnimScrollPage"
     const val webDavDeviceName = "webDavDeviceName"
     const val webServiceWakeLock = "webServiceWakeLock"
     const val audioPlayWakeLock = "audioPlayWakeLock"
     const val readAloudWakeLock = "readAloudWakeLock"
+    const val keepReadAloudOnExit = "keepReadAloudOnExit"
     const val showReadAloudCapsule = "showReadAloudCapsule"
     const val capsuleAutoCollapse = "capsuleAutoCollapse"
     const val defaultReadAloudInterface = "defaultReadAloudInterface"

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

@@ -12,6 +12,7 @@ import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsString
 import io.legado.app.help.config.compatDsValue
@@ -37,6 +38,16 @@ class ReadAloudSettingsRepository : ReadAloudSettingsGateway {
         )
     }
 
+    /** 悬浮胶囊位置;两个分量同批写入,避免只落一个导致胶囊跳位。 */
+    suspend fun putCapsulePosition(x: Float, y: Float) {
+        AppConfigStore.putAllAndAwait(
+            mapOf(
+                ReadAloudKeys.CapsuleOffsetX.name to x,
+                ReadAloudKeys.CapsuleOffsetY.name to y,
+            )
+        )
+    }
+
     /**
      * 应用内容划分方式与配套标点集合。
      *
@@ -74,6 +85,7 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     pauseReadAloudWhilePhoneCalls =
         compatDsValue(ReadAloudKeys.PauseReadAloudWhilePhoneCalls, false),
     readAloudWakeLock = compatDsValue(ReadAloudKeys.ReadAloudWakeLock, false),
+    keepReadAloudOnExit = compatDsValue(ReadAloudKeys.KeepReadAloudOnExit, false),
     showReadAloudCapsule = compatDsValue(ReadAloudKeys.ShowReadAloudCapsule, true),
     capsuleAutoCollapse = compatDsValue(ReadAloudKeys.CapsuleAutoCollapse, true),
     capsuleOffsetX = compatDsValue(ReadAloudKeys.CapsuleOffsetX, 0f),
@@ -89,6 +101,13 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     ttsTimer = PlaybackTimer.normalize(compatDsValue(ReadAloudKeys.TtsTimer, 0)),
     finishCurrentChapterAfterTimer =
         compatDsValue(ReadAloudKeys.FinishCurrentChapterAfterTimer, false),
+    timerMode = compatDsValue(
+        ReadAloudKeys.TimerMode,
+        ReadAloudTimerMode.Minute.storageValue,
+    ),
+    timerChapters = PlaybackTimer.normalizeChapters(
+        compatDsValue(ReadAloudKeys.TimerChapters, 0)
+    ),
     ttsFollowSys = compatDsValue(ReadAloudKeys.TtsFollowSys, true),
     ttsSpeechRate = compatDsValue(ReadAloudKeys.TtsSpeechRate, 5),
     speechAnalysisMode = compatDsValue(ReadAloudKeys.SpeechAnalysisMode, "rule"),
@@ -137,6 +156,7 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.readAloudByMediaButton to readAloudByMediaButton,
     PreferKey.pauseReadAloudWhilePhoneCalls to pauseReadAloudWhilePhoneCalls,
     PreferKey.readAloudWakeLock to readAloudWakeLock,
+    PreferKey.keepReadAloudOnExit to keepReadAloudOnExit,
     PreferKey.showReadAloudCapsule to showReadAloudCapsule,
     PreferKey.capsuleAutoCollapse to capsuleAutoCollapse,
     ReadAloudKeys.CapsuleOffsetX.name to capsuleOffsetX,
@@ -150,6 +170,8 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,
     PreferKey.ttsTimer to ttsTimer,
     PreferKey.finishCurrentChapterAfterTimer to finishCurrentChapterAfterTimer,
+    PreferKey.readAloudTimerMode to timerMode,
+    PreferKey.readAloudTimerChapters to timerChapters,
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,
@@ -170,6 +192,7 @@ private object ReadAloudKeys {
     val PauseReadAloudWhilePhoneCalls =
         booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
     val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
+    val KeepReadAloudOnExit = booleanPreferencesKey(PreferKey.keepReadAloudOnExit)
     val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
     val CapsuleAutoCollapse = booleanPreferencesKey(PreferKey.capsuleAutoCollapse)
     val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
@@ -191,6 +214,8 @@ private object ReadAloudKeys {
     val TtsTimer = intPreferencesKey(PreferKey.ttsTimer)
     val FinishCurrentChapterAfterTimer =
         booleanPreferencesKey(PreferKey.finishCurrentChapterAfterTimer)
+    val TimerMode = stringPreferencesKey(PreferKey.readAloudTimerMode)
+    val TimerChapters = intPreferencesKey(PreferKey.readAloudTimerChapters)
     val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
     val TtsSpeechRate = intPreferencesKey(PreferKey.ttsSpeechRate)
     val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)

+ 10 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -291,6 +291,7 @@ import io.legado.app.ui.dict.DictViewModel
 import io.legado.app.ui.dict.rule.DictRuleViewModel
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleViewModel
 import io.legado.app.ui.login.SourceLoginViewModel
+import io.legado.app.ui.main.MainNavRouteTracker
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
@@ -389,6 +390,7 @@ val appModule = module {
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
     singleOf(::ReadAloudSessionStore)
+    singleOf(::MainNavRouteTracker)
     // R2.3:会话每个所有者一份。ReadBook.callBack 的身份是「阅读页已挂载」信号
     // (prefetchForOpen / upData 判 callBack != null),register 还会给上一个持有者
     // 发 notifyBookChanged——单例会把两个 ReadBookViewModel 的注册身份混成一个。
@@ -688,7 +690,14 @@ val appModule = module {
     viewModelOf(::CloudTtsViewModel)
     viewModelOf(::TtsCacheViewModel)
     singleOf(::ReadAloudPlayerCoordinator)
-    viewModelOf(::ReadAloudPlayerViewModel)
+    /**
+     * 朗读播放界面状态宿主。
+     *
+     * 用单例而不是 `viewModelOf`:悬浮胶囊(Activity 叠层)与听书页目的地会同时存在,
+     * 若各自持有实例,`activeSheet` 之类的瞬态状态与设置快照就会各改各的。
+     * 播放状态本身在 `ReadAloudSessionStore`/服务里,这里只是界面投影。
+     */
+    single { ReadAloudPlayerViewModel(get(), get(), get()) }
     viewModel { (bookUrl: String, entryId: String?) ->
         BookKnowledgeDetailViewModel(
             bookUrl = bookUrl,

+ 12 - 0
app/src/main/java/io/legado/app/domain/model/PlaybackTimer.kt

@@ -1,11 +1,17 @@
 package io.legado.app.domain.model
 
+import io.legado.app.domain.model.PlaybackTimer.MAX_CHAPTERS
+
+
 object PlaybackTimer {
 
     const val MIN_MINUTES = 0
     const val MAX_MINUTES = 180
     private const val INCREMENT_MINUTES = 10
 
+    /** 章节定时的上限;0 表示未开启。 */
+    const val MAX_CHAPTERS = 50
+
     fun normalize(minutes: Int): Int = minutes.coerceIn(MIN_MINUTES, MAX_MINUTES)
 
     fun addIncrement(minutes: Int): Int {
@@ -16,4 +22,10 @@ object PlaybackTimer {
             (normalized + INCREMENT_MINUTES).coerceAtMost(MAX_MINUTES)
         }
     }
+
+    /** 章节定时剩余章数:0 表示未开启,其余收敛到 1..[MAX_CHAPTERS]。 */
+    fun normalizeChapters(chapters: Int): Int = chapters.coerceIn(0, MAX_CHAPTERS)
+
+    /** 递减剩余章数;已为 0 时保持 0,不会变成负数。 */
+    fun consumeChapter(chapters: Int): Int = (chapters - 1).coerceAtLeast(0)
 }

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

@@ -28,6 +28,22 @@ enum class ReadAloudContentSplitMode(val storageValue: String) {
     }
 }
 
+/**
+ * 朗读定时模式。两者互斥:同时只有一个倒计时在跑。
+ *
+ * [Minute] 到点即停(或按 [ReadAloudSettings.ttsTimer] 归零收尾);
+ * [Chapter] 读满 [ReadAloudSettings.timerChapters] 章后停在章末。
+ */
+enum class ReadAloudTimerMode(val storageValue: String) {
+    Minute("minute"),
+    Chapter("chapter");
+
+    companion object {
+        fun fromStorage(value: String): ReadAloudTimerMode =
+            entries.firstOrNull { it.storageValue == value } ?: Minute
+    }
+}
+
 data class ReadAloudSettings(
     val ttsEngine: String? = null,
     val ttsParagraphInterval: Int = 0,
@@ -37,6 +53,8 @@ data class ReadAloudSettings(
     val readAloudByMediaButton: Boolean = false,
     val pauseReadAloudWhilePhoneCalls: Boolean = false,
     val readAloudWakeLock: Boolean = false,
+    /** 退出阅读界面时不停朗读,继续在后台/胶囊里播放。 */
+    val keepReadAloudOnExit: Boolean = false,
     val showReadAloudCapsule: Boolean = true,
     val capsuleAutoCollapse: Boolean = true,
     val capsuleOffsetX: Float = 0f,
@@ -50,7 +68,16 @@ data class ReadAloudSettings(
     val systemMediaControlCompatibilityChange: Boolean = true,
     val streamReadAloudAudio: Boolean = false,
     val ttsTimer: Int = 0,
+    /**
+     * 分钟定时到点后不立刻停,读完当前章再停。
+     *
+     * 只对 [ReadAloudTimerMode.Minute] 有意义:章节模式本身就是「读满 N 章后在章末停」,
+     * 不需要这个修饰。
+     */
     val finishCurrentChapterAfterTimer: Boolean = false,
+    val timerMode: String = ReadAloudTimerMode.Minute.storageValue,
+    /** 章节定时:还剩几章;0 表示未开启。 */
+    val timerChapters: Int = 0,
     val ttsFollowSys: Boolean = true,
     val ttsSpeechRate: Int = 5,
     val speechAnalysisMode: String = "rule",

+ 10 - 0
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -222,4 +222,14 @@ object ReadAloud {
         }
     }
 
+    /** 章节定时剩余章数;0 关闭。走独立 action,避免清掉分钟倒计时。 */
+    fun setTimerChapters(context: Context, chapters: Int) {
+        if (BaseReadAloudService.isRun) {
+            val intent = Intent(context, aloudClass)
+            intent.action = IntentAction.setTimerChapters
+            intent.putExtra("chapters", PlaybackTimer.normalizeChapters(chapters))
+            context.startForegroundServiceCompat(intent)
+        }
+    }
+
 }

+ 119 - 29
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -49,6 +49,7 @@ import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.resolveReadAloudStartPosition
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
 import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.help.MediaHelp
@@ -231,6 +232,14 @@ abstract class BaseReadAloudService : BaseService(),
     private var androidMediaControlEnabled = ReadConfig.androidMediaControlEnabled
     private val finishChapterTimerLock = Any()
     private var finishChapterAtIndex = NO_FINISH_CHAPTER
+
+    /**
+     * 章节定时的剩余章数;`null` 表示未开启。
+     *
+     * 与 [finishChapterAtIndex] 一样只在 [finishChapterTimerLock] 下读写:两者在同一处
+     * 章末判定里一起被消费,分开加锁会出现「臂标已清除但计数没减」的中间态。
+     */
+    private var chapterQuota: Int? = null
     private var prepareReadAloudJob: Coroutine<*>? = null
     private var prepareReadAloudGeneration = 0L
     private var cover: Bitmap =
@@ -281,10 +290,7 @@ abstract class BaseReadAloudService : BaseService(),
         initBroadcastReceiver()
         initPhoneStateListener()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
-        setTimer(ReadConfig.ttsTimer)
-        if (timeMinute > 0) {
-            toastOnUi("朗读定时 $timeMinute 分钟")
-        }
+        applyPersistedTimer()
         execute {
             ImageLoader
                 .loadBitmap(this@BaseReadAloudService, ReadBook.book?.getDisplayCover())
@@ -370,6 +376,8 @@ abstract class BaseReadAloudService : BaseService(),
             IntentAction.next -> nextChapter()
             IntentAction.addTimer -> addTimer()
             IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0))
+            IntentAction.setTimerChapters ->
+                setTimerChapters(intent.getIntExtra("chapters", 0))
             IntentAction.stop -> stopReadAloudService()
         }
         return super.onStartCommand(intent, flags, startId)
@@ -842,20 +850,70 @@ abstract class BaseReadAloudService : BaseService(),
         refreshMediaSessionPlaybackState()
     }
 
+    /**
+     * 按持久化设置恢复定时。
+     *
+     * 两种模式互斥:分钟模式起倒计时,章节模式只装剩余章数(到数后停在章末,由
+     * [completeCurrentChapter] 消费),因此这里不需要清另一侧的臂标。
+     */
+    private fun applyPersistedTimer() {
+        when (ReadAloudTimerMode.fromStorage(ReadConfig.readAloudTimerMode)) {
+            ReadAloudTimerMode.Minute -> {
+                val minutes = PlaybackTimer.normalize(ReadConfig.ttsTimer)
+                timeMinute = minutes
+                doDs()
+                if (minutes > 0) {
+                    toastOnUi(appCtx.getString(R.string.timer_m, minutes))
+                }
+            }
+
+            ReadAloudTimerMode.Chapter -> {
+                val chapters = PlaybackTimer.normalizeChapters(ReadConfig.readAloudTimerChapters)
+                synchronized(finishChapterTimerLock) { chapterQuota = chapters.takeIf { it > 0 } }
+                sessionStore.updateTimer(PlaybackTimer.MIN_MINUTES)
+                if (chapters > 0) {
+                    toastOnUi(appCtx.getString(R.string.timer_chapters, chapters))
+                }
+            }
+        }
+    }
+
+    /**
+     * 设置分钟倒计时。
+     *
+     * 只清章节配额(两种模式互斥),**不碰** [finishChapterAtIndex]:「读完本章再停」是
+     * 分钟模式的修饰项,用户调分钟数不应顺手取消一个已经在等的章末停读。
+     */
     private fun setTimer(minute: Int) {
-        clearFinishChapterTimer()
+        synchronized(finishChapterTimerLock) { chapterQuota = null }
         timeMinute = minute
         doDs()
     }
 
     private fun addTimer() {
-        clearFinishChapterTimer()
+        synchronized(finishChapterTimerLock) { chapterQuota = null }
         timeMinute = PlaybackTimer.addIncrement(timeMinute)
         doDs()
     }
 
     /**
-     * 定时
+     * 章节定时剩余章数。
+     *
+     * 只动配额、不清 [finishChapterAtIndex]:章节模式的语义是「再读 N 章就停」,
+     * 换章不算取消。用户改用分钟模式时才交给 [setTimer] 一起清。
+     */
+    private fun setTimerChapters(chapters: Int) {
+        synchronized(finishChapterTimerLock) {
+            chapterQuota = PlaybackTimer.normalizeChapters(chapters).takeIf { it > 0 }
+        }
+    }
+
+    /**
+     * 分钟倒计时。
+     *
+     * 到点时若用户勾了「读完本章再停」,就给 [finishChapterAtIndex] 装臂标,由
+     * [completeCurrentChapter] 在章末停;否则立刻停。章节模式是另一条独立路径
+     * ([chapterQuota]),两者不会互相清除。
      */
     @Synchronized
     private fun doDs() {
@@ -1102,13 +1160,6 @@ abstract class BaseReadAloudService : BaseService(),
                 .drop(1)
                 .collect { upReadAloudNotification() }
         }
-        lifecycleScope.launch {
-            AppConfigStore.observeBoolean(PreferKey.finishCurrentChapterAfterTimer)
-                .drop(1)
-                .collect { enabled ->
-                    if (enabled != true) clearFinishChapterTimer()
-                }
-        }
     }
 
     private fun setAndroidMediaControlEnabled(enabled: Boolean) {
@@ -1379,7 +1430,8 @@ abstract class BaseReadAloudService : BaseService(),
     abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent?
 
     open fun prevChapter() {
-        clearFinishChapterTimer()
+        // 只清分钟模式装的章末臂标;章节配额的计数跨章保留,否则「读 N 章」永远数不满
+        clearFinishChapterFlag()
         ReadBook.upReadTime()
         toLast = false
         resumeReadAloudInternal()
@@ -1387,7 +1439,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     open fun nextChapter() {
-        clearFinishChapterTimer()
+        clearFinishChapterFlag()
         ReadBook.upReadTime()
         AppLog.putDebug("${readerReadAloudChapter?.title} 朗读结束跳转下一章并朗读")
         resumeReadAloudInternal()
@@ -1404,11 +1456,14 @@ abstract class BaseReadAloudService : BaseService(),
                 durChapterIndex = ReadBook.durChapterIndex,
                 finishedChapterIndex = chapterIndex,
                 finishChapterAtIndex = finishChapterAtIndex,
-                finishChapterSettingEnabled = ReadConfig.finishCurrentChapterAfterTimer,
+                chapterQuota = chapterQuota,
             )
             if (decision.clearTimer) {
                 finishChapterAtIndex = NO_FINISH_CHAPTER
             }
+            if (decision.remainingChapters != null) {
+                chapterQuota = decision.remainingChapters
+            }
             when (decision.action) {
                 ChapterCompletionAction.STOP -> {
                     pause = true
@@ -1425,9 +1480,18 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    /** 清掉分钟模式装的章末臂标;章节配额不动。 */
+    private fun clearFinishChapterFlag() {
+        synchronized(finishChapterTimerLock) {
+            finishChapterAtIndex = NO_FINISH_CHAPTER
+        }
+    }
+
+    /** 两种定时一起清:取消定时或切换模式时用。 */
     private fun clearFinishChapterTimer() {
         synchronized(finishChapterTimerLock) {
             finishChapterAtIndex = NO_FINISH_CHAPTER
+            chapterQuota = null
         }
     }
 
@@ -1569,22 +1633,30 @@ internal fun nextMediaSessionPositionMs(
 internal const val NO_FINISH_CHAPTER = -1
 
 internal enum class ChapterCompletionAction {
-    /** Timer expired during this chapter — stop read-aloud now. */
+    /** 定时到点 / 章节配额读完 —— 停在当前章末。 */
     STOP,
-    /** No finish-chapter intent — continue to the next chapter. */
+
+    /** 没有停读意图 —— 继续下一章。 */
     ADVANCE,
-    /** The chapter already advanced concurrently — do nothing. */
+
+    /** 章节已经并发推进 —— 什么都不做。 */
     SKIP,
 }
 
 internal data class ChapterCompletionDecision(
     val action: ChapterCompletionAction,
     val clearTimer: Boolean,
+    /** 非 null 时需要写回的新剩余章数(已扣掉刚读完的这一章)。 */
+    val remainingChapters: Int? = null,
 )
 
 /**
- * Decides what a natural chapter boundary should do against the "finish current
- * chapter after timer" state. Pure and thread-free so it can be unit tested.
+ * Decides what a natural chapter boundary should do against the timer state. Pure and
+ * thread-free so it can be unit tested.
+ *
+ * 两个来源:
+ * - `finishChapterAtIndex`:锚定的章节读完了(分钟定时到点时装的臂标);
+ * - `chapterQuota`:章节定时的剩余章数,每读完一章扣一,扣到 0 时停。
  *
  * SKIP 只保留给"章已推进且臂标不属于已读完章节"的双重触发竞态(臂标消费后为
  * NO_FINISH,自然落入该分支)。臂标锚定的章节自然读完时必须 STOP:
@@ -1594,7 +1666,7 @@ internal fun decideChapterCompletion(
     durChapterIndex: Int,
     finishedChapterIndex: Int,
     finishChapterAtIndex: Int,
-    finishChapterSettingEnabled: Boolean,
+    chapterQuota: Int?,
 ): ChapterCompletionDecision {
     if (durChapterIndex != finishedChapterIndex &&
         finishChapterAtIndex != finishedChapterIndex
@@ -1605,13 +1677,31 @@ internal fun decideChapterCompletion(
             clearTimer = false,
         )
     }
+    val remaining = chapterQuota?.let { PlaybackTimer.consumeChapter(it) }
     return when {
-        finishChapterAtIndex == NO_FINISH_CHAPTER ->
-            ChapterCompletionDecision(ChapterCompletionAction.ADVANCE, clearTimer = false)
-        finishChapterAtIndex != finishedChapterIndex || !finishChapterSettingEnabled ->
-            ChapterCompletionDecision(ChapterCompletionAction.ADVANCE, clearTimer = true)
-        else ->
-            ChapterCompletionDecision(ChapterCompletionAction.STOP, clearTimer = true)
+        remaining == 0 -> ChapterCompletionDecision(
+            action = ChapterCompletionAction.STOP,
+            clearTimer = true,
+            remainingChapters = remaining,
+        )
+
+        finishChapterAtIndex == NO_FINISH_CHAPTER -> ChapterCompletionDecision(
+            action = ChapterCompletionAction.ADVANCE,
+            clearTimer = false,
+            remainingChapters = remaining,
+        )
+
+        finishChapterAtIndex != finishedChapterIndex -> ChapterCompletionDecision(
+            action = ChapterCompletionAction.ADVANCE,
+            clearTimer = true,
+            remainingChapters = remaining,
+        )
+
+        else -> ChapterCompletionDecision(
+            action = ChapterCompletionAction.STOP,
+            clearTimer = true,
+            remainingChapters = remaining,
+        )
     }
 }
 

+ 65 - 20
app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt

@@ -46,6 +46,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.graphicsLayer
 import androidx.compose.ui.input.pointer.pointerInput
 import androidx.compose.ui.platform.LocalDensity
@@ -54,7 +55,6 @@ import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.IntOffset
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.data.entities.Book
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.image.cover.BookCoverImage
 import kotlinx.coroutines.delay
@@ -62,14 +62,24 @@ import kotlinx.coroutines.isActive
 import top.yukonga.miuix.kmp.basic.VerticalDivider
 import kotlin.time.Duration.Companion.milliseconds
 
+/**
+ * 朗读悬浮胶囊。
+ *
+ * 由宿主 Activity 叠加在所有导航之上(见 `ReadAloudShellHost`),因此只接受展示所需的
+ * 原始值,不依赖阅读器 ViewModel;上下边距由宿主按所在界面传入。
+ */
 @Composable
 fun ReadAloudCapsule(
-    book: Book?,
+    bookName: String?,
+    author: String?,
+    coverPath: String?,
+    sourceOrigin: String?,
     isPaused: Boolean,
     offsetXDp: Float,
     offsetYDp: Float,
     progress: Float,
     autoCollapse: Boolean,
+    bottomPadding: Dp,
     onPositionChanged: (xDp: Float, yDp: Float) -> Unit,
     onTogglePause: () -> Unit,
     onStop: () -> Unit,
@@ -137,7 +147,7 @@ fun ReadAloudCapsule(
             modifier = Modifier
                 .align(Alignment.BottomCenter)
                 .navigationBarsPadding()
-                .padding(bottom = 88.dp)
+                .padding(bottom = bottomPadding)
                 .offset { IntOffset(offsetX.toInt(), offsetY.toInt()) }
                 .pointerInput(density) {
                     detectDragGestures(
@@ -160,9 +170,12 @@ fun ReadAloudCapsule(
                     )
                 },
             shape = RoundedCornerShape(cornerRadius),
-            color = LegadoTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.94f),
-            tonalElevation = 6.dp,
-            shadowElevation = 8.dp,
+            // 胶囊走极简日夜配色:日间纯白 + 深色内容,夜间纯黑 + 浅色内容。
+            // 不跟随主题取色,避免在阅读页/听书页的背景之上忽明忽暗。
+            color = capsuleSurfaceColor(),
+            contentColor = capsuleContentColor(),
+            tonalElevation = 0.dp,
+            shadowElevation = 12.dp,
         ) {
             AnimatedContent(
                 targetState = collapsed,
@@ -180,7 +193,10 @@ fun ReadAloudCapsule(
                     )
                 } else {
                     ExpandedCapsuleContent(
-                        book = book,
+                        bookName = bookName,
+                        author = author,
+                        coverPath = coverPath,
+                        sourceOrigin = sourceOrigin,
                         isPaused = isPaused,
                         progress = progress,
                         coverRotation = coverRotation.value,
@@ -194,12 +210,33 @@ fun ReadAloudCapsule(
     }
 }
 
+/**
+ * 胶囊底色:日间纯白、夜间纯黑。
+ *
+ * 刻意不用 `LegadoTheme.colorScheme`——胶囊是叠在任意界面之上的独立浮层,
+ * 跟随主题取色会在不同底色上忽明忽暗;黑白两色在任何背景上都读得清。
+ */
+@Composable
+private fun capsuleSurfaceColor(): Color =
+    if (LegadoTheme.isDark) Color.Black else Color.White
+
+/** 胶囊内容色:与底色构成最高对比。 */
+@Composable
+private fun capsuleContentColor(): Color =
+    if (LegadoTheme.isDark) Color.White else Color.Black
+
+/** 胶囊上的次要信息(进度圈、分隔线等)用的弱化色。 */
+@Composable
+private fun capsuleMutedColor(): Color =
+    if (LegadoTheme.isDark) Color.White.copy(alpha = 0.6f) else Color.Black.copy(alpha = 0.6f)
+
 @Composable
 private fun CollapsedCapsuleContent(
     isPaused: Boolean,
     onTogglePause: () -> Unit,
     onExpand: () -> Unit,
 ) {
+    val contentColor = capsuleContentColor()
     Row(
         verticalAlignment = Alignment.CenterVertically,
         horizontalArrangement = Arrangement.Center,
@@ -216,14 +253,14 @@ private fun CollapsedCapsuleContent(
                 ),
                 modifier = Modifier
                     .size(16.dp),
-                tint = LegadoTheme.colorScheme.onSurface,
+                tint = contentColor,
             )
             Text(
                 text = stringResource(
                     if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
                 ),
                 style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurface,
+                color = contentColor,
                 modifier = Modifier.padding(start = 2.dp),
             )
         }
@@ -233,7 +270,7 @@ private fun CollapsedCapsuleContent(
                 .padding(start = 4.dp)
                 .height(10.dp)
                 .width(1.dp),
-            color = LegadoTheme.colorScheme.outlineVariant,
+            color = capsuleMutedColor(),
         )
 
         Icon(
@@ -243,14 +280,17 @@ private fun CollapsedCapsuleContent(
                 .clickable(onClick = onExpand)
                 .padding(horizontal = 8.dp, vertical = 6.dp)
                 .size(16.dp),
-            tint = LegadoTheme.colorScheme.onSurface,
+            tint = contentColor,
         )
     }
 }
 
 @Composable
 private fun ExpandedCapsuleContent(
-    book: Book?,
+    bookName: String?,
+    author: String?,
+    coverPath: String?,
+    sourceOrigin: String?,
     isPaused: Boolean,
     progress: Float,
     coverRotation: Float,
@@ -263,13 +303,15 @@ private fun ExpandedCapsuleContent(
         horizontalArrangement = Arrangement.spacedBy(4.dp),
         verticalAlignment = Alignment.CenterVertically,
     ) {
+        val contentColor = capsuleContentColor()
+        val mutedColor = capsuleMutedColor()
         BookCoverImage(
-            name = book?.name,
-            author = book?.author,
-            path = book?.getDisplayCover(),
-            sourceOrigin = book?.origin,
+            name = bookName,
+            author = author,
+            path = coverPath,
+            sourceOrigin = sourceOrigin,
             // 听书胶囊也是书维度场景,本地优先不跑书源脚本
-            bookUrl = book?.bookUrl,
+            bookUrl = null,
             preferCache = true,
             modifier = Modifier
                 .size(40.dp)
@@ -281,7 +323,8 @@ private fun ExpandedCapsuleContent(
             modifier = Modifier
                 .size(40.dp)
                 .clip(CircleShape)
-                .background(LegadoTheme.colorScheme.secondaryContainer)
+                // 黑白配色下用弱化的中性底,不再用主题的 secondaryContainer
+                .background(mutedColor.copy(alpha = 0.1f))
                 .clickable(onClick = onTogglePause),
             contentAlignment = Alignment.Center,
         ) {
@@ -291,7 +334,7 @@ private fun ExpandedCapsuleContent(
                     if (isPaused) R.string.resume_read_aloud else R.string.pause_read_aloud
                 ),
                 modifier = Modifier.size(24.dp),
-                tint = LegadoTheme.colorScheme.onSecondaryContainer,
+                tint = contentColor,
             )
         }
         Box(
@@ -303,6 +346,8 @@ private fun ExpandedCapsuleContent(
                 modifier = Modifier.fillMaxSize(),
                 progress = { progress.coerceIn(0f, 1f) },
                 strokeWidth = 2.dp,
+                color = mutedColor,
+                trackColor = mutedColor.copy(alpha = 0.25f),
             )
             Icon(
                 imageVector = Icons.Default.Close,
@@ -311,7 +356,7 @@ private fun ExpandedCapsuleContent(
                     .size(24.dp)
                     .clip(CircleShape)
                     .clickable(onClick = onStop),
-                tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                tint = contentColor,
             )
         }
     }

+ 76 - 12
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -18,6 +18,7 @@ import io.legado.app.domain.model.readaloud.ReadAloudSplitSymbol
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
 import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
@@ -84,6 +85,7 @@ class ReadAloudDelegate(
                         readAloudIgnoreAudioFocus = prefs.ignoreAudioFocus,
                         readAloudPauseOnPhoneCall = prefs.pauseReadAloudWhilePhoneCalls,
                         readAloudWakeLock = prefs.readAloudWakeLock,
+                        readAloudKeepOnExit = prefs.keepReadAloudOnExit,
                         showReadAloudCapsule = prefs.showReadAloudCapsule,
                         capsuleAutoCollapse = prefs.capsuleAutoCollapse,
                         readAloudCapsuleOffsetX = prefs.capsuleOffsetX,
@@ -101,6 +103,8 @@ class ReadAloudDelegate(
                         readAloudTtsTimer = prefs.ttsTimer,
                         readAloudFinishCurrentChapterAfterTimer =
                             prefs.finishCurrentChapterAfterTimer,
+                        readAloudTimerMode = prefs.timerMode,
+                        readAloudTimerChapters = prefs.timerChapters,
                         speechAnalysisMode = prefs.speechAnalysisMode,
                         speechAnalysisReasoningLevel = prefs.speechAnalysisReasoningLevel,
                         useMultiSpeaker = prefs.useMultiSpeaker,
@@ -205,29 +209,30 @@ class ReadAloudDelegate(
 
     // --- 界面入口 ---
 
-    /** 媒体键/胶囊触发的默认朗读界面:按设置决定开播放器弹层还是经典控制面板。 */
+    /** 媒体键/胶囊触发的默认朗读界面:按设置决定开播放器还是经典控制面板。 */
     fun openDefaultInterface() {
         if (
             host.uiState.defaultReadAloudInterface ==
             ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
         ) {
-            host.updateState {
-                it.copy(
-                    menuState = ReadBookMenuState(),
-                    activeSheet = ReadBookSheet.ReadAloudPlayer,
-                )
-            }
+            openPlayer()
         } else {
             host.openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
         }
     }
 
+    /**
+     * 打开听书播放界面。
+     *
+     * 播放界面是 Navigation 3 目的地而非阅读器弹层,所以这里发导航意图;
+     * 先把菜单状态收起来,返回阅读界面时不会停在半开的菜单上。
+     */
     fun openPlayer() {
-        host.updateState {
-            it.copy(menuState = ReadBookMenuState(), activeSheet = ReadBookSheet.ReadAloudPlayer)
-        }
+        host.updateState { it.copy(menuState = ReadBookMenuState(), activeSheet = null) }
+        host.emitEffect(ReadBookEffect.OpenReadAloudPlayer)
     }
 
+    /** 经典朗读控制面板:阅读菜单里的一页,不遮挡正文区域之外的交互。 */
     fun openClassicControls() {
         host.updateState { it.copy(activeSheet = null) }
         host.openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
@@ -351,6 +356,8 @@ class ReadAloudDelegate(
 
     fun setWakeLock(value: Boolean) = updateSettings { it.copy(readAloudWakeLock = value) }
 
+    fun setKeepOnExit(value: Boolean) = updateSettings { it.copy(keepReadAloudOnExit = value) }
+
     fun setShowCapsule(value: Boolean) = updateSettings { it.copy(showReadAloudCapsule = value) }
 
     fun setCapsuleAutoCollapse(value: Boolean) =
@@ -415,8 +422,22 @@ class ReadAloudDelegate(
     fun setTtsTimer(value: Int) {
         val timer = PlaybackTimer.normalize(value)
         ReadAloud.setTimer(context, timer)
-        updateSettings { it.copy(ttsTimer = timer) }
-        host.updateState { it.copy(readAloudTtsTimer = timer) }
+        // 两种定时互斥:设分钟定时即切到分钟模式并清掉章节配额
+        updateSettings {
+            it.copy(
+                ttsTimer = timer,
+                timerMode = ReadAloudTimerMode.Minute.storageValue,
+                timerChapters = 0,
+            )
+        }
+        ReadAloud.setTimerChapters(context, 0)
+        host.updateState {
+            it.copy(
+                readAloudTtsTimer = timer,
+                readAloudTimerMode = ReadAloudTimerMode.Minute.storageValue,
+                readAloudTimerChapters = 0,
+            )
+        }
     }
 
     fun setFinishCurrentChapterAfterTimer(value: Boolean) {
@@ -424,6 +445,49 @@ class ReadAloudDelegate(
         host.updateState { it.copy(readAloudFinishCurrentChapterAfterTimer = value) }
     }
 
+    fun setTimerMode(mode: ReadAloudTimerMode) {
+        val prefs = readAloudSettingsRepository.currentSettings
+        val minutes = if (mode == ReadAloudTimerMode.Minute) prefs.ttsTimer else 0
+        val chapters = if (mode == ReadAloudTimerMode.Chapter) prefs.timerChapters else 0
+        updateSettings {
+            it.copy(
+                timerMode = mode.storageValue,
+                ttsTimer = minutes,
+                timerChapters = chapters,
+            )
+        }
+        ReadAloud.setTimer(context, minutes)
+        ReadAloud.setTimerChapters(context, chapters)
+        host.updateState {
+            it.copy(
+                readAloudTimerMode = mode.storageValue,
+                readAloudTtsTimer = minutes,
+                readAloudTimerChapters = chapters,
+            )
+        }
+    }
+
+    fun setTimerChapters(value: Int) {
+        val chapters = PlaybackTimer.normalizeChapters(value)
+        ReadAloud.setTimerChapters(context, chapters)
+        updateSettings {
+            it.copy(
+                timerChapters = chapters,
+                timerMode = ReadAloudTimerMode.Chapter.storageValue,
+                ttsTimer = 0,
+            )
+        }
+        // 切到章节模式要同时停掉正在跑的分钟倒计时
+        ReadAloud.setTimer(context, 0)
+        host.updateState {
+            it.copy(
+                readAloudTimerChapters = chapters,
+                readAloudTimerMode = ReadAloudTimerMode.Chapter.storageValue,
+                readAloudTtsTimer = 0,
+            )
+        }
+    }
+
     fun setTtsSpeechRate(value: Int) {
         scope.launch {
             readAloudSettingsRepository.update { it.copy(ttsSpeechRate = value.coerceIn(0, 80)) }

+ 26 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -18,6 +18,7 @@ import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.usecase.BookmarkTargetVerdict
 import io.legado.app.model.translation.TranslationChapterStatus
@@ -280,6 +281,7 @@ data class ReadBookUiState(
     val readAloudIgnoreAudioFocus: Boolean = false,
     val readAloudPauseOnPhoneCall: Boolean = false,
     val readAloudWakeLock: Boolean = false,
+    val readAloudKeepOnExit: Boolean = false,
     val showReadAloudCapsule: Boolean = true,
     val capsuleAutoCollapse: Boolean = true,
     val readAloudCapsuleOffsetX: Float = 0f,
@@ -296,7 +298,12 @@ data class ReadBookUiState(
     val readAloudTtsFollowSys: Boolean = false,
     val readAloudTtsSpeechRate: Int = 10,
     val readAloudTtsTimer: Int = 0,
+    /** 分钟定时到点后读完本章再停;只对分钟模式有意义。 */
     val readAloudFinishCurrentChapterAfterTimer: Boolean = false,
+    /** 定时模式:分钟 / 章节([ReadAloudTimerMode.storageValue])。 */
+    val readAloudTimerMode: String = ReadAloudTimerMode.Minute.storageValue,
+    /** 章节定时剩余章数;0 表示未开启。 */
+    val readAloudTimerChapters: Int = 0,
     val speechAnalysisMode: String = "rule",
     val speechAnalysisReasoningLevel: String = AiReasoningLevel.OFF.storageValue,
     val useMultiSpeaker: Boolean = true,
@@ -312,6 +319,10 @@ data class ReadBookUiState(
 ) {
     val menuVisible: Boolean
         get() = menuState.visible
+
+    /** 朗读设置卡片是否打开;经典控制面板与听书播放界面共用同一份设置内容。 */
+    val isReadAloudConfigOpen: Boolean
+        get() = activeSheet is ReadBookSheet.ReadAloudConfig
 }
 
 /** 护眼模式设置,来源是 ThemeSettings,与外观设置共用同一份值。 */
@@ -789,6 +800,7 @@ sealed interface ReadBookIntent {
     data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
+    data class SetReadAloudKeepOnExit(val value: Boolean) : ReadBookIntent
     data class SetShowReadAloudCapsule(val value: Boolean) : ReadBookIntent
     data class SetCapsuleAutoCollapse(val value: Boolean) : ReadBookIntent
     data object ResetReadAloudCapsulePosition : ReadBookIntent
@@ -814,6 +826,12 @@ sealed interface ReadBookIntent {
     /** 页面脱离朗读位置后,从当前显示页重新开始朗读。 */
     data object ReadAloudFromHere : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
+
+    /** [value] 是 [ReadAloudTimerMode.storageValue]。 */
+    data class SetReadAloudTimerMode(val value: String) : ReadBookIntent
+    data class SetReadAloudTimerChapters(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
@@ -962,6 +980,14 @@ sealed interface ReadBookEffect {
     data class OpenMenuCustomIconPicker(val id: String) : ReadBookEffect
     data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookEffect
     data object OpenSystemTtsSettings : ReadBookEffect
+
+    /**
+     * 打开听书播放界面。
+     *
+     * 播放界面是 Navigation 3 目的地([io.legado.app.ui.main.MainRouteReadAloudPlayer]),
+     * 不再是阅读器内的弹层,因此这里只发导航意图,不写 `activeSheet`。
+     */
+    data object OpenReadAloudPlayer : ReadBookEffect
     data object OpenTtsEnginesAndVoices : ReadBookEffect
     data object OpenTtsCache : ReadBookEffect
     data class OpenBookVoiceCasting(val bookUrl: String) : ReadBookEffect
@@ -1023,7 +1049,6 @@ sealed interface ReadBookSheet {
     data object MoreConfig : ReadBookSheet
     data object BgTextConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
-    data object ReadAloudPlayer : ReadBookSheet
     data object PreDownloadConfig : ReadBookSheet
     data object PreSynthesisConcurrencyConfig : ReadBookSheet
     data object AudioCacheCleanConfig : ReadBookSheet

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

@@ -2247,6 +2247,8 @@ class ReadBookController(
             is ReadBookEffect.OpenSystemTtsSettings,
             ReadBookEffect.OpenTtsEnginesAndVoices,
             ReadBookEffect.OpenTtsCache,
+                // 朗读播放界面是 Navigation 3 目的地,由路由层导航,这里不处理
+            ReadBookEffect.OpenReadAloudPlayer,
             is ReadBookEffect.OpenBookVoiceCasting,
             is ReadBookEffect.OpenHighlightRuleImportPicker,
             is ReadBookEffect.OpenHighlightRuleExportPicker,

+ 35 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -45,6 +45,7 @@ import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
@@ -73,17 +74,20 @@ import dev.chrisbanes.haze.HazeState
 import io.legado.app.R
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.data.repository.ReadPreferences
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.ui.book.read.sheet.AutoReadContent
 import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadStyleContent
 import io.legado.app.ui.book.read.sheet.TypographyPage
 import io.legado.app.ui.book.read.sheet.TypographySection
+import io.legado.app.ui.book.readaloud.ReadAloudTimerConfig
+import io.legado.app.ui.book.readaloud.ReadAloudTimerSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
-import io.legado.app.ui.widget.components.reader.ReaderMenuEffect
 import io.legado.app.ui.widget.components.reader.ReaderMenuAnimatedBottom
 import io.legado.app.ui.widget.components.reader.ReaderMenuAnimatedTop
 import io.legado.app.ui.widget.components.reader.ReaderMenuDismissLayer
+import io.legado.app.ui.widget.components.reader.ReaderMenuEffect
 import io.legado.app.ui.widget.components.reader.ReaderMenuPlacement
 import io.legado.app.ui.widget.components.reader.ReaderMenuTintStyle
 import io.legado.app.ui.widget.components.reader.ReaderMenuVisualState
@@ -557,6 +561,10 @@ private fun ReadBookMenuSurface(
             },
             label = "ReadBookMenuRoute",
         ) { target ->
+            // 朗读定时卡片的开合:放在 AnimatedContent 内的 when 之外,
+            // 菜单路由切换时随 LaunchedEffect 关闭,不会残留
+            var readAloudTimerSheetOpen by remember { mutableStateOf(false) }
+            LaunchedEffect(target) { readAloudTimerSheetOpen = false }
             when (target) {
                 ReadBookMenuContent.Search -> {
                     SearchBottomMenuContent(
@@ -708,6 +716,31 @@ private fun ReadBookMenuSurface(
                     }
 
                     ReadBookMenuRoute.ReadAloud -> {
+                        // 定时卡片叠在菜单页之上:它是宿主无关的读写组件,
+                        // 由 ReadBookMenuBar 自己的布尔状态控制,不占 activeSheet。
+                        ReadAloudTimerSheet(
+                            show = readAloudTimerSheetOpen,
+                            config = ReadAloudTimerConfig(
+                                mode = ReadAloudTimerMode.fromStorage(state.readAloudTimerMode),
+                                minutes = state.readAloudTtsTimer,
+                                chapters = state.readAloudTimerChapters,
+                                finishCurrentChapterAfterTimer =
+                                    state.readAloudFinishCurrentChapterAfterTimer,
+                            ),
+                            onDismissRequest = { readAloudTimerSheetOpen = false },
+                            onSetMode = {
+                                onIntent(
+                                    ReadBookIntent.SetReadAloudTimerMode(it.storageValue)
+                                )
+                            },
+                            onSetMinutes = { onIntent(ReadBookIntent.SetReadAloudTtsTimer(it)) },
+                            onSetChapters = {
+                                onIntent(ReadBookIntent.SetReadAloudTimerChapters(it))
+                            },
+                            onSetFinishCurrentChapterAfterTimer = {
+                                onIntent(ReadBookIntent.SetFinishCurrentChapterAfterTimer(it))
+                            },
+                        )
                         ReadBookMenuRoutePage(
                             title = stringResource(R.string.aloud_config),
                             maxHeight = maxHeight,
@@ -732,6 +765,7 @@ private fun ReadBookMenuSurface(
                                 onShowReadAloudConfig = {
                                     onIntent(ReadBookIntent.ShowReadAloudConfig)
                                 },
+                                onShowTimerSettings = { readAloudTimerSheetOpen = true },
                                 modifier = Modifier.padding(horizontal = 16.dp)
                             )
                         }

+ 2 - 25
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -157,6 +157,7 @@ fun ReadBookRouteScreen(
     onOpenVoiceCasting: (bookUrl: String) -> Unit = {},
     onOpenTtsEnginesAndVoices: () -> Unit = {},
     onOpenTtsCache: () -> Unit = {},
+    onOpenReadAloudPlayer: () -> Unit = {},
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
@@ -422,6 +423,7 @@ fun ReadBookRouteScreen(
                             }
                             ReadBookEffect.OpenTtsEnginesAndVoices -> onOpenTtsEnginesAndVoices()
                             ReadBookEffect.OpenTtsCache -> onOpenTtsCache()
+                            ReadBookEffect.OpenReadAloudPlayer -> onOpenReadAloudPlayer()
                             is ReadBookEffect.MenuSettingReplace -> {
                                 replaceLauncher.launch(
                                     ReplaceRuleActivity.startIntent(
@@ -858,31 +860,6 @@ fun ReadBookRouteScreen(
             ) {
                 TranslationThinkingCapsule()
             }
-            AnimatedVisibility(
-                visible = state.isReadAloudRunning &&
-                    state.showReadAloudCapsule &&
-                        !state.menuVisible,
-                enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),
-                exit = fadeOut(tween(140)) + scaleOut(tween(180), targetScale = 0.88f),
-            ) {
-                ReadAloudCapsule(
-                    book = state.book,
-                    isPaused = state.isReadAloudPaused,
-                    offsetXDp = state.readAloudCapsuleOffsetX,
-                    offsetYDp = state.readAloudCapsuleOffsetY,
-                    progress = state.readAloudChapterPosition.toFloat() /
-                        state.readAloudChapterLength.coerceAtLeast(1),
-                    autoCollapse = state.capsuleAutoCollapse,
-                    onPositionChanged = { x, y ->
-                        viewModel.onIntent(ReadBookIntent.SetReadAloudCapsulePosition(x, y))
-                    },
-                    onTogglePause = {
-                        viewModel.onIntent(ReadBookIntent.ReadAloudTogglePause)
-                    },
-                    onStop = { viewModel.onIntent(ReadBookIntent.ReadAloudStop) },
-                    onOpenPlayer = { viewModel.onIntent(ReadBookIntent.OpenReadAloudPlayer) },
-                )
-            }
             if (featureOverlaysInitialized) {
                 ReadBookOverlayRoute(
                     viewModel = viewModel,

+ 16 - 79
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -14,12 +14,9 @@ import androidx.compose.ui.res.stringArrayResource
 import androidx.compose.ui.res.stringResource
 import androidx.core.net.toUri
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import coil3.ImageLoader
 import io.legado.app.R
 import io.legado.app.data.repository.ReadPreferences
-import io.legado.app.domain.gateway.CoverSettingsGateway
 import io.legado.app.domain.usecase.BookmarkTargetVerdict
-import io.legado.app.help.coil.CoverExtras
 import io.legado.app.ui.book.read.sheet.AiRewritePresetConfigSheet
 import io.legado.app.ui.book.read.sheet.AiTextCleanSheet
 import io.legado.app.ui.book.read.sheet.AiTextRewriteSheet
@@ -38,33 +35,26 @@ import io.legado.app.ui.book.read.sheet.MoreConfigSheet
 import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet
 import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet
 import io.legado.app.ui.book.read.sheet.PhotoSheet
+import io.legado.app.ui.book.read.sheet.ReadAloudConfigContent
 import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet
-import io.legado.app.ui.book.read.sheet.ReadAloudPage
-import io.legado.app.ui.book.read.sheet.ReadAloudScreen
 import io.legado.app.ui.book.read.sheet.ReaderMoreActionsSheet
 import io.legado.app.ui.book.read.sheet.ShadowSetSheet
 import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet
 import io.legado.app.ui.book.read.sheet.TextProcessingSheet
 import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
 import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerEffect
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.dict.DictSheet
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.rememberImageSeedColor
-import io.legado.app.ui.theme.rememberThemeOverride
 import io.legado.app.ui.widget.components.FontFolderState
 import io.legado.app.ui.widget.components.FontSelectSheet
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.bookmark.BookmarkEditSheet
 import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet
-import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
 import io.legado.app.ui.widget.components.log.AppLogSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.collectLatest
-import org.koin.compose.koinInject
-import io.legado.app.model.BookCover as BookCoverModel
 
 /**
  * Stateless reader overlays: back handling, dialogs and sheets.
@@ -507,74 +497,21 @@ fun ReadBookScreen(
     )
 
     val aloudPlayerViewModel: ReadAloudPlayerViewModel =
-        org.koin.androidx.compose.koinViewModel()
-    val aloudPlayerState by aloudPlayerViewModel.uiState.collectAsStateWithLifecycle()
-    val playerTheme = run {
-        val imageLoader: ImageLoader = koinInject()
-        val coverSettings = koinInject<CoverSettingsGateway>().currentSettings
-        val isNight = LegadoTheme.isDark
-        val useDefaultCover = usesDefaultBookCover(aloudPlayerState.coverPath)
-        val defaultCoverPaths = if (isNight) coverSettings.defaultCoverDark else coverSettings.defaultCover
-        val coverPath = remember(
-            aloudPlayerState.bookName,
-            aloudPlayerState.author,
-            aloudPlayerState.coverPath,
-            useDefaultCover,
-            isNight,
-            defaultCoverPaths,
-        ) {
-            if (useDefaultCover) {
-                BookCoverModel.getRandomDefaultPath(
-                    seed = aloudPlayerState.bookName,
-                    isNight = isNight,
-                )
-            } else {
-                aloudPlayerState.coverPath
-            }
-        }
-        val sourceOrigin = if (useDefaultCover) null else aloudPlayerState.sourceOrigin
-        val loadOnlyWifi = !useDefaultCover && coverSettings.loadOnlyOnWifi
-        val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
-            listOf(coverPath, sourceOrigin, loadOnlyWifi)
-        }
-        val seedColor = rememberImageSeedColor(
-            imageLoader = imageLoader,
-            data = coverPath,
-            requestKey = requestKey,
-        ) {
-            extras[CoverExtras.SourceOrigin] = sourceOrigin
-            extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
-        }
-        rememberThemeOverride(seedColor)
-    }
-    val readAloudPage = when (state.activeSheet) {
-        ReadBookSheet.ReadAloudConfig -> ReadAloudPage.Config
-        ReadBookSheet.ReadAloudPlayer -> ReadAloudPage.Player
-        else -> null
-    }
-    ReadAloudScreen(
-        page = readAloudPage,
-        state = state,
-        playerState = aloudPlayerState,
-        playerTheme = playerTheme,
-        onIntent = onIntent,
-        onPlayerIntent = aloudPlayerViewModel::onIntent,
+        org.koin.compose.koinInject()
+    val aloudPlayerShellState by aloudPlayerViewModel.uiState.collectAsStateWithLifecycle()
+    // 听书播放界面已是 Navigation 3 目的地(见 ReadAloudPlayerRouteScreen),
+    // 这里只保留朗读配置卡片;经典控制面板与听书播放界面共用同一份配置内容。
+    AppModalBottomSheet(
+        show = state.activeSheet is ReadBookSheet.ReadAloudConfig,
         onDismissRequest = dismissSheet,
-    )
-    LaunchedEffect(state.activeSheet) {
-        if (state.activeSheet is ReadBookSheet.ReadAloudPlayer) {
-            aloudPlayerViewModel.onIntent(
-                io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent.Refresh
-            )
-            aloudPlayerViewModel.effects.collectLatest { effect ->
-                when (effect) {
-                    ReadAloudPlayerEffect.ReturnToReaderSettings ->
-                        onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
-                    ReadAloudPlayerEffect.ReturnToClassic ->
-                        onIntent(ReadBookIntent.OpenClassicReadAloudControls)
-                }
-            }
-        }
+        title = stringResource(R.string.aloud_config),
+    ) {
+        ReadAloudConfigContent(
+            state = state,
+            playerState = aloudPlayerShellState,
+            onIntent = onIntent,
+            onPlayerIntent = aloudPlayerViewModel::onIntent,
+        )
     }
 
     val dictSheet = state.activeSheet as? ReadBookSheet.Dict

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

@@ -39,6 +39,7 @@ import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadStyleGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.CleanSelectedTextUseCase
@@ -1327,6 +1328,8 @@ class ReadBookViewModel(
             is ReadBookIntent.SetReadAloudPauseOnPhoneCall ->
                 readAloudDelegate.setPauseOnPhoneCall(intent.value)
             is ReadBookIntent.SetReadAloudWakeLock -> readAloudDelegate.setWakeLock(intent.value)
+            is ReadBookIntent.SetReadAloudKeepOnExit ->
+                readAloudDelegate.setKeepOnExit(intent.value)
             is ReadBookIntent.SetShowReadAloudCapsule ->
                 readAloudDelegate.setShowCapsule(intent.value)
             is ReadBookIntent.SetCapsuleAutoCollapse ->
@@ -1354,8 +1357,13 @@ class ReadBookViewModel(
             ReadBookIntent.BackToSpeakingPosition -> readAloudDelegate.backToSpeakingPosition()
             ReadBookIntent.ReadAloudFromHere -> ReadBook.readAloud()
             is ReadBookIntent.SetReadAloudTtsTimer -> readAloudDelegate.setTtsTimer(intent.value)
-            is ReadBookIntent.SetFinishCurrentChapterAfterTimer ->
-                readAloudDelegate.setFinishCurrentChapterAfterTimer(intent.value)
+            is ReadBookIntent.SetReadAloudTimerMode ->
+                readAloudDelegate.setTimerMode(ReadAloudTimerMode.fromStorage(intent.value))
+
+            is ReadBookIntent.SetReadAloudTimerChapters -> readAloudDelegate.setTimerChapters(intent.value)
+            is ReadBookIntent.SetFinishCurrentChapterAfterTimer -> readAloudDelegate.setFinishCurrentChapterAfterTimer(
+                intent.value
+            )
             is ReadBookIntent.SetReadAloudTtsFollowSys ->
                 readAloudDelegate.setTtsFollowSys(intent.value)
             is ReadBookIntent.SetReadAloudTtsSpeechRate ->
@@ -2236,6 +2244,11 @@ class ReadBookViewModel(
         if (closeReadBookKeepReadAloud || !BaseReadAloudService.isRun) {
             return
         }
+        // 「退出阅读时继续后台朗读」:读的是持久设置,不依赖本次退出来源
+        // (标题栏关闭、返回手势、后台按钮走的是同一个 closeReadBook)。
+        if (readAloudSettingsRepository.currentSettings.keepReadAloudOnExit) {
+            return
+        }
         ReadAloud.stop(context)
         _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
     }

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

@@ -45,6 +45,11 @@ fun ReadAloudConfigContent(
     onIntent: (ReadBookIntent) -> Unit,
     onPlayerIntent: (ReadAloudPlayerIntent) -> Unit,
     modifier: Modifier = Modifier,
+    /**
+     * true 表示内容被整页宿主承载,数值项就地铺开成滑块;
+     * false(默认)表示宿主是卡片弹层,数值项继续打开选择器弹层。
+     */
+    asPage: Boolean = false,
 ) {
     val pagerState = rememberPagerState(pageCount = { 2 })
     val scope = rememberCoroutineScope()
@@ -73,7 +78,7 @@ fun ReadAloudConfigContent(
                 modifier = Modifier
                     .fillMaxWidth()
                     .verticalScroll(rememberScrollState())
-                    .padding(top = 8.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
+                    .padding(top = 8.dp, bottom = 16.dp),
             ) {
                 if (page == 0) {
                     TinyDropdownSettingItem(
@@ -147,6 +152,14 @@ fun ReadAloudConfigContent(
                             onIntent(ReadBookIntent.SetReadAloudWakeLock(it))
                         },
                     )
+                    TinySwitchSettingItem(
+                        title = stringResource(R.string.read_aloud_keep_on_exit),
+                        description = stringResource(R.string.read_aloud_keep_on_exit_summary),
+                        checked = state.readAloudKeepOnExit,
+                        onCheckedChange = {
+                            onIntent(ReadBookIntent.SetReadAloudKeepOnExit(it))
+                        },
+                    )
                     TinySwitchSettingItem(
                         title = stringResource(R.string.pref_media_button_per_next),
                         description = stringResource(R.string.pref_media_button_per_next_summary),
@@ -315,22 +328,74 @@ fun ReadAloudConfigContent(
                         title = stringResource(R.string.sys_tts_config),
                         onClick = { onIntent(ReadBookIntent.OpenSystemTtsSettings) },
                     )
-                    TinyClickableSettingItem(
-                        title = stringResource(R.string.read_aloud_preload),
-                        onClick = { onIntent(ReadBookIntent.OpenPreDownloadNumPicker) },
-                    )
-                    TinyClickableSettingItem(
-                        title = stringResource(R.string.tts_pre_synthesis_concurrency),
-                        onClick = { onIntent(ReadBookIntent.OpenPreSynthesisConcurrencyPicker) },
-                    )
-                    TinyClickableSettingItem(
-                        title = stringResource(R.string.tts_paragraph_interval),
-                        onClick = { onIntent(ReadBookIntent.OpenParagraphIntervalPicker) },
-                    )
-                    TinyClickableSettingItem(
-                        title = stringResource(R.string.audio_cache_clean_time),
-                        onClick = { onIntent(ReadBookIntent.OpenCacheCleanTimePicker) },
-                    )
+                    if (asPage) {
+                        // 整页宿主自己就是一层,数值项直接铺开成滑块:
+                        // 再叠一层选择器 sheet 会重新引入「sheet 套 sheet」的层级问题。
+                        ReadAloudNumberSliderItem(
+                            title = stringResource(R.string.read_aloud_preload),
+                            description = stringResource(
+                                R.string.read_aloud_preload_summary, state.preDownloadNum,
+                            ),
+                            value = state.preDownloadNum,
+                            defaultValue = 10,
+                            valueRange = 0f..100f,
+                            onValueChange = { onIntent(ReadBookIntent.ApplyPreDownloadNum(it)) },
+                        )
+                        ReadAloudNumberSliderItem(
+                            title = stringResource(R.string.tts_pre_synthesis_concurrency),
+                            description = stringResource(
+                                R.string.tts_pre_synthesis_concurrency_summary,
+                                state.preSynthesisConcurrency,
+                            ),
+                            value = state.preSynthesisConcurrency,
+                            defaultValue = 3,
+                            valueRange = 1f..8f,
+                            onValueChange = {
+                                onIntent(ReadBookIntent.ApplyPreSynthesisConcurrency(it))
+                            },
+                        )
+                        ReadAloudNumberSliderItem(
+                            title = stringResource(R.string.tts_paragraph_interval),
+                            description = stringResource(
+                                R.string.tts_paragraph_interval_summary,
+                                state.readAloudParagraphInterval,
+                            ),
+                            value = state.readAloudParagraphInterval,
+                            defaultValue = 0,
+                            valueRange = 0f..5000f,
+                            onValueChange = { onIntent(ReadBookIntent.ApplyParagraphInterval(it)) },
+                        )
+                        ReadAloudNumberSliderItem(
+                            title = stringResource(R.string.audio_cache_clean_time),
+                            description = stringResource(
+                                R.string.audio_cache_clean_time_summary,
+                                state.audioCacheCleanTime,
+                            ),
+                            value = state.audioCacheCleanTime,
+                            defaultValue = 10,
+                            valueRange = 0f..10080f,
+                            onValueChange = { onIntent(ReadBookIntent.ApplyAudioCacheCleanTime(it)) },
+                        )
+                    } else {
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.read_aloud_preload),
+                            onClick = { onIntent(ReadBookIntent.OpenPreDownloadNumPicker) },
+                        )
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.tts_pre_synthesis_concurrency),
+                            onClick = {
+                                onIntent(ReadBookIntent.OpenPreSynthesisConcurrencyPicker)
+                            },
+                        )
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.tts_paragraph_interval),
+                            onClick = { onIntent(ReadBookIntent.OpenParagraphIntervalPicker) },
+                        )
+                        TinyClickableSettingItem(
+                            title = stringResource(R.string.audio_cache_clean_time),
+                            onClick = { onIntent(ReadBookIntent.OpenCacheCleanTimePicker) },
+                        )
+                    }
                     TinyClickableSettingItem(
                         title = stringResource(R.string.clear_cache),
                         onClick = { onIntent(ReadBookIntent.ClearTtsCache) },
@@ -341,6 +406,28 @@ fun ReadAloudConfigContent(
     }
 }
 
+/**
+ * 整页宿主用的数值项:直接铺开滑块,不再叠一层选择器弹层。
+ */
+@Composable
+private fun ReadAloudNumberSliderItem(
+    title: String,
+    description: String,
+    value: Int,
+    defaultValue: Int,
+    valueRange: ClosedFloatingPointRange<Float>,
+    onValueChange: (Int) -> Unit,
+) {
+    SliderSettingItem(
+        title = title,
+        description = description,
+        value = value.toFloat(),
+        defaultValue = defaultValue.toFloat(),
+        valueRange = valueRange,
+        onValueChange = { onValueChange(it.toInt()) },
+    )
+}
+
 /**
  * 「按符号」划分方式的标点多选。
  *

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

@@ -0,0 +1,35 @@
+package io.legado.app.ui.book.read.sheet
+
+import io.legado.app.ui.book.read.ReadBookUiState
+import io.legado.app.ui.book.readaloud.player.ReadAloudSettingsUiState
+import kotlinx.collections.immutable.toImmutableSet
+
+/**
+ * 把朗读设置快照投影成配置内容需要的 UiState 形态。
+ *
+ * 听书播放界面是独立目的地,拿不到阅读器 ViewModel;而配置内容只读这些字段。
+ * 与其复制一份几乎相同的表单,不如把两者收敛到同一个入口 —— 配置内容只有一处实现,
+ * 两个宿主各自提供自己的快照。未列出的字段保持 [ReadBookUiState] 默认值。
+ */
+fun ReadAloudSettingsUiState.asReadBookUiState(): ReadBookUiState = ReadBookUiState(
+    defaultReadAloudInterface = defaultReadAloudInterface,
+    showReadAloudCapsule = showReadAloudCapsule,
+    capsuleAutoCollapse = capsuleAutoCollapse,
+    readAloudIgnoreAudioFocus = readAloudIgnoreAudioFocus,
+    readAloudPauseOnPhoneCall = readAloudPauseOnPhoneCall,
+    readAloudWakeLock = readAloudWakeLock,
+    readAloudKeepOnExit = readAloudKeepOnExit,
+    readAloudMediaButtonPerNext = readAloudMediaButtonPerNext,
+    readAloudAndroidMediaControl = readAloudAndroidMediaControl,
+    readAloudSystemMediaCompat = readAloudSystemMediaCompat,
+    readAloudStreamAudio = readAloudStreamAudio,
+    speechAnalysisMode = speechAnalysisMode,
+    speechAnalysisReasoningLevel = speechAnalysisReasoningLevel,
+    useMultiSpeaker = useMultiSpeaker,
+    readAloudContentSplitMode = readAloudContentSplitMode,
+    readAloudContentSplitSymbols = readAloudContentSplitSymbols.toImmutableSet(),
+    preDownloadNum = preDownloadNum,
+    preSynthesisConcurrency = preSynthesisConcurrency,
+    readAloudParagraphInterval = readAloudParagraphInterval,
+    audioCacheCleanTime = audioCacheCleanTime,
+)

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

@@ -1,25 +1,14 @@
 package io.legado.app.ui.book.read.sheet
 
-import androidx.activity.compose.BackHandler
-import androidx.activity.compose.PredictiveBackHandler
-import androidx.compose.animation.AnimatedContent
 import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.WindowInsets
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.width
-import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.List
-import androidx.compose.material.icons.filled.Alarm
 import androidx.compose.material.icons.filled.Headphones
 import androidx.compose.material.icons.filled.Menu
 import androidx.compose.material.icons.filled.Pause
@@ -29,172 +18,32 @@ import androidx.compose.material.icons.filled.SkipNext
 import androidx.compose.material.icons.filled.SkipPrevious
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material.icons.filled.VisibilityOff
-import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.ModalBottomSheetProperties
-import androidx.compose.material3.SheetValue.Expanded
-import androidx.compose.material3.SheetValue.Hidden
 import androidx.compose.material3.Text
-import androidx.compose.material3.rememberBottomSheetState
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableFloatStateOf
-import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.RectangleShape
-import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
-import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerScreenContent
-import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerUiState
-import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.theme.ProvideAppDensity
-import io.legado.app.ui.theme.ProvideThemeOverride
-import io.legado.app.ui.theme.ThemeOverrideState
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
-import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
-import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
+import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 
-enum class ReadAloudPage {
-    Config,
-    Player,
-}
-
-@OptIn(ExperimentalMaterial3Api::class)
-@Composable
-fun ReadAloudScreen(
-    page: ReadAloudPage?,
-    state: ReadBookUiState,
-    playerState: ReadAloudPlayerUiState,
-    playerTheme: ThemeOverrideState?,
-    onIntent: (ReadBookIntent) -> Unit,
-    onPlayerIntent: (ReadAloudPlayerIntent) -> Unit,
-    onDismissRequest: () -> Unit,
-) {
-    val sheetState = rememberBottomSheetState(
-        initialValue = Hidden,
-        enabledValues = setOf(Hidden, Expanded),
-    )
-    val isPlayer = page == ReadAloudPage.Player
-    var backProgress by remember { mutableFloatStateOf(0f) }
-    val predictiveBackOffset = with(LocalDensity.current) { 120.dp.toPx() }
-    var configParentIsPlayer by remember { mutableStateOf(false) }
-    LaunchedEffect(page) {
-        when (page) {
-            ReadAloudPage.Player -> configParentIsPlayer = true
-            null -> configParentIsPlayer = false
-            ReadAloudPage.Config -> Unit
-        }
-    }
-    val returnFromConfig = {
-        if (configParentIsPlayer) {
-            onIntent(
-                ReadBookIntent.ShowSheet(
-                    io.legado.app.ui.book.read.ReadBookSheet.ReadAloudPlayer
-                )
-            )
-        } else {
-            onIntent(ReadBookIntent.OpenClassicReadAloudControls)
-        }
-    }
-    if (page != null) {
-        ModalBottomSheet(
-            onDismissRequest = onDismissRequest,
-            sheetState = sheetState,
-            modifier = (if (isPlayer) Modifier.fillMaxSize() else Modifier)
-                .graphicsLayer { translationY = backProgress * predictiveBackOffset },
-            shape = if (isPlayer) RectangleShape else MaterialTheme.shapes.extraLarge,
-            sheetMaxWidth = if (isPlayer) Dp.Unspecified else 640.dp,
-            containerColor = if (isPlayer) {
-                Color.Transparent
-            } else {
-                LegadoTheme.colorScheme.surfaceContainer
-            },
-            contentColor = LegadoTheme.colorScheme.onSurface,
-            contentWindowInsets = {
-                WindowInsets(0, 0, 0, 0)
-            },
-            dragHandle = null,
-            properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
-        ) {
-            ProvideAppDensity {
-                BackHandler(enabled = page == ReadAloudPage.Config, onBack = returnFromConfig)
-                PredictiveBackHandler(enabled = page != ReadAloudPage.Config) { progress ->
-                    try {
-                        progress.collect { event ->
-                            backProgress = event.progress
-                        }
-                        sheetState.hide()
-                        onDismissRequest()
-                    } finally {
-                        backProgress = 0f
-                    }
-                }
-                AnimatedContent(
-                    targetState = page,
-                    label = "ReadAloudPage",
-                ) { targetPage ->
-                    when (targetPage) {
-                        ReadAloudPage.Config -> Column {
-                            Row(
-                                modifier = Modifier
-                                    .fillMaxWidth()
-                                    .windowInsetsPadding(WindowInsets.statusBars)
-                                    .padding(horizontal = 16.dp),
-                                verticalAlignment = Alignment.CenterVertically,
-                            ) {
-                                MediumTonalButton(
-                                    onClick = {
-                                        returnFromConfig()
-                                    },
-                                    icon = Icons.AutoMirrored.Filled.ArrowBack,
-                                    contentDescription = stringResource(R.string.back),
-                                )
-                                Spacer(Modifier.width(12.dp))
-                                Text(
-                                    text = stringResource(R.string.aloud_config),
-                                    style = LegadoTheme.typography.titleLarge,
-                                )
-                            }
-                            Spacer(modifier = Modifier.padding(vertical = 8.dp))
-                            ReadAloudConfigContent(
-                                state = state,
-                                playerState = playerState,
-                                onIntent = onIntent,
-                                onPlayerIntent = onPlayerIntent,
-                                modifier = Modifier.padding(horizontal = 16.dp)
-                            )
-                        }
-
-                        ReadAloudPage.Player -> ProvideThemeOverride(playerTheme) {
-                            ReadAloudPlayerScreenContent(
-                                state = playerState,
-                                onIntent = onPlayerIntent,
-                                onBack = onDismissRequest,
-                            )
-                        }
-                    }
-                }
-            }
-        }
-    }
-}
-
+/**
+ * 经典朗读控制面板的内容。
+ *
+ * 它自身不是弹层:宿主提供层级与动画。阅读界面用 `AppModalBottomSheet` 承载
+ * (保留自下而上的进出动画与下拉关闭手势),因此不会再被塞进阅读菜单的一页里。
+ */
 @Composable
 fun ReadAloudContent(
     state: ReadBookUiState,
@@ -204,11 +53,11 @@ fun ReadAloudContent(
     onGoToBackground: () -> Unit,
     onOpenMainMenu: () -> Unit,
     onShowReadAloudConfig: () -> Unit,
+    onShowTimerSettings: () -> Unit,
     modifier: Modifier = Modifier,
 ) {
-    val timerMinute = state.readAloudTtsTimer
     val ttsSpeechRate = state.readAloudTtsSpeechRate
-    var timerMenuExpanded by remember { mutableStateOf(false) }
+    var speechRatePreview by remember(ttsSpeechRate) { mutableFloatStateOf(ttsSpeechRate.toFloat()) }
 
     Column(
         modifier = modifier.fillMaxWidth(),
@@ -251,24 +100,10 @@ fun ReadAloudContent(
 
         Spacer(Modifier.height(12.dp))
 
-        TinySliderSettingItem(
+        TinyClickableSettingItem(
             title = stringResource(R.string.set_timer),
-            description = stringResource(R.string.timer_m, timerMinute),
-            value = timerMinute.toFloat(),
-            valueRange = PlaybackTimer.MIN_MINUTES.toFloat()..PlaybackTimer.MAX_MINUTES.toFloat(),
-            steps = PlaybackTimer.MAX_MINUTES - PlaybackTimer.MIN_MINUTES - 1,
-            onValueChange = {
-                onIntent(ReadBookIntent.SetReadAloudTtsTimer(it.toInt()))
-            },
-        )
-
-        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))
-            },
+            description = readAloudTimerSummary(state),
+            onClick = onShowTimerSettings,
         )
 
         Spacer(Modifier.height(8.dp))
@@ -282,28 +117,6 @@ fun ReadAloudContent(
                 text = stringResource(R.string.previous_chapter),
                 modifier = Modifier.weight(1f),
             )
-            Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.Center) {
-                MediumTonalButton(
-                    onClick = { timerMenuExpanded = true },
-                    icon = Icons.Default.Alarm,
-                    contentDescription = stringResource(R.string.timer_m, timerMinute),
-                )
-                RoundDropdownMenu(
-                    expanded = timerMenuExpanded,
-                    onDismissRequest = { timerMenuExpanded = false },
-                ) {
-                    listOf(0, 5, 10, 15, 30, 60, 90).forEach { minute ->
-                        RoundDropdownMenuItem(
-                            text = stringResource(R.string.timer_m, minute),
-                            isSelected = minute == timerMinute,
-                            onClick = {
-                                onIntent(ReadBookIntent.SetReadAloudTtsTimer(minute))
-                                timerMenuExpanded = false
-                            },
-                        )
-                    }
-                }
-            }
             MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ReadAloudNextChapter) },
                 text = stringResource(R.string.next_chapter),
@@ -328,8 +141,10 @@ fun ReadAloudContent(
             valueRange = 0f..80f,
             steps = 79,
             enabled = !state.readAloudTtsFollowSys,
-            onValueChange = {
-                onIntent(ReadBookIntent.SetReadAloudTtsSpeechRate(it.toInt()))
+            // 拖动中写设置会与外部 value 回流打架(滑块来回跳),松手才提交
+            onValueChange = { speechRatePreview = it },
+            onValueChangeFinished = {
+                onIntent(ReadBookIntent.SetReadAloudTtsSpeechRate(speechRatePreview.toInt()))
             },
         )
 
@@ -368,6 +183,22 @@ fun ReadAloudContent(
     }
 }
 
+/**
+ * 定时入口的摘要:按当前模式显示剩余分钟或剩余章数,未开启显示「关闭」。
+ */
+@Composable
+private fun readAloudTimerSummary(state: ReadBookUiState): String = when {
+    state.readAloudTimerMode == ReadAloudTimerMode.Chapter.storageValue &&
+            state.readAloudTimerChapters > 0 ->
+        stringResource(R.string.timer_chapters, state.readAloudTimerChapters)
+
+    state.readAloudTimerMode == ReadAloudTimerMode.Minute.storageValue &&
+            state.readAloudTtsTimer > 0 ->
+        stringResource(R.string.timer_m, state.readAloudTtsTimer)
+
+    else -> stringResource(R.string.close)
+}
+
 @Composable
 private fun ActionButton(
     icon: androidx.compose.ui.graphics.vector.ImageVector,

+ 81 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudShellHost.kt

@@ -0,0 +1,81 @@
+package io.legado.app.ui.book.readaloud
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.tween
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.scaleIn
+import androidx.compose.animation.scaleOut
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.model.ReadAloudSessionStore
+import io.legado.app.ui.book.read.ReadAloudCapsule
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerUiState
+import org.koin.compose.koinInject
+
+/**
+ * 朗读悬浮胶囊的全局叠层。
+ *
+ * 胶囊在阅读界面之外也要可见,因此不能再挂在 `ReadBookRouteScreen` 里:阅读器只是
+ * Navigation 3 的一个目的地,离开它就整棵子树被销毁。这里由宿主 Activity 叠在导航之上,
+ * 只要朗读会话在跑就一直在。
+ *
+ * 数据来自全局的 [ReadAloudSessionStore] 与朗读设置,不依赖任何阅读器 ViewModel,
+ * 所以朗读无论从书架、听书页还是通知栏启动,胶囊表现一致。
+ *
+ * 显隐判据用 [ReadAloudPlayerUiState.readAloudRunning](背后是 `BaseReadAloudService.isRun`,
+ * 服务创建/销毁时同步翻转)而不是 [ReadAloudSessionStore] 的 status:后者只在 play/pause/stop
+ * 事件里更新,服务仍在跑时可能停在旧值,表现为「回到阅读界面胶囊要等下一段才出现」。
+ */
+@Composable
+fun ReadAloudShellHost(
+    playerState: ReadAloudPlayerUiState,
+    showCapsule: Boolean,
+    sessionStore: ReadAloudSessionStore = koinInject(),
+    settingsGateway: ReadAloudSettingsGateway = koinInject(),
+    bottomPadding: Dp = 16.dp,
+    hidden: Boolean = false,
+    onIntent: (ReadAloudPlayerIntent) -> Unit,
+    onCapsulePositionChanged: (x: Float, y: Float) -> Unit,
+    onOpenPlayer: () -> Unit,
+) {
+    val session by sessionStore.state.collectAsStateWithLifecycle()
+    val settings by settingsGateway.settings.collectAsStateWithLifecycle(
+        settingsGateway.currentSettings
+    )
+
+    Box(modifier = Modifier.fillMaxSize()) {
+        AnimatedVisibility(
+            visible = !hidden && showCapsule && playerState.readAloudRunning,
+            enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),
+            exit = fadeOut(tween(140)) + scaleOut(tween(180), targetScale = 0.88f),
+        ) {
+            ReadAloudCapsule(
+                bookName = playerState.bookName,
+                author = playerState.author,
+                coverPath = playerState.coverPath,
+                sourceOrigin = playerState.sourceOrigin,
+                isPaused = session.status != ReadAloudSessionStatus.Playing,
+                offsetXDp = settings.capsuleOffsetX,
+                offsetYDp = settings.capsuleOffsetY,
+                progress = session.playback.chapterPosition.toFloat() /
+                        session.playback.chapterLength.coerceAtLeast(1),
+                autoCollapse = settings.capsuleAutoCollapse,
+                bottomPadding = bottomPadding,
+                onPositionChanged = onCapsulePositionChanged,
+                onTogglePause = { onIntent(ReadAloudPlayerIntent.TogglePause) },
+                onStop = { onIntent(ReadAloudPlayerIntent.StopReadAloud) },
+                onOpenPlayer = onOpenPlayer,
+            )
+        }
+    }
+}

+ 121 - 0
app/src/main/java/io/legado/app/ui/book/readaloud/ReadAloudTimerSheet.kt

@@ -0,0 +1,121 @@
+package io.legado.app.ui.book.readaloud
+
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalResources
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
+import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
+import io.legado.app.ui.widget.components.tabRow.CardTabRow
+import kotlin.math.roundToInt
+
+/**
+ * 朗读定时设置(宿主无关)。
+ *
+ * 经典控制面板与听书播放界面共用这一张卡片,所以只接受原始值 + 回调,
+ * 不依赖任一宿主的 UiState/Intent:两边各自把状态与意图映射进来,
+ * 否则只能复制一份表单,两种定时模式互斥的规则迟早只在一边生效。
+ */
+data class ReadAloudTimerConfig(
+    val mode: ReadAloudTimerMode,
+    val minutes: Int,
+    val chapters: Int,
+    /** 分钟模式到点后是否读完本章再停;章节模式不需要它。 */
+    val finishCurrentChapterAfterTimer: Boolean = false,
+)
+
+@Composable
+fun ReadAloudTimerSheet(
+    show: Boolean,
+    config: ReadAloudTimerConfig,
+    onDismissRequest: () -> Unit,
+    onSetMode: (ReadAloudTimerMode) -> Unit,
+    onSetMinutes: (Int) -> Unit,
+    onSetChapters: (Int) -> Unit,
+    onSetFinishCurrentChapterAfterTimer: (Boolean) -> Unit,
+) {
+    var minutesPreview by remember(config.minutes) {
+        mutableFloatStateOf(config.minutes.toFloat())
+    }
+    var chaptersPreview by remember(config.chapters) {
+        mutableFloatStateOf(config.chapters.toFloat())
+    }
+    // valueFormat 不是 @Composable 上下文,用 context 取文案(stringResource 在此不可用)
+    val closeLabel = stringResource(R.string.close)
+    val resources = LocalResources.current
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.set_timer),
+    ) {
+        // 两种定时互斥,切页签立即写入,不留「选了模式但没设值」的中间态
+        CardTabRow(
+            tabTitles = listOf(
+                stringResource(R.string.read_aloud_timer_by_minute),
+                stringResource(R.string.read_aloud_timer_by_chapter),
+            ),
+            selectedTabIndex = if (config.mode == ReadAloudTimerMode.Chapter) 1 else 0,
+            onTabSelected = { index ->
+                onSetMode(
+                    if (index == 1) ReadAloudTimerMode.Chapter else ReadAloudTimerMode.Minute
+                )
+            },
+        )
+        Spacer(modifier = Modifier.padding(top = 12.dp))
+        if (config.mode == ReadAloudTimerMode.Chapter) {
+            TinySliderSettingItem(
+                title = stringResource(R.string.read_aloud_timer_by_chapter),
+                description = stringResource(R.string.read_aloud_timer_chapter_summary),
+                value = chaptersPreview.coerceIn(0f, PlaybackTimer.MAX_CHAPTERS.toFloat()),
+                valueRange = 0f..PlaybackTimer.MAX_CHAPTERS.toFloat(),
+                // 一章一档;0 保留给「关闭」
+                steps = PlaybackTimer.MAX_CHAPTERS - 1,
+                stepSize = 1f,
+                valueFormat = { value ->
+                    if (value == 0f) closeLabel
+                    else resources.getString(R.string.timer_chapters, value.roundToInt())
+                },
+                // 拖动中只更新本地预览、松手才写设置:拖动中写会与外部 value 回流打架,滑块会跳
+                onValueChange = { chaptersPreview = it },
+                onValueChangeFinished = { onSetChapters(chaptersPreview.roundToInt()) },
+            )
+        } else {
+            TinySliderSettingItem(
+                title = stringResource(R.string.set_timer),
+                description = stringResource(R.string.timer_m, PlaybackTimer.MAX_MINUTES),
+                value = minutesPreview.coerceIn(0f, PlaybackTimer.MAX_MINUTES.toFloat()),
+                valueRange = 0f..PlaybackTimer.MAX_MINUTES.toFloat(),
+                // 一分钟一档;0 保留给「关闭」
+                steps = PlaybackTimer.MAX_MINUTES - 1,
+                stepSize = 1f,
+                valueFormat = { value ->
+                    if (value == 0f) closeLabel
+                    else resources.getString(R.string.timer_m, value.roundToInt())
+                },
+                onValueChange = { minutesPreview = it },
+                onValueChangeFinished = { onSetMinutes(minutesPreview.roundToInt()) },
+            )
+            // 「读完本章再停」是分钟模式的修饰项:章节模式本身就是停在章末,这里不显示
+            TinySwitchSettingItem(
+                title = stringResource(R.string.finish_current_chapter_after_timer),
+                description = stringResource(R.string.finish_current_chapter_after_timer_summary),
+                checked = config.finishCurrentChapterAfterTimer,
+                modifier = Modifier.padding(vertical = 4.dp),
+                color = LegadoTheme.colorScheme.surfaceContainerHigh,
+                onCheckedChange = onSetFinishCurrentChapterAfterTimer,
+            )
+        }
+    }
+}

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

@@ -0,0 +1,56 @@
+package io.legado.app.ui.book.readaloud.player
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import coil3.ImageLoader
+import io.legado.app.domain.gateway.CoverSettingsGateway
+import io.legado.app.help.coil.CoverExtras
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.rememberImageSeedColor
+import io.legado.app.ui.theme.rememberThemeOverride
+import io.legado.app.ui.widget.components.image.cover.usesDefaultBookCover
+import org.koin.compose.koinInject
+import io.legado.app.model.BookCover as BookCoverModel
+
+/**
+ * 听书播放界面的封面取色主题。
+ *
+ * 与阅读器内播放页同源:按显示封面(含默认封面日夜选择)取种子色,再派生主题覆盖。
+ */
+@Composable
+internal fun rememberPlayerThemeOverride(state: ReadAloudPlayerUiState) = run {
+    val imageLoader: ImageLoader = koinInject()
+    val coverSettings = koinInject<CoverSettingsGateway>().currentSettings
+    val isNight = LegadoTheme.isDark
+    val useDefaultCover = usesDefaultBookCover(state.coverPath)
+    val defaultCoverPaths =
+        if (isNight) coverSettings.defaultCoverDark else coverSettings.defaultCover
+    val coverPath = remember(
+        state.bookName,
+        state.author,
+        state.coverPath,
+        useDefaultCover,
+        isNight,
+        defaultCoverPaths,
+    ) {
+        if (useDefaultCover) {
+            BookCoverModel.getRandomDefaultPath(seed = state.bookName, isNight = isNight)
+        } else {
+            state.coverPath
+        }
+    }
+    val sourceOrigin = if (useDefaultCover) null else state.sourceOrigin
+    val loadOnlyWifi = !useDefaultCover && coverSettings.loadOnlyOnWifi
+    val requestKey = remember(coverPath, sourceOrigin, loadOnlyWifi) {
+        listOf(coverPath, sourceOrigin, loadOnlyWifi)
+    }
+    val seedColor = rememberImageSeedColor(
+        imageLoader = imageLoader,
+        data = coverPath,
+        requestKey = requestKey,
+    ) {
+        extras[CoverExtras.SourceOrigin] = sourceOrigin
+        extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
+    }
+    rememberThemeOverride(seedColor)
+}

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

@@ -0,0 +1,74 @@
+package io.legado.app.ui.book.readaloud.player
+
+import io.legado.app.ui.book.read.ReadBookIntent
+
+/**
+ * 把配置卡片的 [ReadBookIntent] 翻译成全局设置写入。
+ *
+ * 配置内容(`ReadAloudConfigContent`)的契约是 `ReadBookIntent`,因为它的主宿主是阅读器。
+ * 听书播放界面是独立目的地、没有阅读器 ViewModel,所以这里把同一批意图落到
+ * [ReadAloudPlayerViewModel.onConfigIntent],两个宿主的设置语义完全一致。
+ */
+internal fun ReadAloudPlayerViewModel.applyReadBookConfigIntent(intent: ReadBookIntent) {
+    when (intent) {
+        is ReadBookIntent.SetDefaultReadAloudInterface ->
+            onConfigIntent(ReadAloudConfigOption.DefaultInterface, value = intent.value)
+
+        is ReadBookIntent.SetShowReadAloudCapsule ->
+            onConfigIntent(ReadAloudConfigOption.ShowCapsule, selected = intent.value)
+
+        is ReadBookIntent.SetCapsuleAutoCollapse ->
+            onConfigIntent(ReadAloudConfigOption.CapsuleAutoCollapse, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudIgnoreAudioFocus ->
+            onConfigIntent(ReadAloudConfigOption.IgnoreAudioFocus, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudPauseOnPhoneCall ->
+            onConfigIntent(ReadAloudConfigOption.PauseOnPhoneCall, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudWakeLock ->
+            onConfigIntent(ReadAloudConfigOption.WakeLock, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudKeepOnExit ->
+            onConfigIntent(ReadAloudConfigOption.KeepOnExit, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudMediaButtonPerNext ->
+            onConfigIntent(ReadAloudConfigOption.MediaButtonPerNext, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudAndroidMediaControl ->
+            onConfigIntent(ReadAloudConfigOption.AndroidMediaControl, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudSystemMediaCompat ->
+            onConfigIntent(ReadAloudConfigOption.SystemMediaCompat, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudStreamAudio ->
+            onConfigIntent(ReadAloudConfigOption.StreamAudio, selected = intent.value)
+
+        is ReadBookIntent.SetSpeechAnalysisMode ->
+            onConfigIntent(ReadAloudConfigOption.SpeechAnalysisMode, value = intent.value)
+
+        is ReadBookIntent.SetSpeechAnalysisReasoningLevel ->
+            onConfigIntent(ReadAloudConfigOption.SpeechAnalysisReasoningLevel, value = intent.value)
+
+        is ReadBookIntent.SetUseMultiSpeaker ->
+            onConfigIntent(ReadAloudConfigOption.UseMultiSpeaker, selected = intent.value)
+
+        is ReadBookIntent.SetReadAloudContentSplitMode ->
+            onConfigIntent(ReadAloudConfigOption.ContentSplit, value = intent.value)
+
+        is ReadBookIntent.ApplyPreDownloadNum ->
+            onConfigIntent(ReadAloudConfigOption.PreDownloadNum, intValue = intent.value)
+
+        is ReadBookIntent.ApplyPreSynthesisConcurrency ->
+            onConfigIntent(ReadAloudConfigOption.PreSynthesisConcurrency, intValue = intent.value)
+
+        is ReadBookIntent.ApplyParagraphInterval ->
+            onConfigIntent(ReadAloudConfigOption.ParagraphInterval, intValue = intent.value)
+
+        is ReadBookIntent.ApplyAudioCacheCleanTime ->
+            onConfigIntent(ReadAloudConfigOption.AudioCacheCleanTime, intValue = intent.value)
+
+        // 其余意图在播放界面没有等价动作(缓存清理、数值选择器弹层等),静默忽略
+        else -> Unit
+    }
+}

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

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.readaloud.player
 
 import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
@@ -31,9 +32,21 @@ data class ReadAloudPlayerUiState(
     val engineName: String = "",
     val speakerName: String = "",
     val isPaused: Boolean = false,
+    /**
+     * 朗读服务是否在跑(`BaseReadAloudService.isRun`)。
+     *
+     * 全局胶囊的显隐以它为准,而不是 `ReadAloudSessionStore.status`:status 只在
+     * play/pause/stop 事件里更新,服务仍在跑时可能停在旧值。
+     */
+    val readAloudRunning: Boolean = false,
     val speed: Int = 10,
     val timerMinutes: Int = 0,
+    /** 分钟定时到点后读完本章再停;只对分钟模式有意义。 */
     val finishCurrentChapterAfterTimer: Boolean = false,
+    /** 定时模式:分钟 / 章节;两者互斥。 */
+    val timerMode: String = ReadAloudTimerMode.Minute.storageValue,
+    /** 章节定时剩余章数;0 表示未开启。 */
+    val timerChapters: Int = 0,
     val bgMode: Int = 0,
     val activeSheet: ReadAloudPlayerSheet? = null,
 )
@@ -46,6 +59,9 @@ sealed interface ReadAloudPlayerSheet {
 sealed interface ReadAloudPlayerIntent {
     data object Refresh : ReadAloudPlayerIntent
     data object TogglePause : ReadAloudPlayerIntent
+
+    /** 悬浮胶囊上的停止按钮与经典控制面板同义。 */
+    data object StopReadAloud : ReadAloudPlayerIntent
     data object PreviousChapter : ReadAloudPlayerIntent
     data object NextChapter : ReadAloudPlayerIntent
     data object PreviousParagraph : ReadAloudPlayerIntent
@@ -57,6 +73,14 @@ 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 SetTimerMode(val value: String) : ReadAloudPlayerIntent
+
+    /** 章节定时剩余章数;0 表示关闭章节定时。 */
+    data class SetTimerChapters(val value: Int) : ReadAloudPlayerIntent
+
+    /** 分钟定时到点后是否读完本章再停。 */
     data class SetFinishCurrentChapterAfterTimer(val value: Boolean) : ReadAloudPlayerIntent
     data class OpenSheet(val sheet: ReadAloudPlayerSheet) : ReadAloudPlayerIntent
     data object DismissSheet : ReadAloudPlayerIntent

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

@@ -10,6 +10,7 @@ import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.readaloud.ContentSplitPolicies
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import io.legado.app.feature.reader.core.readaloud.ReaderReadAloudChapter
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloudSessionStore
@@ -95,6 +96,8 @@ class ReadAloudPlayerCoordinator(
             isPaused = session.status != ReadAloudSessionStatus.Playing,
             speed = readAloudSettingsGateway.currentSettings.ttsSpeechRate,
             timerMinutes = session.timerMinutes,
+            timerMode = settings.timerMode,
+            timerChapters = settings.timerChapters,
             finishCurrentChapterAfterTimer = settings.finishCurrentChapterAfterTimer,
         )
     }
@@ -122,6 +125,8 @@ class ReadAloudPlayerCoordinator(
             isPaused = session.status != ReadAloudSessionStatus.Playing,
             speed = readAloudSettingsGateway.currentSettings.ttsSpeechRate,
             timerMinutes = session.timerMinutes,
+            timerMode = readAloudSettingsGateway.currentSettings.timerMode,
+            timerChapters = readAloudSettingsGateway.currentSettings.timerChapters,
             finishCurrentChapterAfterTimer =
                 readAloudSettingsGateway.currentSettings.finishCurrentChapterAfterTimer,
         )
@@ -175,6 +180,9 @@ class ReadAloudPlayerCoordinator(
         }
     }
 
+    /** 悬浮胶囊与播放界面的停止入口共用。 */
+    fun stop() = ReadAloud.stop(application)
+
     fun previousParagraph() = ReadAloud.prevParagraph(application)
     fun nextParagraph() = ReadAloud.nextParagraph(application)
     fun previousChapter() = ReadBook.moveToPrevChapter(true, false)
@@ -188,10 +196,51 @@ class ReadAloudPlayerCoordinator(
 
     suspend fun setTimer(minutes: Int) {
         val timer = PlaybackTimer.normalize(minutes)
-        readAloudSettingsGateway.update { it.copy(ttsTimer = timer) }
+        readAloudSettingsGateway.update {
+            it.copy(
+                ttsTimer = timer,
+                timerMode = ReadAloudTimerMode.Minute.storageValue,
+                // 两种模式互斥:切到分钟模式时清掉章节配额,避免两个倒计时同时生效
+                timerChapters = 0,
+            )
+        }
         ReadAloud.setTimer(application, timer)
     }
 
+    /** 切换定时模式;切过去的模式若没设过值,等于关闭定时。 */
+    suspend fun setTimerMode(mode: ReadAloudTimerMode) {
+        readAloudSettingsGateway.update {
+            it.copy(
+                timerMode = mode.storageValue,
+                ttsTimer = if (mode == ReadAloudTimerMode.Chapter) 0 else it.ttsTimer,
+                timerChapters = if (mode == ReadAloudTimerMode.Minute) 0 else it.timerChapters,
+            )
+        }
+        val settings = readAloudSettingsGateway.currentSettings
+        ReadAloud.setTimer(
+            application,
+            if (mode == ReadAloudTimerMode.Minute) settings.ttsTimer else 0,
+        )
+        ReadAloud.setTimerChapters(
+            application,
+            if (mode == ReadAloudTimerMode.Chapter) settings.timerChapters else 0,
+        )
+    }
+
+    /** 章节定时剩余章数;0 关闭。 */
+    suspend fun setTimerChapters(chapters: Int) {
+        val quota = PlaybackTimer.normalizeChapters(chapters)
+        readAloudSettingsGateway.update {
+            it.copy(
+                timerChapters = quota,
+                timerMode = ReadAloudTimerMode.Chapter.storageValue,
+                ttsTimer = 0,
+            )
+        }
+        ReadAloud.setTimerChapters(application, quota)
+    }
+
+    /** 分钟定时到点后是否读完本章再停。 */
     suspend fun setFinishCurrentChapterAfterTimer(value: Boolean) {
         readAloudSettingsGateway.update { it.copy(finishCurrentChapterAfterTimer = value) }
     }
@@ -249,6 +298,8 @@ data class ReadAloudPlayerSourceState(
     val isPaused: Boolean,
     val speed: Int,
     val timerMinutes: Int,
+    val timerMode: String,
+    val timerChapters: Int,
     val finishCurrentChapterAfterTimer: Boolean,
 )
 

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

@@ -0,0 +1,58 @@
+package io.legado.app.ui.book.readaloud.player
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.res.stringResource
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.ui.book.read.sheet.ReadAloudConfigContent
+import io.legado.app.ui.book.read.sheet.asReadBookUiState
+import io.legado.app.ui.theme.ProvideThemeOverride
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import org.koin.compose.koinInject
+
+/**
+ * 听书播放界面(Navigation 3 目的地)。
+ *
+ * 它是一层普通全屏目的地:整页纵向进出动画完全由 nav3 的
+ * `NavDisplay.TransitionKey` / `PopTransitionKey` / `PredictivePopTransitionKey` 提供
+ * (见 `MainNavGraph.readAloudPlayerEntryMetadata`)。因此这里不需要 `ModalBottomSheet`
+ * 这类窗口级 sheet 容器,也不自己驱动位移,没有「弹层里再开弹层」的
+ * shape / 宽度 / 返回键特判;代价是没有下拉关闭手势。
+ *
+ * 设置卡片用的是全局 [ReadAloudPlayerViewModel] 的设置快照,不依赖阅读器 ViewModel,
+ * 所以从胶囊直接进听书页时同样能改朗读设置。
+ */
+@Composable
+fun ReadAloudPlayerRouteScreen(
+    showReadAloudConfig: Boolean,
+    onReadAloudConfigVisibleChange: (Boolean) -> Unit,
+    onBack: () -> Unit,
+) {
+    val playerViewModel: ReadAloudPlayerViewModel = koinInject()
+    val playerState by playerViewModel.uiState.collectAsStateWithLifecycle()
+    val settingsState by playerViewModel.readAloudSettings.collectAsStateWithLifecycle()
+    val playerTheme = rememberPlayerThemeOverride(playerState)
+
+    ProvideThemeOverride(playerTheme) {
+        ReadAloudPlayerScreenContent(
+            state = playerState,
+            onIntent = playerViewModel::onIntent,
+            onBack = onBack,
+            onOpenConfig = { onReadAloudConfigVisibleChange(true) },
+        )
+    }
+    // 播放页自己是一层全屏目的地,这里只叠一层设置卡片,全屏只有这一层 scrim。
+    AppModalBottomSheet(
+        show = showReadAloudConfig,
+        onDismissRequest = { onReadAloudConfigVisibleChange(false) },
+        title = stringResource(R.string.aloud_config),
+    ) {
+        ReadAloudConfigContent(
+            state = settingsState.asReadBookUiState(),
+            playerState = playerState,
+            onIntent = playerViewModel::applyReadBookConfigIntent,
+            onPlayerIntent = playerViewModel::onIntent,
+        )
+    }
+}

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

@@ -77,6 +77,9 @@ import dev.chrisbanes.haze.hazeSource
 import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
 import io.legado.app.R
 import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
+import io.legado.app.ui.book.readaloud.ReadAloudTimerConfig
+import io.legado.app.ui.book.readaloud.ReadAloudTimerSheet
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
 import io.legado.app.ui.util.rememberBlurBackdrop
@@ -93,7 +96,6 @@ import io.legado.app.ui.widget.components.player.PlayerBackground
 import io.legado.app.ui.widget.components.player.PlayerProgressSlider
 import io.legado.app.ui.widget.components.player.PlayerTocPage
 import io.legado.app.ui.widget.components.player.playerBgModeLabel
-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
@@ -114,6 +116,7 @@ fun ReadAloudPlayerScreenContent(
     state: ReadAloudPlayerUiState,
     onIntent: (ReadAloudPlayerIntent) -> Unit,
     onBack: () -> Unit,
+    onOpenConfig: () -> Unit,
 ) {
     val horizontalPagerState = rememberPagerState(initialPage = 1, pageCount = { 3 })
     var isTextPageUserScrolling by remember { mutableStateOf(false) }
@@ -192,7 +195,7 @@ fun ReadAloudPlayerScreenContent(
                         )
                     }
                     MediumTonalButton(
-                        onClick = { onIntent(ReadAloudPlayerIntent.OpenSettings) },
+                        onClick = onOpenConfig,
                         icon = Icons.Default.Settings,
                         contentDescription = stringResource(R.string.setting),
                     )
@@ -392,9 +395,21 @@ fun ReadAloudPlayerScreenContent(
     )
     ReadAloudTimerSheet(
         show = state.activeSheet == ReadAloudPlayerSheet.Timer,
-        state = state,
+        config = ReadAloudTimerConfig(
+            mode = ReadAloudTimerMode.fromStorage(state.timerMode),
+            minutes = state.timerMinutes,
+            chapters = state.timerChapters,
+            finishCurrentChapterAfterTimer = state.finishCurrentChapterAfterTimer,
+        ),
         onDismissRequest = { onIntent(ReadAloudPlayerIntent.DismissSheet) },
-        onIntent = onIntent,
+        onSetMode = { mode ->
+            onIntent(ReadAloudPlayerIntent.SetTimerMode(mode.storageValue))
+        },
+        onSetMinutes = { onIntent(ReadAloudPlayerIntent.SetTimer(it)) },
+        onSetChapters = { onIntent(ReadAloudPlayerIntent.SetTimerChapters(it)) },
+        onSetFinishCurrentChapterAfterTimer = {
+            onIntent(ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer(it))
+        },
     )
 }
 
@@ -650,51 +665,6 @@ private fun ReadAloudSpeedSheet(
     }
 }
 
-@Composable
-private fun ReadAloudTimerSheet(
-    show: Boolean,
-    state: ReadAloudPlayerUiState,
-    onDismissRequest: () -> Unit,
-    onIntent: (ReadAloudPlayerIntent) -> Unit,
-) {
-    var timerPreview by remember(state.timerMinutes) {
-        mutableFloatStateOf(state.timerMinutes.toFloat())
-    }
-    AppModalBottomSheet(
-        show = show,
-        onDismissRequest = onDismissRequest,
-        title = stringResource(R.string.set_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,
-        )
-        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(vertical = 4.dp),
-            color = LegadoTheme.colorScheme.surfaceContainerHigh,
-            onCheckedChange = {
-                onIntent(ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer(it))
-            },
-        )
-    }
-}
-
 @Composable
 private fun formatPosition(value: Int): String = if (value < 1000) {
     stringResource(R.string.read_aloud_position_chars, value)

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

@@ -4,8 +4,16 @@ import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.gateway.ReadSettingsGateway
+import io.legado.app.domain.model.readaloud.ReadAloudContentSplitSetting
+import io.legado.app.domain.model.readaloud.ReadAloudSplitSymbol
+import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
+import io.legado.app.domain.model.settings.ReadSettings
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.help.config.compatDsInt
+import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.widget.components.player.PlayerChapterUi
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -18,10 +26,32 @@ import kotlinx.coroutines.launch
 
 class ReadAloudPlayerViewModel(
     private val coordinator: ReadAloudPlayerCoordinator,
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val readSettingsGateway: ReadSettingsGateway,
 ) : ViewModel() {
 
     private val activeSheet = MutableStateFlow<ReadAloudPlayerSheet?>(null)
 
+    /**
+     * 朗读设置快照。
+     *
+     * 听书播放界面是独立目的地,不依赖阅读器 ViewModel,所以设置直接从全局设置源投影;
+     * 阅读界面里的配置卡片仍用 `ReadBookUiState`(内容相同,只是宿主不同)。
+     */
+    val readAloudSettings = combine(
+        readAloudSettingsGateway.settings,
+        readSettingsGateway.settings,
+    ) { aloud, read ->
+        toReadAloudSettingsUiState(aloud, read)
+    }.stateIn(
+        scope = viewModelScope,
+        started = SharingStarted.WhileSubscribed(5000),
+        initialValue = toReadAloudSettingsUiState(
+            aloud = readAloudSettingsGateway.currentSettings,
+            read = readSettingsGateway.currentSettings,
+        ),
+    )
+
     val uiState = combine(
         coordinator.state,
         AppConfigStore.observeInt(PreferKey.readAloudPlayerBgMode),
@@ -41,12 +71,13 @@ class ReadAloudPlayerViewModel(
         when (intent) {
             ReadAloudPlayerIntent.Refresh -> coordinator.refresh()
             ReadAloudPlayerIntent.TogglePause -> coordinator.togglePause()
+            ReadAloudPlayerIntent.StopReadAloud -> coordinator.stop()
             ReadAloudPlayerIntent.PreviousParagraph -> coordinator.previousParagraph()
             ReadAloudPlayerIntent.NextParagraph -> coordinator.nextParagraph()
             ReadAloudPlayerIntent.PreviousChapter -> coordinator.previousChapter()
             ReadAloudPlayerIntent.NextChapter -> coordinator.nextChapter()
-            ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
+            ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.CycleBgMode -> cycleBgMode()
             is ReadAloudPlayerIntent.SelectChapter -> coordinator.selectChapter(intent.index)
             is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
@@ -59,6 +90,13 @@ class ReadAloudPlayerViewModel(
             is ReadAloudPlayerIntent.SetTimer -> viewModelScope.launch {
                 coordinator.setTimer(intent.minutes)
             }
+            is ReadAloudPlayerIntent.SetTimerMode -> viewModelScope.launch {
+                coordinator.setTimerMode(ReadAloudTimerMode.fromStorage(intent.value))
+            }
+
+            is ReadAloudPlayerIntent.SetTimerChapters -> viewModelScope.launch {
+                coordinator.setTimerChapters(intent.value)
+            }
             is ReadAloudPlayerIntent.SetFinishCurrentChapterAfterTimer ->
                 viewModelScope.launch {
                     coordinator.setFinishCurrentChapterAfterTimer(intent.value)
@@ -72,6 +110,90 @@ class ReadAloudPlayerViewModel(
         }
     }
 
+    /** 听书页配置卡片的设置写入;与阅读界面共用同一份设置语义。 */
+    fun onConfigIntent(
+        option: ReadAloudConfigOption,
+        value: String = "",
+        selected: Boolean = false,
+        intValue: Int = 0,
+    ) {
+        viewModelScope.launch {
+            when (option) {
+                ReadAloudConfigOption.DefaultInterface ->
+                    readAloudSettingsGateway.update { it.copy(defaultInterface = value) }
+
+                ReadAloudConfigOption.ShowCapsule ->
+                    readAloudSettingsGateway.update { it.copy(showReadAloudCapsule = selected) }
+
+                ReadAloudConfigOption.CapsuleAutoCollapse ->
+                    readAloudSettingsGateway.update { it.copy(capsuleAutoCollapse = selected) }
+
+                ReadAloudConfigOption.IgnoreAudioFocus ->
+                    readAloudSettingsGateway.update { it.copy(ignoreAudioFocus = selected) }
+
+                ReadAloudConfigOption.PauseOnPhoneCall -> readAloudSettingsGateway.update {
+                    it.copy(pauseReadAloudWhilePhoneCalls = selected)
+                }
+
+                ReadAloudConfigOption.WakeLock ->
+                    readAloudSettingsGateway.update { it.copy(readAloudWakeLock = selected) }
+
+                ReadAloudConfigOption.KeepOnExit ->
+                    readAloudSettingsGateway.update { it.copy(keepReadAloudOnExit = selected) }
+
+                ReadAloudConfigOption.MediaButtonPerNext ->
+                    readAloudSettingsGateway.update { it.copy(mediaButtonPerNext = selected) }
+
+                ReadAloudConfigOption.AndroidMediaControl -> readAloudSettingsGateway.update {
+                    it.copy(androidMediaControlEnabled = selected)
+                }
+
+                ReadAloudConfigOption.SystemMediaCompat -> readAloudSettingsGateway.update {
+                    it.copy(systemMediaControlCompatibilityChange = selected)
+                }
+
+                ReadAloudConfigOption.StreamAudio ->
+                    readAloudSettingsGateway.update { it.copy(streamReadAloudAudio = selected) }
+
+                ReadAloudConfigOption.SpeechAnalysisMode ->
+                    readAloudSettingsGateway.update { it.copy(speechAnalysisMode = value) }
+
+                ReadAloudConfigOption.SpeechAnalysisReasoningLevel ->
+                    readAloudSettingsGateway.update {
+                        it.copy(speechAnalysisReasoningLevel = value)
+                    }
+
+                ReadAloudConfigOption.UseMultiSpeaker ->
+                    readAloudSettingsGateway.update { it.copy(useMultiSpeaker = selected) }
+
+                ReadAloudConfigOption.ContentSplit -> {
+                    val (mode, symbols) = ReadAloudContentSplitSetting.decode(value)
+                    readAloudSettingsGateway.update {
+                        it.copy(
+                            contentSplitMode = mode.storageValue,
+                            contentSplitSymbols = ReadAloudSplitSymbol.storageValues(symbols),
+                        )
+                    }
+                }
+
+                ReadAloudConfigOption.PreDownloadNum ->
+                    readSettingsGateway.update { it.copy(preDownloadNum = intValue) }
+
+                ReadAloudConfigOption.PreSynthesisConcurrency -> readAloudSettingsGateway.update {
+                    it.copy(ttsPreSynthesisConcurrency = intValue.coerceIn(1, 8))
+                }
+
+                ReadAloudConfigOption.ParagraphInterval -> readAloudSettingsGateway.update {
+                    it.copy(ttsParagraphInterval = intValue)
+                }
+
+                ReadAloudConfigOption.AudioCacheCleanTime -> readAloudSettingsGateway.update {
+                    it.copy(audioCacheCleanTime = intValue)
+                }
+            }
+        }
+    }
+
     private fun cycleBgMode() {
         val next = when (readBgMode()) {
             ReadAloudBgMode.Solid -> ReadAloudBgMode.Blur
@@ -117,8 +239,11 @@ class ReadAloudPlayerViewModel(
             engineName = source.engineName,
             speakerName = source.speakerName,
             isPaused = source.isPaused,
+            readAloudRunning = BaseReadAloudService.isRun,
             speed = source.speed,
             timerMinutes = source.timerMinutes,
+            timerMode = source.timerMode,
+            timerChapters = source.timerChapters,
             finishCurrentChapterAfterTimer = source.finishCurrentChapterAfterTimer,
             bgMode = bgMode,
             activeSheet = sheet,
@@ -135,3 +260,29 @@ class ReadAloudPlayerViewModel(
     }
 
 }
+
+private fun toReadAloudSettingsUiState(
+    aloud: ReadAloudSettings,
+    read: ReadSettings,
+): ReadAloudSettingsUiState = ReadAloudSettingsUiState(
+    defaultReadAloudInterface = aloud.defaultInterface,
+    showReadAloudCapsule = aloud.showReadAloudCapsule,
+    capsuleAutoCollapse = aloud.capsuleAutoCollapse,
+    readAloudIgnoreAudioFocus = aloud.ignoreAudioFocus,
+    readAloudPauseOnPhoneCall = aloud.pauseReadAloudWhilePhoneCalls,
+    readAloudWakeLock = aloud.readAloudWakeLock,
+    readAloudKeepOnExit = aloud.keepReadAloudOnExit,
+    readAloudMediaButtonPerNext = aloud.mediaButtonPerNext,
+    readAloudAndroidMediaControl = aloud.androidMediaControlEnabled,
+    readAloudSystemMediaCompat = aloud.systemMediaControlCompatibilityChange,
+    readAloudStreamAudio = aloud.streamReadAloudAudio,
+    speechAnalysisMode = aloud.speechAnalysisMode,
+    speechAnalysisReasoningLevel = aloud.speechAnalysisReasoningLevel,
+    useMultiSpeaker = aloud.useMultiSpeaker,
+    readAloudContentSplitMode = aloud.contentSplitMode,
+    readAloudContentSplitSymbols = aloud.contentSplitSymbols,
+    preDownloadNum = read.preDownloadNum,
+    preSynthesisConcurrency = aloud.ttsPreSynthesisConcurrency,
+    readAloudParagraphInterval = aloud.ttsParagraphInterval,
+    audioCacheCleanTime = aloud.audioCacheCleanTime,
+)

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

@@ -0,0 +1,57 @@
+package io.legado.app.ui.book.readaloud.player
+
+import androidx.compose.runtime.Stable
+
+/**
+ * 朗读设置里可在界面直接修改的选项。
+ *
+ * 听书播放界面与阅读界面各自有独立的设置宿主(前者用全局朗读设置,后者用阅读器状态),
+ * 但设置内容完全一样。这里把「改哪一项」收敛成一份枚举,让两个宿主共用同一份
+ * `ReadAloudConfigContent`,避免两套几乎相同的表单各自漂移。
+ */
+enum class ReadAloudConfigOption {
+    DefaultInterface,
+    ShowCapsule,
+    CapsuleAutoCollapse,
+    IgnoreAudioFocus,
+    PauseOnPhoneCall,
+    WakeLock,
+    KeepOnExit,
+    MediaButtonPerNext,
+    AndroidMediaControl,
+    SystemMediaCompat,
+    StreamAudio,
+    SpeechAnalysisMode,
+    SpeechAnalysisReasoningLevel,
+    UseMultiSpeaker,
+    ContentSplit,
+    PreDownloadNum,
+    PreSynthesisConcurrency,
+    ParagraphInterval,
+    AudioCacheCleanTime,
+}
+
+/** 朗读设置界面需要的只读快照。 */
+@Stable
+data class ReadAloudSettingsUiState(
+    val defaultReadAloudInterface: String = "classic",
+    val showReadAloudCapsule: Boolean = true,
+    val capsuleAutoCollapse: Boolean = true,
+    val readAloudIgnoreAudioFocus: Boolean = false,
+    val readAloudPauseOnPhoneCall: Boolean = false,
+    val readAloudWakeLock: Boolean = false,
+    val readAloudKeepOnExit: Boolean = false,
+    val readAloudMediaButtonPerNext: Boolean = false,
+    val readAloudAndroidMediaControl: Boolean = false,
+    val readAloudSystemMediaCompat: Boolean = true,
+    val readAloudStreamAudio: Boolean = false,
+    val speechAnalysisMode: String = "rule",
+    val speechAnalysisReasoningLevel: String = "",
+    val useMultiSpeaker: Boolean = true,
+    val readAloudContentSplitMode: String = "default",
+    val readAloudContentSplitSymbols: Set<String> = emptySet(),
+    val preDownloadNum: Int = 10,
+    val preSynthesisConcurrency: Int = 3,
+    val readAloudParagraphInterval: Int = 0,
+    val audioCacheCleanTime: Int = 10,
+)

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

@@ -88,6 +88,8 @@ object ReadConfig {
     val ttsSpeechRate get() = aloud.ttsSpeechRate
     val ttsTimer get() = aloud.ttsTimer
     val finishCurrentChapterAfterTimer get() = aloud.finishCurrentChapterAfterTimer
+    val readAloudTimerMode get() = aloud.timerMode
+    val readAloudTimerChapters get() = aloud.timerChapters
     val ttsParagraphInterval get() = aloud.ttsParagraphInterval
     val ignoreAudioFocus get() = aloud.ignoreAudioFocus
     val pauseReadAloudWhilePhoneCalls get() = aloud.pauseReadAloudWhilePhoneCalls

+ 53 - 6
app/src/main/java/io/legado/app/ui/main/MainActivity.kt

@@ -19,12 +19,16 @@ import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
 import androidx.compose.animation.scaleOut
 import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.SideEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
 import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import androidx.lifecycle.lifecycleScope
@@ -38,6 +42,7 @@ import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.constant.AppConst.appInfo
+import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.MangaSettingsGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
@@ -55,6 +60,8 @@ import io.legado.app.ui.book.audio.AudioPlayViewModel
 import io.legado.app.ui.book.read.ReadBookInputHandler
 import io.legado.app.ui.book.read.ReadBookRouteHost
 import io.legado.app.ui.book.read.page.entities.PageDirection
+import io.legado.app.ui.book.readaloud.ReadAloudShellHost
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
 import io.legado.app.ui.theme.LocalAppUiConfiguration
 import io.legado.app.ui.welcome.WelcomeActivity
 import io.legado.app.utils.LogUtils
@@ -285,10 +292,11 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
     private val otherSettingsGateway by inject<OtherSettingsGateway>()
     private val mangaSettingsGateway by inject<MangaSettingsGateway>()
     private val backupSettingsGateway by inject<BackupSettingsGateway>()
+    private val readAloudSettingsRepository by inject<ReadAloudSettingsRepository>()
+    private val navRouteTracker by inject<MainNavRouteTracker>()
     private val routeEvents = MutableSharedFlow<RouteEvent>(extraBufferCapacity = 1)
     private var shouldApplyDefaultToRead = true
     private var restoredReadBookRoute: MainRouteReadBook? = null
-    private var latestBackStack: List<NavKey> = emptyList()
     internal var activeReadBookInputHandler: ReadBookInputHandler? = null
     internal var activeReadBookRoute: MainRouteReadBook? = null
     internal var activeMangaKeyHandler: ((Int) -> Boolean)? = null
@@ -391,8 +399,17 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                 }
             }
         }
-        latestBackStack = startRoutes.toList()
         val backStack = rememberNavBackStack(*startRoutes)
+        SideEffect { navRouteTracker.onBackStackChanged(backStack) }
+
+        // 悬浮胶囊是全局叠层:数据来自全局朗读会话与设置,不依赖阅读器是否在栈上。
+        val pageShellPlayerViewModel: ReadAloudPlayerViewModel =
+            org.koin.compose.koinInject()
+        val pageShellPlayerState by pageShellPlayerViewModel.uiState.collectAsStateWithLifecycle()
+        val pageShellAloudSettings by pageShellPlayerViewModel.readAloudSettings
+            .collectAsStateWithLifecycle()
+        val pageShellShowCapsule = pageShellAloudSettings.showReadAloudCapsule
+        val pageShellCapsuleScope = rememberCoroutineScope()
 
         SideEffect {
             shouldApplyDefaultToRead = false
@@ -403,6 +420,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                 MainNavigator.navigateToRoute(
                     backStack = backStack,
                     route = event.route,
+                    tracker = navRouteTracker,
                     resetToHome = event.resetToHome,
                 )
             }
@@ -411,12 +429,17 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
         LaunchedEffect(backStack) {
             snapshotFlow { backStack.toList() }
                 .collect {
-                    latestBackStack = it
+                    // 兜底同步:预测性返回、系统返回手势等不经过 navigateToRoute/navigateBack 的路径
+                    navRouteTracker.onBackStackChanged(it)
                     MainNavigator.onBackStackChanged()
                 }
         }
+        // 全局朗读胶囊据此判断听书页是否在最上层
+        val navBackStack by navRouteTracker.backStack.collectAsStateWithLifecycle()
+        val currentRoute = navBackStack.lastOrNull()
 
         SharedTransitionLayout {
+            Box(modifier = Modifier.fillMaxSize()) {
             NavDisplay(
                 backStack = backStack,
                 entryDecorators = listOf(
@@ -483,12 +506,36 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
                     onNavigateToRoute = { route ->
                         MainNavigator.navigateToRoute(
                             backStack,
-                            route
+                            route,
+                            navRouteTracker,
                         )
                     },
-                    onNavigateBack = { MainNavigator.navigateBack(this@MainActivity, backStack) },
+                    onNavigateBack = {
+                        MainNavigator.navigateBack(this@MainActivity, backStack, navRouteTracker)
+                    },
                 )
             )
+                // 朗读悬浮胶囊叠在整个导航之上:阅读器只是其中一个目的地,
+                // 挂在阅读器里会导致离开阅读界面后胶囊消失。
+                ReadAloudShellHost(
+                    playerState = pageShellPlayerState,
+                    showCapsule = pageShellShowCapsule,
+                    hidden = currentRoute is MainRouteReadAloudPlayer,
+                    onIntent = pageShellPlayerViewModel::onIntent,
+                    onCapsulePositionChanged = { x, y ->
+                        pageShellCapsuleScope.launch {
+                            readAloudSettingsRepository.putCapsulePosition(x, y)
+                        }
+                    },
+                    onOpenPlayer = {
+                        MainNavigator.navigateToRoute(
+                            backStack,
+                            MainRouteReadAloudPlayer,
+                            navRouteTracker
+                        )
+                    },
+            )
+            }
             BackHandler(
                 enabled = !configuration.appShell.predictiveBackEnabled
             ) {
@@ -594,7 +641,7 @@ open class MainActivity : BaseComposeActivity(), AudioPlay.CallBack {
         if (otherSettingsGateway.currentSettings.autoRefresh) {
             outState.putBoolean("isAutoRefreshedBook", true)
         }
-        val readRoute = latestBackStack.lastOrNull() as? MainRouteReadBook
+        val readRoute = navRouteTracker.backStack.value.lastOrNull() as? MainRouteReadBook
             ?: activeReadBookRoute
         if (readRoute != null) {
             outState.putBoolean(KEY_RESTORE_READ_ROUTE, true)

+ 79 - 0
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -7,12 +7,16 @@ import androidx.activity.compose.BackHandler
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
 import androidx.compose.animation.AnimatedContentTransitionScope
+import androidx.compose.animation.EnterTransition
+import androidx.compose.animation.ExitTransition
 import androidx.compose.animation.ExperimentalSharedTransitionApi
 import androidx.compose.animation.SharedTransitionScope
 import androidx.compose.animation.core.FastOutSlowInEasing
 import androidx.compose.animation.core.tween
 import androidx.compose.animation.fadeIn
 import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
 import androidx.compose.animation.togetherWith
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
@@ -42,8 +46,10 @@ import io.legado.app.domain.model.settings.AppUiConfiguration
 import io.legado.app.help.coil.CoverExtras
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.Download
+import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.SourceCallBack
 import io.legado.app.service.AudioPlayService
+import io.legado.app.service.BaseReadAloudService
 import io.legado.app.ui.about.AboutEffect
 import io.legado.app.ui.about.AboutScreen
 import io.legado.app.ui.about.AboutViewModel
@@ -96,6 +102,7 @@ import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsEffect
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsIntent
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsScreen
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerRouteScreen
 import io.legado.app.ui.book.search.SearchIntent
 import io.legado.app.ui.book.search.SearchRouteScreen
 import io.legado.app.ui.book.search.SearchViewModel
@@ -153,6 +160,8 @@ import io.legado.app.utils.toggleSystemBar
 import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import org.koin.androidx.compose.koinViewModel
@@ -206,6 +215,43 @@ private fun webViewEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
     }
 }
 
+/**
+ * 听书播放页的转场:整页纵向位移,无淡入淡出。
+ *
+ * 进入自下而上滑入、退出从上往下滑出,上一站在进出期间保持不动
+ * (`EnterTransition.None` / `ExitTransition.None`),所以视觉上是「盖上来 / 收下去」
+ * 而不是交叉替换。
+ *
+ * 三个 key 都要覆写:只写 `TransitionKey` / `PopTransitionKey` 时,开启预测性返回的设备
+ * 走系统返回手势会落回 `NavDisplay` 默认的横向转场 —— 这正是「退出时还在走默认动画」的原因
+ * (与 [webViewEntryMetadata] 同款,它同样覆写三个 key)。
+ */
+private fun readAloudPlayerEntryMetadata(predictiveBackEnabled: Boolean) = metadata {
+    put(NavDisplay.TransitionKey) {
+        slideInVertically(
+            animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
+            initialOffsetY = { fullHeight -> fullHeight },
+        ) togetherWith ExitTransition.None
+    }
+    put(NavDisplay.PopTransitionKey) {
+        EnterTransition.None togetherWith
+                slideOutVertically(
+                    animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
+                    targetOffsetY = { fullHeight -> fullHeight },
+                )
+    }
+    if (predictiveBackEnabled) {
+        // 与 PopTransitionKey 同一套位移:系统返回手势不再跟手,只播这段固定时长动画。
+        put(NavDisplay.PredictivePopTransitionKey) { _ ->
+            EnterTransition.None togetherWith
+                    slideOutVertically(
+                        animationSpec = tween(durationMillis = 360, easing = FastOutSlowInEasing),
+                        targetOffsetY = { fullHeight -> fullHeight },
+                    )
+        }
+    }
+}
+
 @OptIn(ExperimentalSharedTransitionApi::class)
 fun MainActivity.mainEntryProvider(
     backStack: MutableList<NavKey>,
@@ -696,11 +742,26 @@ fun MainActivity.mainEntryProvider(
         val effectsReady = remember(readBookViewModel) { CompletableDeferred<Unit>() }
         val readerResumeState = remember(controller, lifecycleOwner) { booleanArrayOf(false) }
         val collectorReady = remember(readBookViewModel) { booleanArrayOf(false) }
+        // 是否跟随朗读位置:用户手动翻页/跳章后为 false,此时回阅读界面不回拉可见页。
+        val readAloudSessionStore: ReadAloudSessionStore = org.koin.compose.koinInject()
+        val readAloudFollow = remember(readBookViewModel) { booleanArrayOf(true) }
+        LaunchedEffect(readAloudSessionStore) {
+            readAloudSessionStore.state
+                .map { it.followReadAloudPosition }
+                .distinctUntilChanged()
+                .collect { readAloudFollow[0] = it }
+        }
         fun resumeReader() {
             if (readerResumeState[0]) return
             readerResumeState[0] = true
             controller.onResume()
             readBookViewModel.onIntent(ReadBookIntent.OnResume)
+            // 回到阅读界面时把可见页对齐到当前朗读位置:朗读期间用户可能进听书页跳段/跳章,
+            // 服务驱动的是内部页游标,可见页需要显式回位,否则停在离开时那一页。
+            // 只在跟随状态下做(用户手动翻页脱离后不回拉),语义与「回到朗读位置」一致。
+            if (BaseReadAloudService.isRun && readAloudFollow[0]) {
+                readBookViewModel.onIntent(ReadBookIntent.BackToSpeakingPosition)
+            }
         }
 
         fun pauseReader() {
@@ -738,6 +799,11 @@ fun MainActivity.mainEntryProvider(
             onOpenTtsCache = {
                 onNavigateToRoute(MainRouteTtsCache)
             },
+            // 经典控制面板的「切换到听书播放器」:目的地是 nav3 路由,
+            // 必须在这里接上,否则意图只发到效果层、没人导航。
+            onOpenReadAloudPlayer = {
+                onNavigateToRoute(MainRouteReadAloudPlayer)
+            },
         )
 
         DisposableEffect(controller, lifecycleOwner, route.readAloud) {
@@ -1360,6 +1426,19 @@ fun MainActivity.mainEntryProvider(
         )
     }
 
+    entry<MainRouteReadAloudPlayer>(
+        metadata = readAloudPlayerEntryMetadata(configuration.appShell.predictiveBackEnabled)
+    ) {
+        // 听书播放界面独立于阅读器:从胶囊或媒体按键打开时,阅读器可能根本不在栈上,
+        // 因此这里不复用阅读器的状态宿主,只依赖全局朗读会话状态。
+        var readAloudConfigOpen by rememberSaveable { mutableStateOf(false) }
+        ReadAloudPlayerRouteScreen(
+            showReadAloudConfig = readAloudConfigOpen,
+            onReadAloudConfigVisibleChange = { readAloudConfigOpen = it },
+            onBack = { onNavigateBack() },
+        )
+    }
+
     entry<MainRouteBookKnowledgeList> { route ->
         val viewModel = koinViewModel<BookKnowledgeListViewModel>(
             key = "KnowledgeList:${route.bookUrl}",

+ 7 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -150,6 +150,13 @@ data class MainRouteAudioPlay(
     val inBookshelf: Boolean = true,
 ) : MainRoute
 
+/**
+ * 听书播放界面。单例语义:可从阅读界面、悬浮胶囊或媒体按键在任意界面之上打开,
+ * 重复进入只替换不叠加。
+ */
+@Serializable
+data object MainRouteReadAloudPlayer : MainRoute
+
 @Serializable
 data class MainRouteSearch(
     val key: String?,

+ 31 - 0
app/src/main/java/io/legado/app/ui/main/MainNavRouteTracker.kt

@@ -0,0 +1,31 @@
+package io.legado.app.ui.main
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+
+/**
+ * 导航栈的进程内快照(完整栈 + 栈顶路由)。
+ *
+ * 它存在的原因:`MainActivity` 里的 back stack 是 `rememberNavBackStack` 建的可变列表,
+ * 本身不是 Compose 状态。任何**叠在导航之外**的宿主(最典型的是全局朗读胶囊,它是 Activity
+ * 级叠层)想按「现在显示的是哪个界面」改变行为,就必须有一个可观察的来源,否则只能读到
+ * 某个普通 `var` 的陈旧值 —— 表现为「进了听书页胶囊还盖在上面,等下一次朗读进度事件才消失」。
+ *
+ * 已有 [MainNavigator.navigateToRoute] / [MainNavigator.navigateBack] 会写入;不经过它们的
+ * 路径(预测性返回、系统返回手势、初始路由)由 Activity 侧兜底同步。
+ */
+class MainNavRouteTracker {
+
+    private val _backStack = MutableStateFlow<List<NavKey>>(emptyList())
+
+    /** 完整导航栈;栈顶即当前显示的界面。 */
+    val backStack: StateFlow<List<NavKey>> = _backStack.asStateFlow()
+
+    val currentRoute: NavKey? get() = _backStack.value.lastOrNull()
+
+    fun onBackStackChanged(backStack: List<NavKey>) {
+        _backStack.value = backStack.toList()
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -26,6 +26,28 @@ object MainNavigator {
         backStack: MutableList<NavKey>,
         route: NavKey,
         resetToHome: Boolean = false,
+    ) {
+        navigateToRoute(backStack, route, null, resetToHome)
+    }
+
+    /**
+     * [tracker] 非空时在同一调用里记录栈顶路由:Activity 级叠层(全局朗读胶囊)据此
+     * 在导航发生的那一刻就重算显隐,而不是等 back stack 快照回灌后才被动刷新。
+     */
+    fun navigateToRoute(
+        backStack: MutableList<NavKey>,
+        route: NavKey,
+        tracker: MainNavRouteTracker?,
+        resetToHome: Boolean = false,
+    ) {
+        navigateToRouteInternal(backStack, route, tracker, resetToHome)
+    }
+
+    private fun navigateToRouteInternal(
+        backStack: MutableList<NavKey>,
+        route: NavKey,
+        tracker: MainNavRouteTracker?,
+        resetToHome: Boolean,
     ) {
         if (resetToHome) {
             backStack.clear()
@@ -153,6 +175,18 @@ object MainNavigator {
                 backStack.add(route)
             }
 
+            MainRouteReadAloudPlayer -> {
+                // 单例语义:已在栈上则回到那一层,避免重复按媒体键叠出多个播放界面
+                val existingPlayerIndex = backStack.indexOfLast { it is MainRouteReadAloudPlayer }
+                if (existingPlayerIndex >= 0) {
+                    while (backStack.lastIndex > existingPlayerIndex) {
+                        backStack.removeAt(backStack.lastIndex)
+                    }
+                } else {
+                    backStack.add(route)
+                }
+            }
+
             is MainRouteSearch -> {
                 if (
                     currentRoute == MainRouteHome ||
@@ -304,15 +338,27 @@ object MainNavigator {
                 }
             }
         }
+        // 同步栈快照:Activity 级叠层(全局朗读胶囊)据此立刻重算显隐,不必等 back stack 回灌
+        tracker?.onBackStackChanged(backStack)
     }
 
     fun navigateBack(activity: Activity, backStack: MutableList<NavKey>) {
+        navigateBack(activity, backStack, null)
+    }
+
+    /** [tracker] 非空时同步栈顶快照,供 Activity 级叠层立即重算显隐。 */
+    fun navigateBack(
+        activity: Activity,
+        backStack: MutableList<NavKey>,
+        tracker: MainNavRouteTracker?,
+    ) {
         if (backNavigationInProgress) {
             return
         }
         if (backStack.size > 1) {
             backNavigationInProgress = true
             backStack.removeLastOrNull()
+            tracker?.onBackStackChanged(backStack)
         } else {
             activity.finish()
         }

+ 9 - 0
app/src/main/java/io/legado/app/ui/widget/components/settingItem/TinySettingItems.kt

@@ -293,6 +293,14 @@ fun TinySliderSettingItem(
     showDecimal: Boolean = false,
     valueFormat: ((Float) -> String)? = null,
     onValueChange: (Float) -> Unit,
+    /**
+     * 拖动结束(或步进按钮点完)后回调。
+     *
+     * 需要「拖动中只更新本地预览、松手才写设置」的场景必须用这个而不是 [onValueChange]:
+     * 拖动中每帧写设置会触发外部 [value] 回流,与本组件内部的 `LaunchedEffect(value)` 同步
+     * 打架,表现为滑块来回跳。
+     */
+    onValueChangeFinished: () -> Unit = {},
 ) {
     var expanded by remember { mutableStateOf(false) }
     var sliderValue by remember(value) { mutableFloatStateOf(value) }
@@ -331,6 +339,7 @@ fun TinySliderSettingItem(
                 },
                 onValueChangeFinished = {
                     dragState?.stopDragging()
+                    onValueChangeFinished()
                 },
                 valueRange = valueRange,
                 steps = steps,

+ 9 - 2
app/src/main/res/values-zh-rCN/strings.xml

@@ -741,6 +741,13 @@
     <string name="reading">阅读</string>
     <string name="battery_show">%d%%</string>
     <string name="timer_m">%d 分钟</string>
+    <string name="timer_chapters">%d 章</string>
+    <string name="finish_current_chapter_after_timer">定时到点后读完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定时结束后继续朗读,读完当前章节再停止</string>
+    <string name="read_aloud_timer_mode">定时方式</string>
+    <string name="read_aloud_timer_by_minute">按时间</string>
+    <string name="read_aloud_timer_by_chapter">按章节</string>
+    <string name="read_aloud_timer_chapter_summary">读满所选章数后,在章节末尾停止</string>
     <string name="brightness_auto">自动亮度 %s</string>
     <string name="read_aloud_by_page">按页朗读</string>
     <string name="speak_engine">朗读引擎</string>
@@ -1682,8 +1689,6 @@
     <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>
@@ -2873,6 +2878,8 @@
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">显示朗读胶囊</string>
     <string name="show_read_aloud_capsule_summary">朗读时在阅读页显示封面、暂停和关闭按钮</string>
+    <string name="read_aloud_keep_on_exit">退出阅读时继续朗读</string>
+    <string name="read_aloud_keep_on_exit_summary">关闭阅读界面后不停朗读,继续在后台播放</string>
     <string name="reset_read_aloud_capsule_position">重置朗读胶囊位置</string>
     <string name="reset_read_aloud_capsule_position_summary">将朗读胶囊移回默认位置</string>
     <string name="pause_read_aloud">暂停朗读</string>

+ 9 - 2
app/src/main/res/values-zh-rHK/strings.xml

@@ -668,6 +668,13 @@
     <string name="reading">閲讀</string>
     <string name="battery_show">%d%%</string>
     <string name="timer_m">%d 分鐘</string>
+    <string name="timer_chapters">%d 章</string>
+    <string name="finish_current_chapter_after_timer">定時到點後讀完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定時結束後繼續朗讀,讀完目前章節再停止</string>
+    <string name="read_aloud_timer_mode">定時方式</string>
+    <string name="read_aloud_timer_by_minute">按時間</string>
+    <string name="read_aloud_timer_by_chapter">按章節</string>
+    <string name="read_aloud_timer_chapter_summary">讀滿所選章數後,在章節末尾停止</string>
     <string name="brightness_auto">自動亮度 %s</string>
     <string name="read_aloud_by_page">按頁朗讀</string>
     <string name="speak_engine">朗讀引擎</string>
@@ -2291,8 +2298,6 @@
     <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>
@@ -2656,6 +2661,8 @@
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">顯示朗讀膠囊</string>
     <string name="show_read_aloud_capsule_summary">朗讀時在閱讀頁顯示封面、暫停和關閉按鈕</string>
+    <string name="read_aloud_keep_on_exit">離開閱讀時繼續朗讀</string>
+    <string name="read_aloud_keep_on_exit_summary">關閉閱讀介面後不停朗讀,繼續在背景播放</string>
     <string name="reset_read_aloud_capsule_position">重設朗讀膠囊位置</string>
     <string name="reset_read_aloud_capsule_position_summary">將朗讀膠囊移回預設位置</string>
     <string name="pause_read_aloud">暫停朗讀</string>

+ 9 - 2
app/src/main/res/values-zh-rTW/strings.xml

@@ -677,6 +677,13 @@
     <string name="reading">閱讀</string>
     <string name="battery_show">%d%%</string>
     <string name="timer_m">%d分鐘</string>
+    <string name="timer_chapters">%d 章</string>
+    <string name="finish_current_chapter_after_timer">定時到點後讀完本章</string>
+    <string name="finish_current_chapter_after_timer_summary">定時結束後繼續朗讀,讀完目前章節再停止</string>
+    <string name="read_aloud_timer_mode">定時方式</string>
+    <string name="read_aloud_timer_by_minute">按時間</string>
+    <string name="read_aloud_timer_by_chapter">按章節</string>
+    <string name="read_aloud_timer_chapter_summary">讀滿所選章數後,在章節末尾停止</string>
     <string name="brightness_auto">自動亮度%s</string>
     <string name="read_aloud_by_page">按頁朗讀</string>
     <string name="speak_engine">朗讀引擎</string>
@@ -1857,8 +1864,6 @@
     <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>
@@ -2661,6 +2666,8 @@
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">顯示朗讀膠囊</string>
     <string name="show_read_aloud_capsule_summary">朗讀時在閱讀頁顯示封面、暫停和關閉按鈕</string>
+    <string name="read_aloud_keep_on_exit">離開閱讀時繼續朗讀</string>
+    <string name="read_aloud_keep_on_exit_summary">關閉閱讀介面後不停朗讀,繼續在背景播放</string>
     <string name="reset_read_aloud_capsule_position">重設朗讀膠囊位置</string>
     <string name="reset_read_aloud_capsule_position_summary">將朗讀膠囊移回預設位置</string>
     <string name="pause_read_aloud">暫停朗讀</string>

+ 9 - 2
app/src/main/res/values/strings.xml

@@ -771,6 +771,13 @@
     <string name="reading">Read</string>
     <string name="battery_show">%d%%</string>
     <string name="timer_m">%d min</string>
+    <string name="timer_chapters">%d chapters</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="read_aloud_timer_mode">Timer mode</string>
+    <string name="read_aloud_timer_by_minute">By time</string>
+    <string name="read_aloud_timer_by_chapter">By chapters</string>
+    <string name="read_aloud_timer_chapter_summary">Stop at the end of the chapter once the selected number of chapters is read</string>
     <string name="brightness_auto">Auto-Brightness %s</string>
     <string name="read_aloud_by_page">Speak by pages</string>
     <string name="speak_engine">Speak Engine</string>
@@ -1737,8 +1744,6 @@
     <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>
@@ -2878,6 +2883,8 @@
     <string name="http_tts">HTTP TTS</string>
     <string name="show_read_aloud_capsule">Show read-aloud capsule</string>
     <string name="show_read_aloud_capsule_summary">Show cover, pause and stop controls over the reading page</string>
+    <string name="read_aloud_keep_on_exit">Keep reading aloud after exit</string>
+    <string name="read_aloud_keep_on_exit_summary">Keep reading aloud in the background after leaving the reader</string>
     <string name="reset_read_aloud_capsule_position">Reset capsule position</string>
     <string name="reset_read_aloud_capsule_position_summary">Move the read aloud capsule back to its default position</string>
     <string name="pause_read_aloud">Pause read aloud</string>

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

@@ -4,20 +4,21 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.settings.ReadAloudContentSplitMode
 import io.legado.app.domain.model.settings.ReadAloudSettings
+import io.legado.app.domain.model.settings.ReadAloudTimerMode
 import org.junit.Assert.assertEquals
 import org.junit.Test
 
 class ReadAloudSettingsMappingTest {
 
     @Test
-    fun `朗读设置 28 键写映射逐字段对应`() {
+    fun `朗读设置 29 键写映射逐字段对应`() {
         readAloudMappingSamples().forEach { settings ->
             assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
     }
 
     @Test
-    fun `朗读设置 28 键读映射逐字段对应`() {
+    fun `朗读设置 29 键读映射逐字段对应`() {
         readAloudMappingSamples().forEach { expected ->
             assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toReadAloudSettings())
         }
@@ -56,7 +57,15 @@ class ReadAloudSettingsMappingTest {
     }
 
     @Test
-    fun `定时到点后读完本章默认关闭`() {
+    fun `定时默认按时间且未开启`() {
+        val settings = emptyMap<String, Any?>().toTestPreferences().toReadAloudSettings()
+
+        assertEquals(ReadAloudTimerMode.Minute.storageValue, settings.timerMode)
+        assertEquals(0, settings.timerChapters)
+    }
+
+    @Test
+    fun `到点后读完本章默认关闭`() {
         val settings = emptyMap<String, Any?>().toTestPreferences().toReadAloudSettings()
 
         assertEquals(false, settings.finishCurrentChapterAfterTimer)
@@ -98,6 +107,7 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         base.copy(readAloudByMediaButton = true),
         base.copy(pauseReadAloudWhilePhoneCalls = true),
         base.copy(readAloudWakeLock = true),
+        base.copy(keepReadAloudOnExit = true),
         base.copy(showReadAloudCapsule = false),
         base.copy(mediaButtonPerNext = true),
         base.copy(readAloudByPage = true),
@@ -108,6 +118,10 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         base.copy(androidMediaControlEnabled = true),
         base.copy(systemMediaControlCompatibilityChange = false),
         base.copy(streamReadAloudAudio = true),
+        base.copy(
+            timerMode = ReadAloudTimerMode.Chapter.storageValue,
+            timerChapters = 3,
+        ),
         base.copy(finishCurrentChapterAfterTimer = true),
         base.copy(ttsFollowSys = false),
         base.copy(useMultiSpeaker = false),
@@ -123,6 +137,7 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.readAloudByMediaButton to readAloudByMediaButton,
     PreferKey.pauseReadAloudWhilePhoneCalls to pauseReadAloudWhilePhoneCalls,
     PreferKey.readAloudWakeLock to readAloudWakeLock,
+    PreferKey.keepReadAloudOnExit to keepReadAloudOnExit,
     PreferKey.showReadAloudCapsule to showReadAloudCapsule,
     PreferKey.capsuleAutoCollapse to capsuleAutoCollapse,
     CAPSULE_OFFSET_X to capsuleOffsetX,
@@ -136,6 +151,8 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.streamReadAloudAudio to streamReadAloudAudio,
     PreferKey.ttsTimer to ttsTimer,
     PreferKey.finishCurrentChapterAfterTimer to finishCurrentChapterAfterTimer,
+    PreferKey.readAloudTimerMode to timerMode,
+    PreferKey.readAloudTimerChapters to timerChapters,
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,

+ 64 - 20
app/src/test/java/io/legado/app/service/ReadAloudChapterCompletionTest.kt

@@ -11,7 +11,7 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 5,
             finishedChapterIndex = 5,
             finishChapterAtIndex = 5,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.STOP, decision.action)
@@ -24,11 +24,12 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 5,
             finishedChapterIndex = 5,
             finishChapterAtIndex = NO_FINISH_CHAPTER,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
         assertEquals(false, decision.clearTimer)
+        assertEquals(null, decision.remainingChapters)
     }
 
     @Test
@@ -37,20 +38,7 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 5,
             finishedChapterIndex = 5,
             finishChapterAtIndex = 3,
-            finishChapterSettingEnabled = true,
-        )
-
-        assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
-        assertEquals(true, decision.clearTimer)
-    }
-
-    @Test
-    fun timerDisabledAfterArmingClearsAndContinues() {
-        val decision = decideChapterCompletion(
-            durChapterIndex = 5,
-            finishedChapterIndex = 5,
-            finishChapterAtIndex = 5,
-            finishChapterSettingEnabled = false,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
@@ -65,7 +53,7 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 6,
             finishedChapterIndex = 5,
             finishChapterAtIndex = 5,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.STOP, decision.action)
@@ -78,7 +66,7 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 6,
             finishedChapterIndex = 5,
             finishChapterAtIndex = 3,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.SKIP, decision.action)
@@ -93,7 +81,7 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 6,
             finishedChapterIndex = 5,
             finishChapterAtIndex = NO_FINISH_CHAPTER,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.SKIP, decision.action)
@@ -106,11 +94,67 @@ class ReadAloudChapterCompletionTest {
             durChapterIndex = 0,
             finishedChapterIndex = 0,
             finishChapterAtIndex = NO_FINISH_CHAPTER,
-            finishChapterSettingEnabled = true,
+            chapterQuota = null,
         )
 
         assertEquals(ChapterCompletionAction.ADVANCE, decision.action)
         assertEquals(false, decision.clearTimer)
     }
 
+    @Test
+    fun chapterQuotaOneStopsAtFirstChapterBoundary() {
+        val decision = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            chapterQuota = 1,
+        )
+
+        assertEquals(ChapterCompletionAction.STOP, decision.action)
+        assertEquals(0, decision.remainingChapters)
+    }
+
+    @Test
+    fun chapterQuotaDecrementsAndContinuesUntilExhausted() {
+        val first = decideChapterCompletion(
+            durChapterIndex = 5,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            chapterQuota = 3,
+        )
+        assertEquals(ChapterCompletionAction.ADVANCE, first.action)
+        assertEquals(2, first.remainingChapters)
+
+        val second = decideChapterCompletion(
+            durChapterIndex = 6,
+            finishedChapterIndex = 6,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            chapterQuota = first.remainingChapters,
+        )
+        assertEquals(ChapterCompletionAction.ADVANCE, second.action)
+        assertEquals(1, second.remainingChapters)
+
+        val third = decideChapterCompletion(
+            durChapterIndex = 7,
+            finishedChapterIndex = 7,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            chapterQuota = second.remainingChapters,
+        )
+        assertEquals(ChapterCompletionAction.STOP, third.action)
+        assertEquals(0, third.remainingChapters)
+    }
+
+    @Test
+    fun raceSkipDoesNotConsumeChapterQuota() {
+        // 竞态下这一章的完结不属于本次朗读推进,配额不能被白扣
+        val decision = decideChapterCompletion(
+            durChapterIndex = 6,
+            finishedChapterIndex = 5,
+            finishChapterAtIndex = NO_FINISH_CHAPTER,
+            chapterQuota = 3,
+        )
+
+        assertEquals(ChapterCompletionAction.SKIP, decision.action)
+        assertEquals(null, decision.remainingChapters)
+    }
 }

+ 15 - 3
app/src/test/java/io/legado/app/ui/book/read/ReadBookDomainSplitBoundaryTest.kt

@@ -206,16 +206,28 @@ class ReadBookDomainSplitBoundaryTest {
      * 已在 `ReadAloudContentSplitSetting` 编码成单一载荷,因此 VIM 只需一个意图入口;
      * 方式与标点的合并、迁移标记落盘、标点集合校验全在 `ReadAloudDelegate` 与
      * `ReadAloudSettingsRepository.setContentSplit` 里。
+     *
+     * 2733 → 2736:朗读定时改为「时间 / 章节」两种互斥模式,新增两个意图分支
+     * (`SetReadAloudTimerMode`、`SetReadAloudTimerChapters`),各一行转发,共 3 行
+     * (含分支名换行)。逐行都摘不掉:意图入口只能在 VM,模式与章数的解析、互斥写入、
+     * 服务重装都在 `ReadAloudDelegate.setTimerMode` / `setTimerChapters` 里。
+     * 没有为压行数把两个语义不同的设置合并成一个载荷——那会让「只改章数」也必须带上模式。
+     *
+     * 2736 → 2743:退出阅读时继续后台朗读开关。新增一个意图分支(`SetReadAloudKeepOnExit`)
+     * 与一行转发,加上换行共 4 行;其余 3 行是 `stopReadAloudForClose()` 里新增的持久设置
+     * 短路判定(含注释)。逐行都摘不掉:关闭朗读的决策点就在 VM 的 `closeReadBook` 路径上,
+     * 设置读取与 delegate 转发分别在 `ReadAloudSettingsRepository` 与 `ReadAloudDelegate`,
+     * VM 只剩这两处接线。
      */
     @Test
-    fun `ReadBookViewModel 不超过 R2 验收的 2733 行`() {
+    fun `ReadBookViewModel 不超过 R2 验收的 2743 行`() {
         val lineCount = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt")
             .readLines().size
         assertTrue(
-            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2733。\n" +
+            "ReadBookViewModel 涨到了 $lineCount 行,超过 R2 验收线 2743。\n" +
                 "新功能请摘成 io/legado/app/ui/book/read/ 下的 XxxDelegate," +
                 "并在本测试的 DOMAINS 里加一条边界。",
-            lineCount <= 2733,
+            lineCount <= 2743,
         )
     }