Răsfoiți Sursa

fix: 朗读相关

HapeLee 5 luni în urmă
părinte
comite
e28be0301b

+ 35 - 22
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.booleanPreferencesKey
 import androidx.datastore.preferences.core.emptyPreferences
 import androidx.datastore.preferences.core.emptyPreferences
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
+import io.legado.app.ui.config.readConfig.ReadConfig
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.map
@@ -42,41 +43,53 @@ class ReadAloudSettingsRepository(
             preferences.toReadAloudPreferences()
             preferences.toReadAloudPreferences()
         }
         }
 
 
-    suspend fun setIgnoreAudioFocus(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.ignoreAudioFocus, value)
+    suspend fun setIgnoreAudioFocus(value: Boolean) {
+        ReadConfig.ignoreAudioFocus = value
+    }
 
 
-    suspend fun setMediaButtonOnExit(value: Boolean) =
+    suspend fun setMediaButtonOnExit(value: Boolean) {
         settingsRepository.putBoolean(PreferKey.mediaButtonOnExit, value)
         settingsRepository.putBoolean(PreferKey.mediaButtonOnExit, value)
+    }
 
 
-    suspend fun setReadAloudByMediaButton(value: Boolean) =
+    suspend fun setReadAloudByMediaButton(value: Boolean) {
         settingsRepository.putBoolean(PreferKey.readAloudByMediaButton, value)
         settingsRepository.putBoolean(PreferKey.readAloudByMediaButton, value)
+    }
 
 
-    suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
+    suspend fun setPauseReadAloudWhilePhoneCalls(value: Boolean) {
+        ReadConfig.pauseReadAloudWhilePhoneCalls = value
+    }
 
 
-    suspend fun setReadAloudWakeLock(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.readAloudWakeLock, value)
+    suspend fun setReadAloudWakeLock(value: Boolean) {
+        ReadConfig.readAloudWakeLock = value
+    }
 
 
-    suspend fun setMediaButtonPerNext(value: Boolean) =
-        settingsRepository.putBoolean(KEY_MEDIA_BUTTON_PER_NEXT, value)
+    suspend fun setMediaButtonPerNext(value: Boolean) {
+        ReadConfig.mediaButtonPerNext = value
+    }
 
 
-    suspend fun setReadAloudByPage(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.readAloudByPage, value)
+    suspend fun setReadAloudByPage(value: Boolean) {
+        ReadConfig.readAloudByPage = value
+    }
 
 
-    suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
+    suspend fun setSystemMediaControlCompatibilityChange(value: Boolean) {
+        ReadConfig.systemMediaControlCompatibilityChange = value
+    }
 
 
-    suspend fun setStreamReadAloudAudio(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.streamReadAloudAudio, value)
+    suspend fun setStreamReadAloudAudio(value: Boolean) {
+        ReadConfig.streamReadAloudAudio = value
+    }
 
 
-    suspend fun setTtsTimer(value: Int) =
-        settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180))
+    suspend fun setTtsTimer(value: Int) {
+        ReadConfig.ttsTimer = value.coerceIn(0, 180)
+    }
 
 
-    suspend fun setTtsFollowSys(value: Boolean) =
-        settingsRepository.putBoolean(PreferKey.ttsFollowSys, value)
+    suspend fun setTtsFollowSys(value: Boolean) {
+        ReadConfig.ttsFollowSys = value
+    }
 
 
-    suspend fun setTtsSpeechRate(value: Int) =
-        settingsRepository.putInt(PreferKey.ttsSpeechRate, value.coerceIn(0, 80))
+    suspend fun setTtsSpeechRate(value: Int) {
+        ReadConfig.ttsSpeechRate = value.coerceIn(0, 80)
+    }
 
 
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
         return ReadAloudPreferences(

+ 22 - 12
app/src/main/java/io/legado/app/help/config/AppConfig.kt

@@ -376,9 +376,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
         }
 
 
     var ttsFlowSys: Boolean
     var ttsFlowSys: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.ttsFollowSys, true)
+        get() = ReadConfig.ttsFollowSys
         set(value) {
         set(value) {
-            appCtx.putPrefBoolean(PreferKey.ttsFollowSys, value)
+            ReadConfig.ttsFollowSys = value
         }
         }
 
 
     val noAnimScrollPage: Boolean
     val noAnimScrollPage: Boolean
@@ -387,15 +387,15 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
     const val defaultSpeechRate = 5
     const val defaultSpeechRate = 5
 
 
     var ttsSpeechRate: Int
     var ttsSpeechRate: Int
-        get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, defaultSpeechRate)
+        get() = ReadConfig.ttsSpeechRate
         set(value) {
         set(value) {
-            appCtx.putPrefInt(PreferKey.ttsSpeechRate, value)
+            ReadConfig.ttsSpeechRate = value
         }
         }
 
 
     var ttsTimer: Int
     var ttsTimer: Int
-        get() = appCtx.getPrefInt(PreferKey.ttsTimer, 0)
+        get() = ReadConfig.ttsTimer
         set(value) {
         set(value) {
-            appCtx.putPrefInt(PreferKey.ttsTimer, value)
+            ReadConfig.ttsTimer = value
         }
         }
 
 
     val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate
     val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate
@@ -604,11 +604,17 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
 
 
     val showAddToShelfAlert get() = OtherConfig.showAddToShelfAlert
     val showAddToShelfAlert get() = OtherConfig.showAddToShelfAlert
 
 
-    val ignoreAudioFocus get() = appCtx.getPrefBoolean(PreferKey.ignoreAudioFocus, false)
+    var ignoreAudioFocus
+        get() = ReadConfig.ignoreAudioFocus
+        set(value) {
+            ReadConfig.ignoreAudioFocus = value
+        }
 
 
     var pauseReadAloudWhilePhoneCalls
     var pauseReadAloudWhilePhoneCalls
-        get() = appCtx.getPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, false)
-        set(value) = appCtx.putPrefBoolean(PreferKey.pauseReadAloudWhilePhoneCalls, value)
+        get() = ReadConfig.pauseReadAloudWhilePhoneCalls
+        set(value) {
+            ReadConfig.pauseReadAloudWhilePhoneCalls = value
+        }
 
 
     val onlyLatestBackup get() = BackupConfig.onlyLatestBackup
     val onlyLatestBackup get() = BackupConfig.onlyLatestBackup
 
 
@@ -618,7 +624,11 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
 
 
     val updateToVariant get() = OtherConfig.updateToVariant
     val updateToVariant get() = OtherConfig.updateToVariant
 
 
-    val streamReadAloudAudio get() = appCtx.getPrefBoolean(PreferKey.streamReadAloudAudio, false)
+    var streamReadAloudAudio
+        get() = ReadConfig.streamReadAloudAudio
+        set(value) {
+            ReadConfig.streamReadAloudAudio = value
+        }
 
 
     val doublePageHorizontal: String?
     val doublePageHorizontal: String?
         get() = ReadConfig.doubleHorizontalPage
         get() = ReadConfig.doubleHorizontalPage
@@ -940,9 +950,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
         }
         }
 
 
     var systemMediaControlCompatibilityChange: Boolean
     var systemMediaControlCompatibilityChange: Boolean
-        get() = appCtx.getPrefBoolean(PreferKey.systemMediaControlCompatibilityChange, true)
+        get() = ReadConfig.systemMediaControlCompatibilityChange
         set(value) {
         set(value) {
-            appCtx.putPrefBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
+            ReadConfig.systemMediaControlCompatibilityChange = value
         }
         }
 
 
     var isPredictiveBackEnabled: Boolean
     var isPredictiveBackEnabled: Boolean

+ 127 - 384
app/src/main/java/io/legado/app/help/config/ReadBookConfig.kt

@@ -7,36 +7,25 @@ import android.graphics.drawable.ColorDrawable
 import android.graphics.drawable.Drawable
 import android.graphics.drawable.Drawable
 import androidx.annotation.Keep
 import androidx.annotation.Keep
 import androidx.core.graphics.toColorInt
 import androidx.core.graphics.toColorInt
-import androidx.datastore.preferences.core.emptyPreferences
-import androidx.datastore.preferences.core.intPreferencesKey
 import io.legado.app.R
 import io.legado.app.R
 import io.legado.app.constant.PageAnim
 import io.legado.app.constant.PageAnim
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurStyle
 import io.legado.app.constant.ReadMenuBlurStyle
-import io.legado.app.data.repository.ReadPreferences
+import androidx.compose.runtime.State
 import io.legado.app.data.repository.ReadStyleRepository
 import io.legado.app.data.repository.ReadStyleRepository
-import io.legado.app.data.repository.dataStore
 import io.legado.app.help.DefaultData
 import io.legado.app.help.DefaultData
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.ui.config.PrefDelegate
+import io.legado.app.ui.config.prefDelegate
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.getMeanColor
 import io.legado.app.utils.getMeanColor
-import io.legado.app.utils.getPrefBoolean
-import io.legado.app.utils.getPrefFloat
-import io.legado.app.utils.getPrefInt
-import io.legado.app.utils.getPrefString
 import io.legado.app.utils.hexString
 import io.legado.app.utils.hexString
-import io.legado.app.utils.putPrefBoolean
-import io.legado.app.utils.putPrefInt
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.catch
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.runBlocking
 import splitties.init.appCtx
 import splitties.init.appCtx
-import java.io.IOException
 import java.io.InputStream
 import java.io.InputStream
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
 
 
 /**
 /**
  * 阅读界面配置
  * 阅读界面配置
@@ -46,6 +35,40 @@ import java.io.InputStream
 object ReadBookConfig {
 object ReadBookConfig {
     private val readStyleRepository = ReadStyleRepository()
     private val readStyleRepository = ReadStyleRepository()
 
 
+    // region prefDelegate helpers
+
+    /** 读写时自动 coerceIn 的 prefDelegate */
+    private fun <T : Comparable<T>> clampedPrefDelegate(
+        key: String,
+        defaultValue: T,
+        range: ClosedRange<T>,
+    ): PrefDelegate<T> {
+        val raw = prefDelegate(key, defaultValue)
+        return object : PrefDelegate<T> {
+            override val state: State<T> get() = raw.state
+            override fun dispose() = raw.dispose()
+            override fun getValue(thisRef: Any?, property: KProperty<*>): T =
+                raw.getValue(thisRef, property).coerceIn(range)
+            override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) =
+                raw.setValue(thisRef, property, value.coerceIn(range))
+        }
+    }
+
+    /** Map<String,String> 存储为 JSON String 的 prefDelegate */
+    private class PrefDelegateMap(
+        key: String,
+        private val parse: (String?) -> Map<String, String>,
+        private val encode: (Map<String, String>) -> String,
+    ) : ReadWriteProperty<Any?, Map<String, String>> {
+        private val raw = prefDelegate<String?>(key, null)
+        override fun getValue(thisRef: Any?, property: KProperty<*>): Map<String, String> =
+            parse(raw.getValue(thisRef, property))
+        override fun setValue(thisRef: Any?, property: KProperty<*>, value: Map<String, String>) =
+            raw.setValue(thisRef, property, encode(value))
+    }
+
+    // endregion
+
     // region Tip position constants
     // region Tip position constants
     const val tipNone = 0
     const val tipNone = 0
     const val tipChapterTitle = 1
     const val tipChapterTitle = 1
@@ -94,23 +117,6 @@ object ReadBookConfig {
         initShareConfig()
         initShareConfig()
     }
     }
 
 
-    /**
-     * 从 DataStore 读取 Int 值,DS 无值时回退到 SharedPreferences。
-     * 与 PrefDelegate.readFromDs() 逻辑一致。
-     */
-    private fun readIntFromDataStore(key: String, defaultValue: Int): Int {
-        return runBlocking(Dispatchers.IO) {
-            try {
-                appCtx.dataStore.data
-                    .catch { if (it is IOException) emit(emptyPreferences()) else throw it }
-                    .map { it[intPreferencesKey(key)] }
-                    .first() ?: defaultValue
-            } catch (_: Exception) {
-                appCtx.getPrefInt(key, defaultValue)
-            }
-        }
-    }
-
     @Synchronized
     @Synchronized
     fun getConfig(index: Int): Config {
     fun getConfig(index: Int): Config {
         if (configList.size < 5) {
         if (configList.size < 5) {
@@ -179,195 +185,115 @@ object ReadBookConfig {
         }
         }
     }
     }
 
 
-    // Runtime compatibility snapshot. New write/read flows should use ReadSettingsRepository.
-    private var readBodyToLhValue = appCtx.getPrefBoolean(PreferKey.readBodyToLh, true)
-    var readBodyToLh: Boolean
-        get() = readBodyToLhValue
-        set(value) {
-            readBodyToLhValue = value
-        }
-
-    private var autoReadSpeedValue = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10)
-    var autoReadSpeed: Int
-        get() = autoReadSpeedValue
-        set(value) {
-            autoReadSpeedValue = value
-            appCtx.putPrefInt(PreferKey.autoReadSpeed, value)
-        }
+    // region Scalar properties (prefDelegate — auto-syncs SP + DataStore)
+
+    var readBodyToLh by prefDelegate(PreferKey.readBodyToLh, true)
+    var autoReadSpeed by prefDelegate(PreferKey.autoReadSpeed, 10)
+    var readStyleSelect by prefDelegate(PreferKey.readStyleSelect, 0)
+    var comicStyleSelect by prefDelegate(PreferKey.comicStyleSelect, 0)
+    var shareLayout by prefDelegate(PreferKey.shareLayout, false)
+    var textFullJustify by prefDelegate(PreferKey.textFullJustify, true)
+    var textBottomJustify by prefDelegate(PreferKey.textBottomJustify, true)
+    var hideStatusBar by prefDelegate(PreferKey.hideStatusBar, false)
+    var hideNavigationBar by prefDelegate(PreferKey.hideNavigationBar, false)
+    var useZhLayout by prefDelegate(PreferKey.useZhLayout, false)
+    var readMenuIconShowText by prefDelegate(PreferKey.readMenuIconShowText, true)
+    var readMenuFloatingBottomBar by prefDelegate(PreferKey.readMenuFloatingBottomBar, false)
+    var readMenuTopBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuTopBarLiquidGlassButtons, false)
+    var readMenuBottomBarLiquidGlassButtons by prefDelegate(PreferKey.readMenuBottomBarLiquidGlassButtons, false)
+    var readMenuBorderColor by prefDelegate(PreferKey.readMenuBorderColor, 0)
+    var readMenuBorderColorNight by prefDelegate(PreferKey.readMenuBorderColorNight, 0)
+    var showTitleBarIcons by prefDelegate(PreferKey.showTitleBarIcons, true)
+    var readSliderMode by prefDelegate(PreferKey.readSliderMode, "0")
 
 
     var styleSelect: Int
     var styleSelect: Int
         get() = if (isComic) comicStyleSelect else readStyleSelect
         get() = if (isComic) comicStyleSelect else readStyleSelect
         set(value) {
         set(value) {
-            if (isComic) {
-                comicStyleSelect = value
-            } else {
-                readStyleSelect = value
-            }
+            if (isComic) comicStyleSelect = value else readStyleSelect = value
         }
         }
 
 
-    private var readStyleSelectValue = readIntFromDataStore(PreferKey.readStyleSelect, 0)
-    var readStyleSelect: Int
-        get() = readStyleSelectValue
-        set(value) {
-            readStyleSelectValue = value
-            if (appCtx.getPrefInt(PreferKey.readStyleSelect) != value) {
-                appCtx.putPrefInt(PreferKey.readStyleSelect, value)
-            }
-        }
+    // endregion
 
 
-    private var comicStyleSelectValue =
-        readIntFromDataStore(PreferKey.comicStyleSelect, readStyleSelect)
-    var comicStyleSelect: Int
-        get() = comicStyleSelectValue
-        set(value) {
-            comicStyleSelectValue = value
-            if (appCtx.getPrefInt(PreferKey.comicStyleSelect) != value) {
-                appCtx.putPrefInt(PreferKey.comicStyleSelect, value)
-            }
-        }
+    // region coerceIn properties (clampedPrefDelegate)
+
+    var readMenuColorMode by clampedPrefDelegate(PreferKey.readMenuColorMode, 1, 0..1)
+    var readMenuIconStyle by clampedPrefDelegate(PreferKey.readMenuIconStyle, 0, 0..2)
+    var readMenuIconItemsPerRow by clampedPrefDelegate(PreferKey.readMenuIconItemsPerRow, 5, 2..8)
+    var readMenuIconRowCount by clampedPrefDelegate(PreferKey.readMenuIconRowCount, 1, 1..2)
+    var readMenuBottomCornerRadius by clampedPrefDelegate(PreferKey.readMenuBottomCornerRadius, 0, 0..32)
+    var readMenuTopBarBlurMode by clampedPrefDelegate(PreferKey.readMenuTopBarBlurMode, ReadMenuBlurMode.None, 0..2)
+    var readMenuBottomBarBlurMode by clampedPrefDelegate(PreferKey.readMenuBottomBarBlurMode, ReadMenuBlurMode.None, 0..2)
+    var readMenuTopBarBlurStyle by clampedPrefDelegate(PreferKey.readMenuTopBarBlurStyle, ReadMenuBlurStyle.Progressive, 0..1)
+    var readMenuBottomBarBlurStyle by clampedPrefDelegate(PreferKey.readMenuBottomBarBlurStyle, ReadMenuBlurStyle.Solid, 0..1)
+    var readMenuBlurRadius by clampedPrefDelegate(PreferKey.readMenuBlurRadius, 24, 0..32)
+    var readMenuBlurAlpha by clampedPrefDelegate(PreferKey.readMenuBlurAlpha, 60, 0..100)
+    var readMenuLensRadius by clampedPrefDelegate(PreferKey.readMenuLensRadius, 24f, 0f..48f)
+    var readMenuBorderWidth by clampedPrefDelegate(PreferKey.readMenuBorderWidth, 0, 0..4)
+    var titleBarIconPosition by clampedPrefDelegate(PreferKey.titleBarIconPosition, 0, 0..3)
 
 
-    private var shareLayoutValue = appCtx.getPrefBoolean(PreferKey.shareLayout)
-    var shareLayout: Boolean
-        get() = shareLayoutValue
-        set(value) {
-            shareLayoutValue = value
-            if (appCtx.getPrefBoolean(PreferKey.shareLayout) != value) {
-                appCtx.putPrefBoolean(PreferKey.shareLayout, value)
-            }
-        }
+    // endregion
 
 
-    private var textFullJustifyValue = appCtx.getPrefBoolean(PreferKey.textFullJustify, true)
-    private var textBottomJustifyValue = appCtx.getPrefBoolean(PreferKey.textBottomJustify, true)
-    private var hideStatusBarValue = appCtx.getPrefBoolean(PreferKey.hideStatusBar)
-    private var hideNavigationBarValue = appCtx.getPrefBoolean(PreferKey.hideNavigationBar)
-    private var useZhLayoutValue = appCtx.getPrefBoolean(PreferKey.useZhLayout)
-    private var readMenuBgColorValue = appCtx.getPrefInt(PreferKey.readMenuBgColor)
-    private var readMenuAccentColorValue = appCtx.getPrefInt(PreferKey.readMenuAccentColor)
-    private var readMenuContainerColorValue = appCtx.getPrefInt(PreferKey.readMenuContainerColor)
-    private var readMenuBgColorNightValue = appCtx.getPrefInt(PreferKey.readMenuBgColorNight)
-    private var readMenuAccentColorNightValue = appCtx.getPrefInt(PreferKey.readMenuAccentColorNight)
-    private var readMenuContainerColorNightValue = appCtx.getPrefInt(PreferKey.readMenuContainerColorNight)
-    private var readMenuColorModeValue = appCtx.getPrefInt(PreferKey.readMenuColorMode, 1)
-    private var readMenuIconShowTextValue = appCtx.getPrefBoolean(PreferKey.readMenuIconShowText, true)
-    private var readMenuIconStyleValue = appCtx.getPrefInt(PreferKey.readMenuIconStyle)
-    private var readMenuIconItemsPerRowValue = appCtx.getPrefInt(PreferKey.readMenuIconItemsPerRow, 5)
-    private var readMenuIconRowCountValue = appCtx.getPrefInt(PreferKey.readMenuIconRowCount, 1)
-    private var readMenuBottomCornerRadiusValue = appCtx.getPrefInt(PreferKey.readMenuBottomCornerRadius)
-    private var readMenuFloatingBottomBarValue = appCtx.getPrefBoolean(PreferKey.readMenuFloatingBottomBar)
-    private var readMenuTopBarBlurModeValue = appCtx.getPrefInt(
-        PreferKey.readMenuTopBarBlurMode,
-        ReadMenuBlurMode.None
-    )
-    private var readMenuBottomBarBlurModeValue = appCtx.getPrefInt(
-        PreferKey.readMenuBottomBarBlurMode,
-        ReadMenuBlurMode.None
-    )
-    private var readMenuTopBarLiquidGlassButtonsValue = appCtx.getPrefBoolean(
-        PreferKey.readMenuTopBarLiquidGlassButtons
-    )
-    private var readMenuBottomBarLiquidGlassButtonsValue = appCtx.getPrefBoolean(
-        PreferKey.readMenuBottomBarLiquidGlassButtons
-    )
-    private var readMenuTopBarBlurStyleValue = appCtx.getPrefInt(
-        PreferKey.readMenuTopBarBlurStyle,
-        ReadMenuBlurStyle.Progressive
-    )
-    private var readMenuBottomBarBlurStyleValue = appCtx.getPrefInt(
-        PreferKey.readMenuBottomBarBlurStyle,
-        ReadMenuBlurStyle.Solid
-    )
-    private var readMenuBlurRadiusValue = appCtx.getPrefInt(PreferKey.readMenuBlurRadius, 24)
-    private var readMenuBlurAlphaValue = appCtx.getPrefInt(PreferKey.readMenuBlurAlpha, 60)
-    private var readMenuLensRadiusValue = appCtx.getPrefFloat(PreferKey.readMenuLensRadius, 24f)
-    private var readMenuBorderWidthValue = appCtx.getPrefInt(PreferKey.readMenuBorderWidth)
-    private var readMenuBorderColorValue = appCtx.getPrefInt(PreferKey.readMenuBorderColor)
-    private var readMenuBorderColorNightValue = appCtx.getPrefInt(PreferKey.readMenuBorderColorNight)
-    private var readMenuCustomIconsValue =
-        parseReadMenuCustomIcons(appCtx.getPrefString(PreferKey.readMenuCustomIcons))
-    private var titleBarCustomIconsValue =
-        parseReadMenuCustomIcons(appCtx.getPrefString(PreferKey.titleBarCustomIcons))
-    private var titleBarIconPositionValue = appCtx.getPrefInt(PreferKey.titleBarIconPosition)
-    private var showTitleBarIconsValue = appCtx.getPrefBoolean(PreferKey.showTitleBarIcons, true)
-    private var readSliderModeValue = appCtx.getPrefString(PreferKey.readSliderMode) ?: "0"
-
-    fun syncPreferences(preferences: ReadPreferences) {
-        readBodyToLhValue = preferences.readBodyToLh
-        autoReadSpeedValue = preferences.autoReadSpeed
-        readStyleSelectValue = preferences.readStyleSelect
-        comicStyleSelectValue = preferences.comicStyleSelect
-        shareLayoutValue = preferences.shareLayout
-        textFullJustifyValue = preferences.textFullJustify
-        textBottomJustifyValue = preferences.textBottomJustify
-        hideStatusBarValue = preferences.hideStatusBar
-        hideNavigationBarValue = preferences.hideNavigationBar
-        useZhLayoutValue = preferences.useZhLayout
-        readMenuBgColorValue = preferences.readMenuBgColor
-        readMenuAccentColorValue = preferences.readMenuAccentColor
-        readMenuContainerColorValue = preferences.readMenuContainerColor
-        readMenuBgColorNightValue = preferences.readMenuBgColorNight
-        readMenuAccentColorNightValue = preferences.readMenuAccentColorNight
-        readMenuContainerColorNightValue = preferences.readMenuContainerColorNight
-        readMenuColorModeValue = preferences.readMenuColorMode
-        readMenuIconShowTextValue = preferences.readMenuIconShowText
-        readMenuIconStyleValue = preferences.readMenuIconStyle
-        readMenuIconItemsPerRowValue = preferences.readMenuIconItemsPerRow
-        readMenuIconRowCountValue = preferences.readMenuIconRowCount
-        readMenuBottomCornerRadiusValue = preferences.readMenuBottomCornerRadius
-        readMenuFloatingBottomBarValue = preferences.readMenuFloatingBottomBar
-        readMenuTopBarBlurModeValue = preferences.readMenuTopBarBlurMode
-        readMenuBottomBarBlurModeValue = preferences.readMenuBottomBarBlurMode
-        readMenuTopBarLiquidGlassButtonsValue = preferences.readMenuTopBarLiquidGlassButtons
-        readMenuBottomBarLiquidGlassButtonsValue = preferences.readMenuBottomBarLiquidGlassButtons
-        readMenuTopBarBlurStyleValue = preferences.readMenuTopBarBlurStyle
-        readMenuBottomBarBlurStyleValue = preferences.readMenuBottomBarBlurStyle
-        readMenuBlurRadiusValue = preferences.readMenuBlurRadius
-        readMenuBlurAlphaValue = preferences.readMenuBlurAlpha
-        readMenuLensRadiusValue = preferences.readMenuLensRadius
-        readMenuBorderWidthValue = preferences.readMenuBorderWidth
-        readMenuBorderColorValue = preferences.readMenuBorderColor
-        readMenuBorderColorNightValue = preferences.readMenuBorderColorNight
-        readMenuCustomIconsValue = parseReadMenuCustomIcons(preferences.readMenuCustomIcons)
-        titleBarCustomIconsValue = parseReadMenuCustomIcons(preferences.titleBarCustomIcons)
-        titleBarIconPositionValue = preferences.titleBarIconPosition
-        showTitleBarIconsValue = preferences.showTitleBarIcons
-        readSliderModeValue = preferences.readSliderMode
-    }
+    // region Color properties (prefDelegate backing + custom fallback getter)
 
 
+    private val readMenuBgColorDelegate = prefDelegate(PreferKey.readMenuBgColor, 0)
     var readMenuBgColor: Int
     var readMenuBgColor: Int
-        get() = readMenuBgColorValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = false)
-        set(value) {
-            readMenuBgColorValue = value
-        }
+        get() = readMenuBgColorDelegate.getValue(this, ::readMenuBgColor)
+            .takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = false)
+        set(value) = readMenuBgColorDelegate.setValue(this, ::readMenuBgColor, value)
 
 
+    private val readMenuAccentColorDelegate = prefDelegate(PreferKey.readMenuAccentColor, 0)
     var readMenuAccentColor: Int
     var readMenuAccentColor: Int
-        get() = readMenuAccentColorValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = false)
-        set(value) {
-            readMenuAccentColorValue = value
-        }
+        get() = readMenuAccentColorDelegate.getValue(this, ::readMenuAccentColor)
+            .takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = false)
+        set(value) = readMenuAccentColorDelegate.setValue(this, ::readMenuAccentColor, value)
 
 
+    private val readMenuContainerColorDelegate = prefDelegate(PreferKey.readMenuContainerColor, 0)
     var readMenuContainerColor: Int
     var readMenuContainerColor: Int
-        get() = readMenuContainerColorValue.takeIf { it != 0 } ?: readMenuBgColor
-        set(value) {
-            readMenuContainerColorValue = value
-        }
+        get() = readMenuContainerColorDelegate.getValue(this, ::readMenuContainerColor)
+            .takeIf { it != 0 } ?: readMenuBgColor
+        set(value) = readMenuContainerColorDelegate.setValue(this, ::readMenuContainerColor, value)
 
 
+    private val readMenuBgColorNightDelegate = prefDelegate(PreferKey.readMenuBgColorNight, 0)
     var readMenuBgColorNight: Int
     var readMenuBgColorNight: Int
-        get() = readMenuBgColorNightValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = true)
-        set(value) {
-            readMenuBgColorNightValue = value
-        }
+        get() = readMenuBgColorNightDelegate.getValue(this, ::readMenuBgColorNight)
+            .takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = true)
+        set(value) = readMenuBgColorNightDelegate.setValue(this, ::readMenuBgColorNight, value)
 
 
+    private val readMenuAccentColorNightDelegate = prefDelegate(PreferKey.readMenuAccentColorNight, 0)
     var readMenuAccentColorNight: Int
     var readMenuAccentColorNight: Int
-        get() = readMenuAccentColorNightValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = true)
-        set(value) {
-            readMenuAccentColorNightValue = value
-        }
+        get() = readMenuAccentColorNightDelegate.getValue(this, ::readMenuAccentColorNight)
+            .takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = true)
+        set(value) = readMenuAccentColorNightDelegate.setValue(this, ::readMenuAccentColorNight, value)
 
 
+    private val readMenuContainerColorNightDelegate = prefDelegate(PreferKey.readMenuContainerColorNight, 0)
     var readMenuContainerColorNight: Int
     var readMenuContainerColorNight: Int
-        get() = readMenuContainerColorNightValue.takeIf { it != 0 } ?: readMenuBgColorNight
-        set(value) {
-            readMenuContainerColorNightValue = value
-        }
+        get() = readMenuContainerColorNightDelegate.getValue(this, ::readMenuContainerColorNight)
+            .takeIf { it != 0 } ?: readMenuBgColorNight
+        set(value) = readMenuContainerColorNightDelegate.setValue(this, ::readMenuContainerColorNight, value)
+
+    // endregion
+
+    // region Map properties (PrefDelegateMap — JSON string serialization)
+
+    fun encodeReadMenuCustomIcons(value: Map<String, String>): String {
+        return GSON.toJson(value.filterValues { it.isNotBlank() })
+    }
+
+    private fun parseReadMenuCustomIcons(value: String?): Map<String, String> {
+        if (value.isNullOrBlank()) return emptyMap()
+        return GSON.fromJsonObject<Map<String, String>>(value).getOrNull()
+            ?.filterValues { it.isNotBlank() } ?: emptyMap()
+    }
+
+    var readMenuCustomIcons: Map<String, String> by PrefDelegateMap(
+        PreferKey.readMenuCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
+    )
+
+    var titleBarCustomIcons: Map<String, String> by PrefDelegateMap(
+        PreferKey.titleBarCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
+    )
+
+    // endregion
 
 
     val resolvedMenuBgColor: Int
     val resolvedMenuBgColor: Int
         get() {
         get() {
@@ -400,198 +326,15 @@ object ReadBookConfig {
     val resolvedMenuContainerColor: Int
     val resolvedMenuContainerColor: Int
         get() = if (ReadStyleResolver.isNightTheme()) readMenuContainerColorNight else readMenuContainerColor
         get() = if (ReadStyleResolver.isNightTheme()) readMenuContainerColorNight else readMenuContainerColor
 
 
-    var readMenuColorMode: Int
-        get() = readMenuColorModeValue.coerceIn(0, 1)
-        set(value) {
-            readMenuColorModeValue = value.coerceIn(0, 1)
-        }
-
-    var readMenuIconShowText: Boolean
-        get() = readMenuIconShowTextValue
-        set(value) {
-            readMenuIconShowTextValue = value
-        }
-
-    var readMenuIconStyle: Int
-        get() = readMenuIconStyleValue.coerceIn(0, 2)
-        set(value) {
-            readMenuIconStyleValue = value.coerceIn(0, 2)
-        }
-
-    var readMenuIconItemsPerRow: Int
-        get() = readMenuIconItemsPerRowValue.coerceIn(2, 8)
-        set(value) {
-            readMenuIconItemsPerRowValue = value.coerceIn(2, 8)
-        }
-
-    var readMenuIconRowCount: Int
-        get() = readMenuIconRowCountValue.coerceIn(1, 2)
-        set(value) {
-            readMenuIconRowCountValue = value.coerceIn(1, 2)
-        }
-
-    var readMenuBottomCornerRadius: Int
-        get() = readMenuBottomCornerRadiusValue.coerceIn(0, 32)
-        set(value) {
-            readMenuBottomCornerRadiusValue = value.coerceIn(0, 32)
-        }
-
-    var readMenuFloatingBottomBar: Boolean
-        get() = readMenuFloatingBottomBarValue
-        set(value) {
-            readMenuFloatingBottomBarValue = value
-        }
-
-    var readMenuTopBarBlurMode: Int
-        get() = readMenuTopBarBlurModeValue.coerceIn(0, 2)
-        set(value) {
-            readMenuTopBarBlurModeValue = value.coerceIn(0, 2)
-        }
-
-    var readMenuBottomBarBlurMode: Int
-        get() = readMenuBottomBarBlurModeValue.coerceIn(0, 2)
-        set(value) {
-            readMenuBottomBarBlurModeValue = value.coerceIn(0, 2)
-        }
-
-    var readMenuTopBarLiquidGlassButtons: Boolean
-        get() = readMenuTopBarLiquidGlassButtonsValue
-        set(value) {
-            readMenuTopBarLiquidGlassButtonsValue = value
-        }
-
-    var readMenuBottomBarLiquidGlassButtons: Boolean
-        get() = readMenuBottomBarLiquidGlassButtonsValue
-        set(value) {
-            readMenuBottomBarLiquidGlassButtonsValue = value
-        }
-
-    var readMenuTopBarBlurStyle: Int
-        get() = readMenuTopBarBlurStyleValue.coerceIn(0, 1)
-        set(value) {
-            readMenuTopBarBlurStyleValue = value.coerceIn(0, 1)
-        }
-
-    var readMenuBottomBarBlurStyle: Int
-        get() = readMenuBottomBarBlurStyleValue.coerceIn(0, 1)
-        set(value) {
-            readMenuBottomBarBlurStyleValue = value.coerceIn(0, 1)
-        }
-
-    var readMenuBlurRadius: Int
-        get() = readMenuBlurRadiusValue.coerceIn(0, 32)
-        set(value) {
-            readMenuBlurRadiusValue = value.coerceIn(0, 32)
-        }
-
-    var readMenuBlurAlpha: Int
-        get() = readMenuBlurAlphaValue.coerceIn(0, 100)
-        set(value) {
-            readMenuBlurAlphaValue = value.coerceIn(0, 100)
-        }
-
-    var readMenuLensRadius: Float
-        get() = readMenuLensRadiusValue.coerceIn(0f, 48f)
-        set(value) {
-            readMenuLensRadiusValue = value.coerceIn(0f, 48f)
-        }
-
-    var readMenuBorderWidth: Int
-        get() = readMenuBorderWidthValue.coerceIn(0, 4)
-        set(value) {
-            readMenuBorderWidthValue = value.coerceIn(0, 4)
-        }
-
-    var readMenuBorderColor: Int
-        get() = readMenuBorderColorValue
-        set(value) {
-            readMenuBorderColorValue = value
-        }
-
-    var readMenuBorderColorNight: Int
-        get() = readMenuBorderColorNightValue
-        set(value) {
-            readMenuBorderColorNightValue = value
-        }
-
     val resolvedMenuBorderColor: Int
     val resolvedMenuBorderColor: Int
         get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor
         get() = if (ReadStyleResolver.isNightTheme()) readMenuBorderColorNight else readMenuBorderColor
 
 
-    var readMenuCustomIcons: Map<String, String>
-        get() = readMenuCustomIconsValue
-        set(value) {
-            readMenuCustomIconsValue = value.filterValues { it.isNotBlank() }
-        }
-
-    var titleBarCustomIcons: Map<String, String>
-        get() = titleBarCustomIconsValue
-        set(value) {
-            titleBarCustomIconsValue = value.filterValues { it.isNotBlank() }
-        }
-
-    // 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right
-    var titleBarIconPosition: Int
-        get() = titleBarIconPositionValue.coerceIn(0, 3)
-        set(value) {
-            titleBarIconPositionValue = value.coerceIn(0, 3)
-        }
-
-    var showTitleBarIcons: Boolean
-        get() = showTitleBarIconsValue
-        set(value) {
-            showTitleBarIconsValue = value
-        }
-
-    var readSliderMode: String
-        get() = readSliderModeValue
-        set(value) {
-            readSliderModeValue = value
-        }
-
-    fun encodeReadMenuCustomIcons(value: Map<String, String>): String {
-        return GSON.toJson(value.filterValues { it.isNotBlank() })
-    }
-
-    private fun parseReadMenuCustomIcons(value: String?): Map<String, String> {
-        if (value.isNullOrBlank()) {
-            return emptyMap()
-        }
-        return GSON.fromJsonObject<Map<String, String>>(value).getOrNull()
-            ?.filterValues { it.isNotBlank() }
-            ?: emptyMap()
-    }
-    
     val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
     val regexColorRules: ArrayList<RegexColorRule> get() = durConfig.regexColorRules
 
 
     fun saveRegexColorRules() {
     fun saveRegexColorRules() {
         save()
         save()
     }
     }
 
 
-    /**
-     * 两端对齐
-     */
-    val textFullJustify get() = textFullJustifyValue
-
-    /**
-     * 底部对齐
-     */
-    val textBottomJustify get() = textBottomJustifyValue
-    var hideStatusBar: Boolean
-        get() = hideStatusBarValue
-        set(value) {
-            hideStatusBarValue = value
-        }
-    var hideNavigationBar: Boolean
-        get() = hideNavigationBarValue
-        set(value) {
-            hideNavigationBarValue = value
-        }
-    var useZhLayout: Boolean
-        get() = useZhLayoutValue
-        set(value) {
-            useZhLayoutValue = value
-        }
-
     val config get() = if (shareLayout) shareConfig else durConfig
     val config get() = if (shareLayout) shareConfig else durConfig
 
 
     var bgAlpha: Int
     var bgAlpha: Int

+ 5 - 4
app/src/main/java/io/legado/app/service/AudioPlayService.kt

@@ -31,6 +31,7 @@ import io.legado.app.constant.NotificationId
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.exoplayer.ExoPlayerHelper
 import io.legado.app.help.exoplayer.ExoPlayerHelper
 import io.legado.app.help.glide.ImageLoader
 import io.legado.app.help.glide.ImageLoader
@@ -469,7 +470,7 @@ class AudioPlayService : BaseService(),
     @SuppressLint("UnspecifiedImmutableFlag")
     @SuppressLint("UnspecifiedImmutableFlag")
     private fun initMediaSession() {
     private fun initMediaSession() {
         mediaSessionCompat = MediaSessionCompat(this, "readAloud")
         mediaSessionCompat = MediaSessionCompat(this, "readAloud")
-        if (AppConfig.systemMediaControlCompatibilityChange) {
+        if (ReadConfig.systemMediaControlCompatibilityChange) {
             mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() {
             mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() {
                 override fun onSeekTo(pos: Long) {
                 override fun onSeekTo(pos: Long) {
                     position = pos.toInt()
                     position = pos.toInt()
@@ -534,7 +535,7 @@ class AudioPlayService : BaseService(),
      * 音频焦点变化
      * 音频焦点变化
      */
      */
     override fun onAudioFocusChange(focusChange: Int) {
     override fun onAudioFocusChange(focusChange: Int) {
-        if (AppConfig.ignoreAudioFocus) {
+        if (ReadConfig.ignoreAudioFocus) {
             AppLog.put("忽略音频焦点处理(有声)")
             AppLog.put("忽略音频焦点处理(有声)")
             return
             return
         }
         }
@@ -630,7 +631,7 @@ class AudioPlayService : BaseService(),
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
         mediaStyle.setShowActionsInCompactView(1,2,4)
         mediaStyle.setShowActionsInCompactView(1,2,4)
-        if (AppConfig.systemMediaControlCompatibilityChange) {
+        if (ReadConfig.systemMediaControlCompatibilityChange) {
             //fix #4090 android 14 can not show play control in lock screen
             //fix #4090 android 14 can not show play control in lock screen
             mediaStyle.setMediaSession(mediaSessionCompat?.sessionToken)
             mediaStyle.setMediaSession(mediaSessionCompat?.sessionToken)
         }
         }
@@ -669,7 +670,7 @@ class AudioPlayService : BaseService(),
      * @return 音频焦点
      * @return 音频焦点
      */
      */
     private fun requestFocus(): Boolean {
     private fun requestFocus(): Boolean {
-        if (AppConfig.ignoreAudioFocus) {
+        if (ReadConfig.ignoreAudioFocus) {
             return true
             return true
         }
         }
         return MediaHelp.requestFocus(mFocusRequest)
         return MediaHelp.requestFocus(mFocusRequest)

+ 13 - 12
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -36,6 +36,7 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Status
 import io.legado.app.constant.Status
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.glide.ImageLoader
 import io.legado.app.help.glide.ImageLoader
 import io.legado.app.lib.permission.Permissions
 import io.legado.app.lib.permission.Permissions
@@ -92,7 +93,7 @@ abstract class BaseReadAloudService : BaseService(),
 
 
     }
     }
 
 
-    private val useWakeLock = appCtx.getPrefBoolean(PreferKey.readAloudWakeLock, false)
+    private val useWakeLock = ReadConfig.readAloudWakeLock
     private val wakeLock by lazy {
     private val wakeLock by lazy {
         powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService")
         powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService")
             .apply {
             .apply {
@@ -151,9 +152,9 @@ abstract class BaseReadAloudService : BaseService(),
         initBroadcastReceiver()
         initBroadcastReceiver()
         initPhoneStateListener()
         initPhoneStateListener()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
-        setTimer(AppConfig.ttsTimer)
-        if (AppConfig.ttsTimer > 0) {
-            toastOnUi("朗读定时 ${AppConfig.ttsTimer} 分钟")
+        setTimer(ReadConfig.ttsTimer)
+        if (ReadConfig.ttsTimer > 0) {
+            toastOnUi("朗读定时 ${ReadConfig.ttsTimer} 分钟")
         }
         }
         execute {
         execute {
             ImageLoader
             ImageLoader
@@ -238,7 +239,7 @@ abstract class BaseReadAloudService : BaseService(),
                 return@execute
                 return@execute
             }
             }
             readAloudNumber = textChapter.getReadLength(pageIndex) + startPos
             readAloudNumber = textChapter.getReadLength(pageIndex) + startPos
-            readAloudByPage = getPrefBoolean(PreferKey.readAloudByPage)
+            readAloudByPage = ReadConfig.readAloudByPage
             contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0)
             contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0)
                 .split("\n")
                 .split("\n")
                 .filter { it.isNotEmpty() }
                 .filter { it.isNotEmpty() }
@@ -429,7 +430,7 @@ abstract class BaseReadAloudService : BaseService(),
      * @return 音频焦点
      * @return 音频焦点
      */
      */
     fun requestFocus(): Boolean {
     fun requestFocus(): Boolean {
-        if (AppConfig.ignoreAudioFocus) {
+        if (ReadConfig.ignoreAudioFocus) {
             return true
             return true
         }
         }
         val requestFocus = MediaHelp.requestFocus(mFocusRequest)
         val requestFocus = MediaHelp.requestFocus(mFocusRequest)
@@ -495,7 +496,7 @@ abstract class BaseReadAloudService : BaseService(),
      */
      */
     @SuppressLint("UnspecifiedImmutableFlag")
     @SuppressLint("UnspecifiedImmutableFlag")
     private fun initMediaSession() {
     private fun initMediaSession() {
-        if (AppConfig.systemMediaControlCompatibilityChange) {
+        if (ReadConfig.systemMediaControlCompatibilityChange) {
             mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
             mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
                 override fun onPlay() {
                 override fun onPlay() {
                     resumeReadAloud()
                     resumeReadAloud()
@@ -506,7 +507,7 @@ abstract class BaseReadAloudService : BaseService(),
                 }
                 }
 
 
                 override fun onSkipToNext() {
                 override fun onSkipToNext() {
-                    if (getPrefBoolean("mediaButtonPerNext", false)) {
+                    if (ReadConfig.mediaButtonPerNext) {
                         nextChapter()
                         nextChapter()
                     } else {
                     } else {
                         nextP()
                         nextP()
@@ -514,7 +515,7 @@ abstract class BaseReadAloudService : BaseService(),
                 }
                 }
 
 
                 override fun onSkipToPrevious() {
                 override fun onSkipToPrevious() {
-                    if (getPrefBoolean("mediaButtonPerNext", false)) {
+                    if (ReadConfig.mediaButtonPerNext) {
                         prevChapter()
                         prevChapter()
                     } else {
                     } else {
                         prevP()
                         prevP()
@@ -558,7 +559,7 @@ abstract class BaseReadAloudService : BaseService(),
      * 音频焦点变化
      * 音频焦点变化
      */
      */
     override fun onAudioFocusChange(focusChange: Int) {
     override fun onAudioFocusChange(focusChange: Int) {
-        if (AppConfig.ignoreAudioFocus) {
+        if (ReadConfig.ignoreAudioFocus) {
             AppLog.put("忽略音频焦点处理(TTS)")
             AppLog.put("忽略音频焦点处理(TTS)")
             return
             return
         }
         }
@@ -606,7 +607,7 @@ abstract class BaseReadAloudService : BaseService(),
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
     private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
         val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
             .setShowActionsInCompactView(1, 2, 4)
             .setShowActionsInCompactView(1, 2, 4)
-        if (AppConfig.systemMediaControlCompatibilityChange) {
+        if (ReadConfig.systemMediaControlCompatibilityChange) {
             //fix #4090 android 14 can not show play control in lock screen
             //fix #4090 android 14 can not show play control in lock screen
             mediaStyle.setMediaSession(mediaSessionCompat.sessionToken)
             mediaStyle.setMediaSession(mediaSessionCompat.sessionToken)
         }
         }
@@ -720,7 +721,7 @@ abstract class BaseReadAloudService : BaseService(),
     }
     }
 
 
     private fun initPhoneStateListener() {
     private fun initPhoneStateListener() {
-        val needRegister = AppConfig.ignoreAudioFocus && AppConfig.pauseReadAloudWhilePhoneCalls
+        val needRegister = ReadConfig.ignoreAudioFocus && ReadConfig.pauseReadAloudWhilePhoneCalls
         if (needRegister && registeredPhoneStateListener) {
         if (needRegister && registeredPhoneStateListener) {
             return
             return
         }
         }

+ 4 - 3
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -35,6 +35,7 @@ import io.legado.app.data.entities.HttpTTS
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.exoplayer.InputStreamDataSource
 import io.legado.app.help.exoplayer.InputStreamDataSource
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClient
@@ -128,7 +129,7 @@ class HttpReadAloudService : BaseReadAloudService(),
             ReadBook.readAloud()
             ReadBook.readAloud()
         } else {
         } else {
             super.play()
             super.play()
-            if (AppConfig.streamReadAloudAudio) {
+            if (ReadConfig.streamReadAloudAudio) {
                 downloadAndPlayAudiosStream()
                 downloadAndPlayAudiosStream()
             } else {
             } else {
                 downloadAndPlayAudios()
                 downloadAndPlayAudios()
@@ -572,7 +573,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         downloadTask?.cancel()
         downloadTask?.cancel()
         exoPlayer.stop()
         exoPlayer.stop()
         speechRate = AppConfig.speechRatePlay + 5
         speechRate = AppConfig.speechRatePlay + 5
-        if (AppConfig.streamReadAloudAudio) {
+        if (ReadConfig.streamReadAloudAudio) {
             downloadAndPlayAudiosStream()
             downloadAndPlayAudiosStream()
         } else {
         } else {
             downloadAndPlayAudios()
             downloadAndPlayAudios()
@@ -650,7 +651,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
     }
 
 
     private fun deleteCurrentSpeakFile() {
     private fun deleteCurrentSpeakFile() {
-        if (AppConfig.streamReadAloudAudio) {
+        if (ReadConfig.streamReadAloudAudio) {
             return
             return
         }
         }
         val mediaItem = exoPlayer.currentMediaItem ?: return
         val mediaItem = exoPlayer.currentMediaItem ?: return

+ 3 - 2
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -10,6 +10,7 @@ import io.legado.app.constant.AppPattern
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfig
+import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.lib.dialogs.SelectItem
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadAloud
@@ -152,13 +153,13 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
      * 更新朗读速度
      * 更新朗读速度
      */
      */
     override fun upSpeechRate(reset: Boolean) {
     override fun upSpeechRate(reset: Boolean) {
-        if (AppConfig.ttsFlowSys) {
+        if (ReadConfig.ttsFollowSys) {
             if (reset) {
             if (reset) {
                 clearTTS()
                 clearTTS()
                 initTts()
                 initTts()
             }
             }
         } else {
         } else {
-            val speechRate = (AppConfig.ttsSpeechRate + 5) / 10f
+            val speechRate = (ReadConfig.ttsSpeechRate + 5) / 10f
             textToSpeech?.setSpeechRate(speechRate)
             textToSpeech?.setSpeechRate(speechRate)
         }
         }
     }
     }

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

@@ -9,6 +9,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSource
+import io.legado.app.data.entities.HttpTTS
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.book.searchContent.SearchResult
@@ -134,6 +135,7 @@ data class ReadBookUiState(
     val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
     val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
     val selectedTtsEngine: String? = null,
     val selectedTtsEngine: String? = null,
     val speakEngineName: String = "",
     val speakEngineName: String = "",
+    val editingHttpTts: HttpTTS? = null,
     val preDownloadNum: Int = 10,
     val preDownloadNum: Int = 10,
     val audioCacheCleanTime: Int = 10,
     val audioCacheCleanTime: Int = 10,
     // Read aloud config
     // Read aloud config
@@ -416,6 +418,12 @@ sealed interface ReadBookIntent {
     data class ApplySpeakEngine(val value: String?) : ReadBookIntent
     data class ApplySpeakEngine(val value: String?) : ReadBookIntent
     data class ApplyPreDownloadNum(val value: Int) : ReadBookIntent
     data class ApplyPreDownloadNum(val value: Int) : ReadBookIntent
     data class ApplyAudioCacheCleanTime(val value: Int) : ReadBookIntent
     data class ApplyAudioCacheCleanTime(val value: Int) : ReadBookIntent
+    data class EditHttpTts(val engineId: Long? = null) : ReadBookIntent
+    data class DeleteHttpTts(val engineId: Long) : ReadBookIntent
+    data class SaveHttpTts(val httpTTS: HttpTTS) : ReadBookIntent
+    data class ApplySpeakEnginePerBook(val value: String?) : ReadBookIntent
+    data class ImportHttpTtsJson(val json: String) : ReadBookIntent
+    data object ExportAllHttpTts : ReadBookIntent
     data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent
     data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
@@ -567,6 +575,9 @@ sealed interface ReadBookEffect {
     data object UnregisterNetworkListener : ReadBookEffect
     data object UnregisterNetworkListener : ReadBookEffect
     data object OpenBooksDirPicker : ReadBookEffect
     data object OpenBooksDirPicker : ReadBookEffect
     data object BackupNow : ReadBookEffect
     data object BackupNow : ReadBookEffect
+
+    // Export — Activity handles file writing
+    data class ExportJson(val json: String) : ReadBookEffect
 }
 }
 
 
 @Immutable
 @Immutable
@@ -590,6 +601,7 @@ sealed interface ReadBookSheet {
     data object BgTextConfig : ReadBookSheet
     data object BgTextConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
     data object SpeakEngineConfig : ReadBookSheet
     data object SpeakEngineConfig : ReadBookSheet
+    data class HttpTtsEdit(val engineId: Long? = null) : ReadBookSheet
     data object PreDownloadConfig : ReadBookSheet
     data object PreDownloadConfig : ReadBookSheet
     data object AudioCacheCleanConfig : ReadBookSheet
     data object AudioCacheCleanConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet
     data object ClickActionConfig : ReadBookSheet

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

@@ -767,6 +767,7 @@ class ReadBookController(
             is ReadBookEffect.OpenTitleBarCustomIconPicker,
             is ReadBookEffect.OpenTitleBarCustomIconPicker,
             is ReadBookEffect.OpenSystemTtsSettings,
             is ReadBookEffect.OpenSystemTtsSettings,
             is ReadBookEffect.TtsCacheCleared,
             is ReadBookEffect.TtsCacheCleared,
+            is ReadBookEffect.ExportJson,
             // DB query + bookmark effects — handled by ViewModel, ignored here
             // DB query + bookmark effects — handled by ViewModel, ignored here
             is ReadBookEffect.MenuChangeSource,
             is ReadBookEffect.MenuChangeSource,
             is ReadBookEffect.MenuBookChangeSource,
             is ReadBookEffect.MenuBookChangeSource,

+ 11 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -30,6 +30,7 @@ import io.legado.app.ui.book.read.sheet.ReadAloudNumberConfigSheet
 import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
 import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
 import io.legado.app.ui.book.read.sheet.ShadowSetSheet
 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.SimulatedReadingSheet
+import io.legado.app.ui.book.read.sheet.HttpTtsEditSheet
 import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet
 import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet
 import io.legado.app.ui.book.read.sheet.TitleBarIconSheet
 import io.legado.app.ui.book.read.sheet.TitleBarIconSheet
 import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
 import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
@@ -198,13 +199,20 @@ fun ReadBookScreen(
     )
     )
     SpeakEngineConfigSheet(
     SpeakEngineConfigSheet(
         show = state.activeSheet is ReadBookSheet.SpeakEngineConfig,
         show = state.activeSheet is ReadBookSheet.SpeakEngineConfig,
-        items = state.ttsEngineItems,
-        selectedValue = state.selectedTtsEngine,
-        onSelect = { onIntent(ReadBookIntent.ApplySpeakEngine(it)) },
+        state = state,
+        onIntent = onIntent,
         onDismissRequest = {
         onDismissRequest = {
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
             onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
         },
         },
     )
     )
+    HttpTtsEditSheet(
+        show = state.activeSheet is ReadBookSheet.HttpTtsEdit,
+        httpTTS = state.editingHttpTts,
+        onIntent = onIntent,
+        onDismissRequest = {
+            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SpeakEngineConfig))
+        },
+    )
     ReadAloudNumberConfigSheet(
     ReadAloudNumberConfigSheet(
         show = state.activeSheet is ReadBookSheet.PreDownloadConfig,
         show = state.activeSheet is ReadBookSheet.PreDownloadConfig,
         title = stringResource(R.string.read_aloud_preload),
         title = stringResource(R.string.read_aloud_preload),

+ 85 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -20,6 +20,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.BookProgress
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadPreferences
@@ -590,6 +591,90 @@ class ReadBookViewModel(
                     )
                     )
                 }
                 }
             }
             }
+
+            is ReadBookIntent.EditHttpTts -> {
+                if (intent.engineId == null) {
+                    _uiState.update {
+                        it.copy(
+                            editingHttpTts = HttpTTS(),
+                            activeSheet = ReadBookSheet.HttpTtsEdit(),
+                        )
+                    }
+                } else {
+                    execute {
+                        appDb.httpTTSDao.get(intent.engineId)
+                    }.onSuccess { tts ->
+                        _uiState.update {
+                            it.copy(
+                                editingHttpTts = tts,
+                                activeSheet = ReadBookSheet.HttpTtsEdit(intent.engineId),
+                            )
+                        }
+                    }
+                }
+            }
+
+            is ReadBookIntent.DeleteHttpTts -> {
+                execute {
+                    appDb.httpTTSDao.get(intent.engineId)?.let { tts ->
+                        appDb.httpTTSDao.delete(tts)
+                    }
+                }.onSuccess {
+                    loadTtsEngineItems()
+                    if (ReadAloud.ttsEngine == intent.engineId.toString()) {
+                        AppConfig.ttsEngine = null
+                        ReadAloud.upReadAloudClass()
+                    }
+                }
+            }
+
+            is ReadBookIntent.SaveHttpTts -> {
+                execute {
+                    appDb.httpTTSDao.insert(intent.httpTTS)
+                }.onSuccess {
+                    loadTtsEngineItems()
+                    _uiState.update {
+                        it.copy(
+                            editingHttpTts = null,
+                            activeSheet = ReadBookSheet.SpeakEngineConfig,
+                        )
+                    }
+                }
+            }
+
+            is ReadBookIntent.ApplySpeakEnginePerBook -> {
+                ReadBook.book?.setTtsEngine(intent.value)
+                _uiState.update {
+                    it.copy(
+                        selectedTtsEngine = ReadAloud.ttsEngine,
+                        speakEngineName = computeSpeakEngineName(),
+                        activeSheet = ReadBookSheet.ReadAloudConfig,
+                    )
+                }
+            }
+
+            is ReadBookIntent.ImportHttpTtsJson -> {
+                execute {
+                    HttpTTS.fromJsonArray(intent.json).getOrDefault(arrayListOf())
+                }.onSuccess { list ->
+                    if (list.isNotEmpty()) {
+                        execute {
+                            appDb.httpTTSDao.insert(*list.toTypedArray())
+                        }.onSuccess {
+                            loadTtsEngineItems()
+                        }
+                    }
+                }
+            }
+
+            is ReadBookIntent.ExportAllHttpTts -> {
+                execute {
+                    GSON.toJson(appDb.httpTTSDao.all)
+                }.onSuccess { json ->
+                    _effects.tryEmit(ReadBookEffect.ExportJson(json))
+                }
+            }
+
             is ReadBookIntent.SetReadAloudIgnoreAudioFocus -> {
             is ReadBookIntent.SetReadAloudIgnoreAudioFocus -> {
                 viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) }
                 viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) }
             }
             }
@@ -1191,7 +1276,6 @@ class ReadBookViewModel(
             readSettingsRepository.preferences.collect { preferences ->
             readSettingsRepository.preferences.collect { preferences ->
                 val old = previous
                 val old = previous
                 previous = preferences
                 previous = preferences
-                ReadBookConfig.syncPreferences(preferences)
                 AppConfig.syncReadPreferences(preferences)
                 AppConfig.syncReadPreferences(preferences)
                 _readPreferences.value = preferences
                 _readPreferences.value = preferences
                 if (!preferences.hasMenuClickArea()) {
                 if (!preferences.hasMenuClickArea()) {

+ 280 - 6
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt

@@ -1,24 +1,56 @@
 package io.legado.app.ui.book.read.sheet
 package io.legado.app.ui.book.read.sheet
 
 
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Description
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+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.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.R
+import io.legado.app.data.entities.HttpTTS
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookTtsEngineItem
 import io.legado.app.ui.book.read.ReadBookTtsEngineItem
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.read.ReadBookUiState
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+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.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
+import io.legado.app.utils.GSON
 
 
 @Composable
 @Composable
 fun ReadAloudConfigSheet(
 fun ReadAloudConfigSheet(
@@ -125,32 +157,274 @@ fun ReadAloudConfigSheet(
 @Composable
 @Composable
 fun SpeakEngineConfigSheet(
 fun SpeakEngineConfigSheet(
     show: Boolean,
     show: Boolean,
-    items: List<ReadBookTtsEngineItem>,
-    selectedValue: String?,
-    onSelect: (String?) -> Unit,
+    state: ReadBookUiState,
+    onIntent: (ReadBookIntent) -> Unit,
     onDismissRequest: () -> Unit,
     onDismissRequest: () -> Unit,
 ) {
 ) {
+    val clipboardManager = LocalClipboardManager.current
+    val items = state.ttsEngineItems
+    val selectedValue = state.selectedTtsEngine
+
     AppModalBottomSheet(
     AppModalBottomSheet(
         show = show,
         show = show,
         onDismissRequest = onDismissRequest,
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.speak_engine),
         title = stringResource(R.string.speak_engine),
+        startAction = {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(2.dp)
+            ) {
+                SmallTonalButton(
+                    onClick = { onIntent(ReadBookIntent.EditHttpTts()) },
+                    icon = Icons.Default.Add
+                )
+                //TODO: 导入
+                SmallTonalButton(
+                    onClick = {
+                        clipboardManager.getText()?.text?.let { text ->
+                            if (text.isNotBlank()) {
+                                onIntent(ReadBookIntent.ImportHttpTtsJson(text))
+                            }
+                        }
+                    },
+                    icon = Icons.Default.Description
+                )
+                SmallTonalButton(
+                    onClick = { onIntent(ReadBookIntent.ExportAllHttpTts) },
+                    icon = Icons.Default.FileDownload
+                )
+            }
+        },
+        endAction = {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(2.dp)
+            ) {
+                SmallTonalButton(
+                    onClick = { onIntent(ReadBookIntent.ApplySpeakEnginePerBook(selectedValue)) },
+                    text = stringResource(R.string.book)
+                )
+                SmallTonalButton(
+                    onClick = { onIntent(ReadBookIntent.ApplySpeakEngine(selectedValue)) },
+                    text = stringResource(R.string.general)
+                )
+            }
+        }
     ) {
     ) {
         LazyColumn(
         LazyColumn(
             modifier = Modifier
             modifier = Modifier
                 .fillMaxWidth()
                 .fillMaxWidth()
-                .padding(bottom = 16.dp),
+                .weight(1f, fill = false)
+                .padding(bottom = 8.dp),
         ) {
         ) {
             items(items) { item ->
             items(items) { item ->
+                val isHttpTts = item.value != null
+                val isSelected = item.value == selectedValue
                 TinyClickableSettingItem(
                 TinyClickableSettingItem(
                     title = item.title,
                     title = item.title,
-                    description = if (item.value == selectedValue) {
+                    description = if (isSelected) {
                         stringResource(R.string.default_version)
                         stringResource(R.string.default_version)
                     } else {
                     } else {
                         null
                         null
                     },
                     },
-                    onClick = { onSelect(item.value) },
+                    onClick = { onIntent(ReadBookIntent.ApplySpeakEngine(item.value)) },
+                    trailingContent = if (isHttpTts) {
+                        {
+                            Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+                                SmallTonalButton(
+                                    onClick = { onIntent(ReadBookIntent.EditHttpTts(item.value!!.toLong())) },
+                                    icon = Icons.Default.Edit,
+                                )
+                                SmallTonalButton(
+                                    onClick = { onIntent(ReadBookIntent.DeleteHttpTts(item.value!!.toLong())) },
+                                    icon = Icons.Default.Delete,
+                                )
+                            }
+                        }
+                    } else null,
+                )
+            }
+        }
+
+    }
+}
+
+@Composable
+fun HttpTtsEditSheet(
+    show: Boolean,
+    httpTTS: HttpTTS?,
+    onIntent: (ReadBookIntent) -> Unit,
+    onDismissRequest: () -> Unit,
+) {
+    val tts = httpTTS ?: return
+    val clipboardManager = LocalClipboardManager.current
+    var name by remember(httpTTS) { mutableStateOf(tts.name) }
+    var url by remember(httpTTS) { mutableStateOf(tts.url) }
+    var contentType by remember(httpTTS) { mutableStateOf(tts.contentType ?: "") }
+    var concurrentRate by remember(httpTTS) { mutableStateOf(tts.concurrentRate ?: "0") }
+    var header by remember(httpTTS) { mutableStateOf(tts.header ?: "") }
+    var loginUrl by remember(httpTTS) { mutableStateOf(tts.loginUrl ?: "") }
+    var loginUi by remember(httpTTS) { mutableStateOf(tts.loginUi ?: "") }
+    var loginCheckJs by remember(httpTTS) { mutableStateOf(tts.loginCheckJs ?: "") }
+    var jsLib by remember(httpTTS) { mutableStateOf(tts.jsLib ?: "") }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.speak_engine),
+        startAction = {
+            var expanded by remember { mutableStateOf(false) }
+            Box {
+                SmallTonalButton(
+                    onClick = { expanded = true },
+                    icon = Icons.Default.MoreVert
+                )
+                RoundDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.copy_text),
+                        onClick = {
+                            expanded = false
+                            val json = GSON.toJson(
+                                tts.copy(
+                                    name = name, url = url, contentType = contentType,
+                                    concurrentRate = concurrentRate, header = header,
+                                    loginUrl = loginUrl, loginUi = loginUi,
+                                    loginCheckJs = loginCheckJs, jsLib = jsLib,
+                                )
+                            )
+                            clipboardManager.setText(AnnotatedString(json))
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.paste_source),
+                        onClick = {
+                            expanded = false
+                            clipboardManager.getText()?.text?.let { text ->
+                                HttpTTS.fromJson(text).getOrNull()?.let { imported ->
+                                    name = imported.name
+                                    url = imported.url
+                                    contentType = imported.contentType ?: ""
+                                    concurrentRate = imported.concurrentRate ?: "0"
+                                    header = imported.header ?: ""
+                                    loginUrl = imported.loginUrl ?: ""
+                                    loginUi = imported.loginUi ?: ""
+                                    loginCheckJs = imported.loginCheckJs ?: ""
+                                    jsLib = imported.jsLib ?: ""
+                                }
+                            }
+                        },
+                    )
+                }
+            }
+        },
+        endAction = {
+            SmallTonalButton(
+                icon = Icons.Default.Save,
+                onClick = {
+                    onIntent(
+                        ReadBookIntent.SaveHttpTts(
+                            tts.copy(
+                                name = name, url = url,
+                                contentType = contentType.ifBlank { null },
+                                concurrentRate = concurrentRate,
+                                header = header.ifBlank { null },
+                                loginUrl = loginUrl.ifBlank { null },
+                                loginUi = loginUi.ifBlank { null },
+                                loginCheckJs = loginCheckJs.ifBlank { null },
+                                jsLib = jsLib.ifBlank { null },
+                            )
+                        )
+                    )
+                },
+            )
+        },
+    ) {
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            item {
+                AppTextField(
+                    value = name,
+                    onValueChange = { name = it },
+                    label = stringResource(R.string.name),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = url,
+                    onValueChange = { url = it },
+                    label = "URL",
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = contentType,
+                    onValueChange = { contentType = it },
+                    label = "Content-Type",
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = concurrentRate,
+                    onValueChange = { concurrentRate = it },
+                    label = stringResource(R.string.concurrent_rate),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = header,
+                    onValueChange = { header = it },
+                    label = stringResource(R.string.source_http_header),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = loginUrl,
+                    onValueChange = { loginUrl = it },
+                    label = stringResource(R.string.login_url),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = loginUi,
+                    onValueChange = { loginUi = it },
+                    label = stringResource(R.string.login_ui),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = loginCheckJs,
+                    onValueChange = { loginCheckJs = it },
+                    label = stringResource(R.string.login_check_js),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+            }
+            item {
+                AppTextField(
+                    value = jsLib,
+                    onValueChange = { jsLib = it },
+                    label = "jsLib",
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth(),
                 )
                 )
             }
             }
+            item { Spacer(Modifier.height(16.dp)) }
         }
         }
     }
     }
 }
 }

+ 48 - 71
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudSheet.kt

@@ -7,23 +7,27 @@ import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.width
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.FilledTonalIconButton
-import androidx.compose.material3.Icon
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.List
+import androidx.compose.material.icons.filled.Pause
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Settings
+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.MaterialTheme
 import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
 import androidx.compose.material3.Text
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.res.painterResource
 import androidx.compose.ui.res.stringResource
 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.R
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.read.ReadBookUiState
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 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.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
@@ -77,55 +81,34 @@ fun ReadAloudContent(
             horizontalArrangement = Arrangement.Center,
             horizontalArrangement = Arrangement.Center,
             verticalAlignment = Alignment.CenterVertically,
             verticalAlignment = Alignment.CenterVertically,
         ) {
         ) {
-            FilledTonalIconButton(
+            MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ReadAloudPrevParagraph) },
                 onClick = { onIntent(ReadBookIntent.ReadAloudPrevParagraph) },
-            ) {
-                Icon(
-                    painter = painterResource(R.drawable.ic_skip_previous),
-                    contentDescription = stringResource(R.string.prev_sentence),
-                )
-            }
+                icon = Icons.Default.SkipPrevious,
+                contentDescription = stringResource(R.string.prev_sentence),
+            )
             Spacer(Modifier.width(6.dp))
             Spacer(Modifier.width(6.dp))
-            FilledTonalIconButton(
-                onClick = {
-                    onIntent(ReadBookIntent.ReadAloudTogglePause)
-                },
-                modifier = Modifier.size(48.dp),
-            ) {
-                Icon(
-                    painter = painterResource(
-                        if (state.isReadAloudPaused) R.drawable.ic_play else R.drawable.ic_pause
-                    ),
-                    contentDescription = stringResource(
-                        if (state.isReadAloudPaused) R.string.audio_play else R.string.pause
-                    ),
-                )
-            }
+            MediumTonalButton(
+                onClick = { onIntent(ReadBookIntent.ReadAloudTogglePause) },
+                icon = if (state.isReadAloudPaused) Icons.Default.PlayArrow else Icons.Default.Pause,
+                contentDescription = stringResource(
+                    if (state.isReadAloudPaused) R.string.audio_play else R.string.pause
+                ),
+            )
             Spacer(Modifier.width(6.dp))
             Spacer(Modifier.width(6.dp))
-            FilledTonalIconButton(
+            MediumTonalButton(
                 onClick = {
                 onClick = {
                     onIntent(ReadBookIntent.ReadAloudStop)
                     onIntent(ReadBookIntent.ReadAloudStop)
                     onDismissRequest()
                     onDismissRequest()
                 },
                 },
-                modifier = Modifier.size(48.dp),
-            ) {
-                Icon(
-                    painter = painterResource(R.drawable.ic_stop_black_24dp),
-                    contentDescription = stringResource(R.string.stop),
-                )
-            }
+                icon = Icons.Default.Stop,
+                contentDescription = stringResource(R.string.stop),
+            )
             Spacer(Modifier.width(6.dp))
             Spacer(Modifier.width(6.dp))
-            FilledTonalButton(
+            MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ReadAloudNextParagraph) },
                 onClick = { onIntent(ReadBookIntent.ReadAloudNextParagraph) },
-            ) {
-                Icon(
-                    painter = painterResource(R.drawable.ic_skip_next),
-                    contentDescription = null,
-                    modifier = Modifier.size(18.dp),
-                )
-                Spacer(Modifier.width(4.dp))
-                Text(stringResource(R.string.next_sentence))
-            }
+                icon = Icons.Default.SkipNext,
+                text = stringResource(R.string.next_sentence),
+            )
         }
         }
 
 
         Spacer(Modifier.height(12.dp))
         Spacer(Modifier.height(12.dp))
@@ -147,26 +130,21 @@ fun ReadAloudContent(
             modifier = Modifier.fillMaxWidth(),
             modifier = Modifier.fillMaxWidth(),
             horizontalArrangement = Arrangement.spacedBy(8.dp),
             horizontalArrangement = Arrangement.spacedBy(8.dp),
         ) {
         ) {
-            OutlinedButton(
+            MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ReadAloudPrevChapter) },
                 onClick = { onIntent(ReadBookIntent.ReadAloudPrevChapter) },
+                text = stringResource(R.string.previous_chapter),
                 modifier = Modifier.weight(1f),
                 modifier = Modifier.weight(1f),
-            ) {
-                Text(stringResource(R.string.previous_chapter))
-            }
-            FilledTonalButton(
-                onClick = {
-                    onIntent(ReadBookIntent.SetReadAloudTtsTimer(timerMinute))
-                },
+            )
+            MediumTonalButton(
+                onClick = { onIntent(ReadBookIntent.SetReadAloudTtsTimer(timerMinute)) },
+                text = stringResource(R.string.timer_m, timerMinute),
                 modifier = Modifier.weight(1f),
                 modifier = Modifier.weight(1f),
-            ) {
-                Text(stringResource(R.string.timer_m, timerMinute))
-            }
-            OutlinedButton(
+            )
+            MediumTonalButton(
                 onClick = { onIntent(ReadBookIntent.ReadAloudNextChapter) },
                 onClick = { onIntent(ReadBookIntent.ReadAloudNextChapter) },
+                text = stringResource(R.string.next_chapter),
                 modifier = Modifier.weight(1f),
                 modifier = Modifier.weight(1f),
-            ) {
-                Text(stringResource(R.string.next_chapter))
-            }
+            )
         }
         }
 
 
         Spacer(Modifier.height(12.dp))
         Spacer(Modifier.height(12.dp))
@@ -197,17 +175,17 @@ fun ReadAloudContent(
             horizontalArrangement = Arrangement.SpaceEvenly,
             horizontalArrangement = Arrangement.SpaceEvenly,
         ) {
         ) {
             ActionButton(
             ActionButton(
-                icon = R.drawable.ic_toc,
+                icon = Icons.AutoMirrored.Filled.List,
                 label = stringResource(R.string.chapter_list),
                 label = stringResource(R.string.chapter_list),
                 onClick = onOpenChapterList,
                 onClick = onOpenChapterList,
             )
             )
             ActionButton(
             ActionButton(
-                icon = R.drawable.ic_visibility_off,
+                icon = Icons.Default.VisibilityOff,
                 label = stringResource(R.string.to_backstage),
                 label = stringResource(R.string.to_backstage),
                 onClick = onGoToBackground,
                 onClick = onGoToBackground,
             )
             )
             ActionButton(
             ActionButton(
-                icon = R.drawable.ic_settings,
+                icon = Icons.Default.Settings,
                 label = stringResource(R.string.setting),
                 label = stringResource(R.string.setting),
                 onClick = onShowReadAloudConfig,
                 onClick = onShowReadAloudConfig,
             )
             )
@@ -217,19 +195,18 @@ fun ReadAloudContent(
 
 
 @Composable
 @Composable
 private fun ActionButton(
 private fun ActionButton(
-    icon: Int,
+    icon: androidx.compose.ui.graphics.vector.ImageVector,
     label: String,
     label: String,
     onClick: () -> Unit,
     onClick: () -> Unit,
 ) {
 ) {
     Column(
     Column(
         horizontalAlignment = Alignment.CenterHorizontally,
         horizontalAlignment = Alignment.CenterHorizontally,
     ) {
     ) {
-        FilledTonalIconButton(onClick = onClick) {
-            Icon(
-                painter = painterResource(icon),
-                contentDescription = label,
-            )
-        }
+        MediumTonalButton(
+            onClick = onClick,
+            icon = icon,
+            contentDescription = label,
+        )
         Spacer(Modifier.height(4.dp))
         Spacer(Modifier.height(4.dp))
         Text(
         Text(
             text = label,
             text = label,

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

@@ -189,4 +189,56 @@ object ReadConfig {
         PreferKey.nextKeys,
         PreferKey.nextKeys,
         ""
         ""
     )
     )
+
+    // --- Read Aloud ---
+
+    var ignoreAudioFocus by prefDelegate(
+        PreferKey.ignoreAudioFocus,
+        false
+    )
+
+    var pauseReadAloudWhilePhoneCalls by prefDelegate(
+        PreferKey.pauseReadAloudWhilePhoneCalls,
+        false
+    )
+
+    var readAloudWakeLock by prefDelegate(
+        PreferKey.readAloudWakeLock,
+        false
+    )
+
+    var mediaButtonPerNext by prefDelegate(
+        "mediaButtonPerNext",
+        false
+    )
+
+    var readAloudByPage by prefDelegate(
+        PreferKey.readAloudByPage,
+        false
+    )
+
+    var systemMediaControlCompatibilityChange by prefDelegate(
+        PreferKey.systemMediaControlCompatibilityChange,
+        true
+    )
+
+    var streamReadAloudAudio by prefDelegate(
+        PreferKey.streamReadAloudAudio,
+        false
+    )
+
+    var ttsTimer by prefDelegate(
+        PreferKey.ttsTimer,
+        0
+    )
+
+    var ttsFollowSys by prefDelegate(
+        PreferKey.ttsFollowSys,
+        true
+    )
+
+    var ttsSpeechRate by prefDelegate(
+        PreferKey.ttsSpeechRate,
+        5
+    )
 }
 }