fix: 朗读相关

This commit is contained in:
HapeLee
2026-06-09 02:48:12 +08:00
parent f00da95724
commit e28be0301b
14 changed files with 703 additions and 525 deletions
@@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.emptyPreferences
import io.legado.app.constant.PreferKey
import io.legado.app.ui.config.readConfig.ReadConfig
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -42,41 +43,53 @@ class ReadAloudSettingsRepository(
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)
}
suspend fun setReadAloudByMediaButton(value: Boolean) =
suspend fun setReadAloudByMediaButton(value: Boolean) {
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 {
return ReadAloudPreferences(
@@ -376,9 +376,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var ttsFlowSys: Boolean
get() = appCtx.getPrefBoolean(PreferKey.ttsFollowSys, true)
get() = ReadConfig.ttsFollowSys
set(value) {
appCtx.putPrefBoolean(PreferKey.ttsFollowSys, value)
ReadConfig.ttsFollowSys = value
}
val noAnimScrollPage: Boolean
@@ -387,15 +387,15 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
const val defaultSpeechRate = 5
var ttsSpeechRate: Int
get() = appCtx.getPrefInt(PreferKey.ttsSpeechRate, defaultSpeechRate)
get() = ReadConfig.ttsSpeechRate
set(value) {
appCtx.putPrefInt(PreferKey.ttsSpeechRate, value)
ReadConfig.ttsSpeechRate = value
}
var ttsTimer: Int
get() = appCtx.getPrefInt(PreferKey.ttsTimer, 0)
get() = ReadConfig.ttsTimer
set(value) {
appCtx.putPrefInt(PreferKey.ttsTimer, value)
ReadConfig.ttsTimer = value
}
val speechRatePlay: Int get() = if (ttsFlowSys) defaultSpeechRate else ttsSpeechRate
@@ -604,11 +604,17 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
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
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
@@ -618,7 +624,11 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
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?
get() = ReadConfig.doubleHorizontalPage
@@ -940,9 +950,9 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
}
var systemMediaControlCompatibilityChange: Boolean
get() = appCtx.getPrefBoolean(PreferKey.systemMediaControlCompatibilityChange, true)
get() = ReadConfig.systemMediaControlCompatibilityChange
set(value) {
appCtx.putPrefBoolean(PreferKey.systemMediaControlCompatibilityChange, value)
ReadConfig.systemMediaControlCompatibilityChange = value
}
var isPredictiveBackEnabled: Boolean
@@ -7,36 +7,25 @@ import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import androidx.annotation.Keep
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.constant.PageAnim
import io.legado.app.constant.PreferKey
import io.legado.app.constant.ReadMenuBlurMode
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.dataStore
import io.legado.app.help.DefaultData
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.fromJsonObject
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.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 java.io.IOException
import java.io.InputStream
import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty
/**
* 阅读界面配置
@@ -46,6 +35,40 @@ import java.io.InputStream
object ReadBookConfig {
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
const val tipNone = 0
const val tipChapterTitle = 1
@@ -94,23 +117,6 @@ object ReadBookConfig {
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
fun getConfig(index: Int): Config {
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
}
// region Scalar properties (prefDelegate — auto-syncs SP + DataStore)
private var autoReadSpeedValue = appCtx.getPrefInt(PreferKey.autoReadSpeed, 10)
var autoReadSpeed: Int
get() = autoReadSpeedValue
set(value) {
autoReadSpeedValue = value
appCtx.putPrefInt(PreferKey.autoReadSpeed, value)
}
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
get() = if (isComic) comicStyleSelect else readStyleSelect
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)
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)
}
}
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 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"
// endregion
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
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
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
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
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
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
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() })
}
var readMenuBgColor: Int
get() = readMenuBgColorValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = false)
set(value) {
readMenuBgColorValue = value
}
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 readMenuAccentColor: Int
get() = readMenuAccentColorValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = false)
set(value) {
readMenuAccentColorValue = value
}
var readMenuCustomIcons: Map<String, String> by PrefDelegateMap(
PreferKey.readMenuCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
)
var readMenuContainerColor: Int
get() = readMenuContainerColorValue.takeIf { it != 0 } ?: readMenuBgColor
set(value) {
readMenuContainerColorValue = value
}
var titleBarCustomIcons: Map<String, String> by PrefDelegateMap(
PreferKey.titleBarCustomIcons, ::parseReadMenuCustomIcons, ::encodeReadMenuCustomIcons,
)
var readMenuBgColorNight: Int
get() = readMenuBgColorNightValue.takeIf { it != 0 } ?: durConfig.menuBgColor(isNight = true)
set(value) {
readMenuBgColorNightValue = value
}
var readMenuAccentColorNight: Int
get() = readMenuAccentColorNightValue.takeIf { it != 0 } ?: durConfig.menuAccentColor(isNight = true)
set(value) {
readMenuAccentColorNightValue = value
}
var readMenuContainerColorNight: Int
get() = readMenuContainerColorNightValue.takeIf { it != 0 } ?: readMenuBgColorNight
set(value) {
readMenuContainerColorNightValue = value
}
// endregion
val resolvedMenuBgColor: Int
get() {
@@ -400,198 +326,15 @@ object ReadBookConfig {
val resolvedMenuContainerColor: Int
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
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
fun saveRegexColorRules() {
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
var bgAlpha: Int
@@ -31,6 +31,7 @@ import io.legado.app.constant.NotificationId
import io.legado.app.constant.Status
import io.legado.app.help.MediaHelp
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.exoplayer.ExoPlayerHelper
import io.legado.app.help.glide.ImageLoader
@@ -469,7 +470,7 @@ class AudioPlayService : BaseService(),
@SuppressLint("UnspecifiedImmutableFlag")
private fun initMediaSession() {
mediaSessionCompat = MediaSessionCompat(this, "readAloud")
if (AppConfig.systemMediaControlCompatibilityChange) {
if (ReadConfig.systemMediaControlCompatibilityChange) {
mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() {
override fun onSeekTo(pos: Long) {
position = pos.toInt()
@@ -534,7 +535,7 @@ class AudioPlayService : BaseService(),
* 音频焦点变化
*/
override fun onAudioFocusChange(focusChange: Int) {
if (AppConfig.ignoreAudioFocus) {
if (ReadConfig.ignoreAudioFocus) {
AppLog.put("忽略音频焦点处理(有声)")
return
}
@@ -630,7 +631,7 @@ class AudioPlayService : BaseService(),
private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
mediaStyle.setShowActionsInCompactView(1,2,4)
if (AppConfig.systemMediaControlCompatibilityChange) {
if (ReadConfig.systemMediaControlCompatibilityChange) {
//fix #4090 android 14 can not show play control in lock screen
mediaStyle.setMediaSession(mediaSessionCompat?.sessionToken)
}
@@ -669,7 +670,7 @@ class AudioPlayService : BaseService(),
* @return 音频焦点
*/
private fun requestFocus(): Boolean {
if (AppConfig.ignoreAudioFocus) {
if (ReadConfig.ignoreAudioFocus) {
return true
}
return MediaHelp.requestFocus(mFocusRequest)
@@ -36,6 +36,7 @@ import io.legado.app.constant.PreferKey
import io.legado.app.constant.Status
import io.legado.app.help.MediaHelp
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.glide.ImageLoader
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 {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService")
.apply {
@@ -151,9 +152,9 @@ abstract class BaseReadAloudService : BaseService(),
initBroadcastReceiver()
initPhoneStateListener()
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 {
ImageLoader
@@ -238,7 +239,7 @@ abstract class BaseReadAloudService : BaseService(),
return@execute
}
readAloudNumber = textChapter.getReadLength(pageIndex) + startPos
readAloudByPage = getPrefBoolean(PreferKey.readAloudByPage)
readAloudByPage = ReadConfig.readAloudByPage
contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0)
.split("\n")
.filter { it.isNotEmpty() }
@@ -429,7 +430,7 @@ abstract class BaseReadAloudService : BaseService(),
* @return 音频焦点
*/
fun requestFocus(): Boolean {
if (AppConfig.ignoreAudioFocus) {
if (ReadConfig.ignoreAudioFocus) {
return true
}
val requestFocus = MediaHelp.requestFocus(mFocusRequest)
@@ -495,7 +496,7 @@ abstract class BaseReadAloudService : BaseService(),
*/
@SuppressLint("UnspecifiedImmutableFlag")
private fun initMediaSession() {
if (AppConfig.systemMediaControlCompatibilityChange) {
if (ReadConfig.systemMediaControlCompatibilityChange) {
mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
override fun onPlay() {
resumeReadAloud()
@@ -506,7 +507,7 @@ abstract class BaseReadAloudService : BaseService(),
}
override fun onSkipToNext() {
if (getPrefBoolean("mediaButtonPerNext", false)) {
if (ReadConfig.mediaButtonPerNext) {
nextChapter()
} else {
nextP()
@@ -514,7 +515,7 @@ abstract class BaseReadAloudService : BaseService(),
}
override fun onSkipToPrevious() {
if (getPrefBoolean("mediaButtonPerNext", false)) {
if (ReadConfig.mediaButtonPerNext) {
prevChapter()
} else {
prevP()
@@ -558,7 +559,7 @@ abstract class BaseReadAloudService : BaseService(),
* 音频焦点变化
*/
override fun onAudioFocusChange(focusChange: Int) {
if (AppConfig.ignoreAudioFocus) {
if (ReadConfig.ignoreAudioFocus) {
AppLog.put("忽略音频焦点处理(TTS)")
return
}
@@ -606,7 +607,7 @@ abstract class BaseReadAloudService : BaseService(),
private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle {
val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1, 2, 4)
if (AppConfig.systemMediaControlCompatibilityChange) {
if (ReadConfig.systemMediaControlCompatibilityChange) {
//fix #4090 android 14 can not show play control in lock screen
mediaStyle.setMediaSession(mediaSessionCompat.sessionToken)
}
@@ -720,7 +721,7 @@ abstract class BaseReadAloudService : BaseService(),
}
private fun initPhoneStateListener() {
val needRegister = AppConfig.ignoreAudioFocus && AppConfig.pauseReadAloudWhilePhoneCalls
val needRegister = ReadConfig.ignoreAudioFocus && ReadConfig.pauseReadAloudWhilePhoneCalls
if (needRegister && registeredPhoneStateListener) {
return
}
@@ -35,6 +35,7 @@ import io.legado.app.data.entities.HttpTTS
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.book.BookHelp
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.exoplayer.InputStreamDataSource
import io.legado.app.help.http.okHttpClient
@@ -128,7 +129,7 @@ class HttpReadAloudService : BaseReadAloudService(),
ReadBook.readAloud()
} else {
super.play()
if (AppConfig.streamReadAloudAudio) {
if (ReadConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
@@ -572,7 +573,7 @@ class HttpReadAloudService : BaseReadAloudService(),
downloadTask?.cancel()
exoPlayer.stop()
speechRate = AppConfig.speechRatePlay + 5
if (AppConfig.streamReadAloudAudio) {
if (ReadConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
@@ -650,7 +651,7 @@ class HttpReadAloudService : BaseReadAloudService(),
}
private fun deleteCurrentSpeakFile() {
if (AppConfig.streamReadAloudAudio) {
if (ReadConfig.streamReadAloudAudio) {
return
}
val mediaItem = exoPlayer.currentMediaItem ?: return
@@ -10,6 +10,7 @@ import io.legado.app.constant.AppPattern
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.MediaHelp
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.lib.dialogs.SelectItem
import io.legado.app.model.ReadAloud
@@ -152,13 +153,13 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener
* 更新朗读速度
*/
override fun upSpeechRate(reset: Boolean) {
if (AppConfig.ttsFlowSys) {
if (ReadConfig.ttsFollowSys) {
if (reset) {
clearTTS()
initTts()
}
} else {
val speechRate = (AppConfig.ttsSpeechRate + 5) / 10f
val speechRate = (ReadConfig.ttsSpeechRate + 5) / 10f
textToSpeech?.setSpeechRate(speechRate)
}
}
@@ -9,6 +9,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
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.TextPage
import io.legado.app.ui.book.searchContent.SearchResult
@@ -134,6 +135,7 @@ data class ReadBookUiState(
val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
val selectedTtsEngine: String? = null,
val speakEngineName: String = "",
val editingHttpTts: HttpTTS? = null,
val preDownloadNum: Int = 10,
val audioCacheCleanTime: Int = 10,
// Read aloud config
@@ -416,6 +418,12 @@ sealed interface ReadBookIntent {
data class ApplySpeakEngine(val value: String?) : ReadBookIntent
data class ApplyPreDownloadNum(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 SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
@@ -567,6 +575,9 @@ sealed interface ReadBookEffect {
data object UnregisterNetworkListener : ReadBookEffect
data object OpenBooksDirPicker : ReadBookEffect
data object BackupNow : ReadBookEffect
// Export — Activity handles file writing
data class ExportJson(val json: String) : ReadBookEffect
}
@Immutable
@@ -590,6 +601,7 @@ sealed interface ReadBookSheet {
data object BgTextConfig : ReadBookSheet
data object ReadAloudConfig : ReadBookSheet
data object SpeakEngineConfig : ReadBookSheet
data class HttpTtsEdit(val engineId: Long? = null) : ReadBookSheet
data object PreDownloadConfig : ReadBookSheet
data object AudioCacheCleanConfig : ReadBookSheet
data object ClickActionConfig : ReadBookSheet
@@ -767,6 +767,7 @@ class ReadBookController(
is ReadBookEffect.OpenTitleBarCustomIconPicker,
is ReadBookEffect.OpenSystemTtsSettings,
is ReadBookEffect.TtsCacheCleared,
is ReadBookEffect.ExportJson,
// DB query + bookmark effects — handled by ViewModel, ignored here
is ReadBookEffect.MenuChangeSource,
is ReadBookEffect.MenuBookChangeSource,
@@ -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.ShadowSetSheet
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.TitleBarIconSheet
import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
@@ -198,13 +199,20 @@ fun ReadBookScreen(
)
SpeakEngineConfigSheet(
show = state.activeSheet is ReadBookSheet.SpeakEngineConfig,
items = state.ttsEngineItems,
selectedValue = state.selectedTtsEngine,
onSelect = { onIntent(ReadBookIntent.ApplySpeakEngine(it)) },
state = state,
onIntent = onIntent,
onDismissRequest = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
},
)
HttpTtsEditSheet(
show = state.activeSheet is ReadBookSheet.HttpTtsEdit,
httpTTS = state.editingHttpTts,
onIntent = onIntent,
onDismissRequest = {
onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SpeakEngineConfig))
},
)
ReadAloudNumberConfigSheet(
show = state.activeSheet is ReadBookSheet.PreDownloadConfig,
title = stringResource(R.string.read_aloud_preload),
@@ -20,6 +20,7 @@ import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.BookProgress
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.ReadBookStyleConfigRepository
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 -> {
viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) }
}
@@ -1191,7 +1276,6 @@ class ReadBookViewModel(
readSettingsRepository.preferences.collect { preferences ->
val old = previous
previous = preferences
ReadBookConfig.syncPreferences(preferences)
AppConfig.syncReadPreferences(preferences)
_readPreferences.value = preferences
if (!preferences.hasMenuClickArea()) {
@@ -1,24 +1,56 @@
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
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.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.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
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.ReadBookTtsEngineItem
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.settingItem.SliderSettingItem
import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
import io.legado.app.utils.GSON
@Composable
fun ReadAloudConfigSheet(
@@ -125,33 +157,275 @@ fun ReadAloudConfigSheet(
@Composable
fun SpeakEngineConfigSheet(
show: Boolean,
items: List<ReadBookTtsEngineItem>,
selectedValue: String?,
onSelect: (String?) -> Unit,
state: ReadBookUiState,
onIntent: (ReadBookIntent) -> Unit,
onDismissRequest: () -> Unit,
) {
val clipboardManager = LocalClipboardManager.current
val items = state.ttsEngineItems
val selectedValue = state.selectedTtsEngine
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest,
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(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
.weight(1f, fill = false)
.padding(bottom = 8.dp),
) {
items(items) { item ->
val isHttpTts = item.value != null
val isSelected = item.value == selectedValue
TinyClickableSettingItem(
title = item.title,
description = if (item.value == selectedValue) {
description = if (isSelected) {
stringResource(R.string.default_version)
} else {
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)) }
}
}
}
@@ -7,23 +7,27 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
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.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.book.read.ReadBookIntent
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.settingItem.TinySliderSettingItem
import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
@@ -77,55 +81,34 @@ fun ReadAloudContent(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
FilledTonalIconButton(
MediumTonalButton(
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))
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))
FilledTonalIconButton(
MediumTonalButton(
onClick = {
onIntent(ReadBookIntent.ReadAloudStop)
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))
FilledTonalButton(
MediumTonalButton(
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))
@@ -147,26 +130,21 @@ fun ReadAloudContent(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
MediumTonalButton(
onClick = { onIntent(ReadBookIntent.ReadAloudPrevChapter) },
text = stringResource(R.string.previous_chapter),
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),
) {
Text(stringResource(R.string.timer_m, timerMinute))
}
OutlinedButton(
)
MediumTonalButton(
onClick = { onIntent(ReadBookIntent.ReadAloudNextChapter) },
text = stringResource(R.string.next_chapter),
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.next_chapter))
}
)
}
Spacer(Modifier.height(12.dp))
@@ -197,17 +175,17 @@ fun ReadAloudContent(
horizontalArrangement = Arrangement.SpaceEvenly,
) {
ActionButton(
icon = R.drawable.ic_toc,
icon = Icons.AutoMirrored.Filled.List,
label = stringResource(R.string.chapter_list),
onClick = onOpenChapterList,
)
ActionButton(
icon = R.drawable.ic_visibility_off,
icon = Icons.Default.VisibilityOff,
label = stringResource(R.string.to_backstage),
onClick = onGoToBackground,
)
ActionButton(
icon = R.drawable.ic_settings,
icon = Icons.Default.Settings,
label = stringResource(R.string.setting),
onClick = onShowReadAloudConfig,
)
@@ -217,19 +195,18 @@ fun ReadAloudContent(
@Composable
private fun ActionButton(
icon: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
onClick: () -> Unit,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
FilledTonalIconButton(onClick = onClick) {
Icon(
painter = painterResource(icon),
contentDescription = label,
)
}
MediumTonalButton(
onClick = onClick,
icon = icon,
contentDescription = label,
)
Spacer(Modifier.height(4.dp))
Text(
text = label,
@@ -189,4 +189,56 @@ object ReadConfig {
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
)
}