fix: 修复阅读界面交互问题

This commit is contained in:
HapeLee
2026-06-18 00:07:42 +08:00
parent 300922f96e
commit 4f6ee460ae
13 changed files with 287 additions and 54 deletions
@@ -82,7 +82,10 @@ class ReadAloudSettingsRepository(
suspend fun setTtsTimer(value: Int) { suspend fun setTtsTimer(value: Int) {
val timer = value.coerceIn(0, 180) val timer = value.coerceIn(0, 180)
ReadConfig.ttsTimer = timer ReadConfig.ttsTimer = timer
settingsRepository.putInt(PreferKey.ttsTimer, timer) }
suspend fun saveTtsTimer(value: Int) {
settingsRepository.putInt(PreferKey.ttsTimer, value.coerceIn(0, 180))
} }
suspend fun setTtsFollowSys(value: Boolean) { suspend fun setTtsFollowSys(value: Boolean) {
@@ -133,6 +133,7 @@ data class ReadBookUiState(
val contentEditLoading: Boolean = false, val contentEditLoading: Boolean = false,
val contentEditText: String = "", val contentEditText: String = "",
val contentEditTitle: String = "", val contentEditTitle: String = "",
val contentEditCursorOffset: Int = 0,
val contentEditIsLocalTxt: Boolean = false, val contentEditIsLocalTxt: Boolean = false,
val contentEditSaveToSource: Boolean = false, val contentEditSaveToSource: Boolean = false,
val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(), val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
@@ -446,6 +447,8 @@ sealed interface ReadBookIntent {
// Dialog callback bridge // Dialog callback bridge
data object ReadAloudAction : ReadBookIntent data object ReadAloudAction : ReadBookIntent
data object ConfirmAddCurrentBookToBookshelf : ReadBookIntent
data object ExitWithoutAddingCurrentBookToBookshelf : ReadBookIntent
// Read aloud config (needs Activity for DialogFragment) // Read aloud config (needs Activity for DialogFragment)
data object ShowReadAloudConfig : ReadBookIntent data object ShowReadAloudConfig : ReadBookIntent
@@ -461,6 +464,7 @@ sealed interface ReadBookIntent {
data class ApplySpeakEnginePerBook(val value: String?) : ReadBookIntent data class ApplySpeakEnginePerBook(val value: String?) : ReadBookIntent
data class OpenHttpTtsLogin(val engineId: Long) : ReadBookIntent data class OpenHttpTtsLogin(val engineId: Long) : ReadBookIntent
data class ImportHttpTtsJson(val json: String) : ReadBookIntent data class ImportHttpTtsJson(val json: String) : ReadBookIntent
data class ImportHttpTtsSource(val text: String) : ReadBookIntent
data object ImportHttpTtsFile : ReadBookIntent data object ImportHttpTtsFile : ReadBookIntent
data class ImportHttpTtsFileSelected(val uri: Uri) : ReadBookIntent data class ImportHttpTtsFileSelected(val uri: Uri) : ReadBookIntent
data object ExportAllHttpTts : ReadBookIntent data object ExportAllHttpTts : ReadBookIntent
@@ -479,6 +483,7 @@ sealed interface ReadBookIntent {
data object ReadAloudPrevChapter : ReadBookIntent data object ReadAloudPrevChapter : ReadBookIntent
data object ReadAloudNextChapter : ReadBookIntent data object ReadAloudNextChapter : ReadBookIntent
data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
data class SaveReadAloudTtsTimer(val value: Int) : ReadBookIntent
data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
data object OpenSystemTtsSettings : ReadBookIntent data object OpenSystemTtsSettings : ReadBookIntent
@@ -688,6 +693,7 @@ sealed interface ReadBookDialog {
data object RestoreLastBookProgress : ReadBookDialog data object RestoreLastBookProgress : ReadBookDialog
data object ConfirmSkipToChapter : ReadBookDialog data object ConfirmSkipToChapter : ReadBookDialog
data class ConfirmChapterPay(val chapterTitle: String) : ReadBookDialog data class ConfirmChapterPay(val chapterTitle: String) : ReadBookDialog
data class ConfirmAddToBookshelf(val bookName: String) : ReadBookDialog
} }
/** /**
@@ -117,6 +117,10 @@ class ReadBookController(
private val popupAction by lazy { PopupAction(activity) } private val popupAction by lazy { PopupAction(activity) }
private var screenTimeOut: Long = 0 private var screenTimeOut: Long = 0
private var pendingSearchResultMark: IntArray? = null private var pendingSearchResultMark: IntArray? = null
private val originalRequestedOrientation = activity.requestedOrientation
private val originalScreenBrightness = activity.window.attributes.screenBrightness
private val originalKeepScreenOn =
(activity.window.attributes.flags and WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) != 0
// justInitData moved to ViewModel (set on InitData intent) // justInitData moved to ViewModel (set on InitData intent)
val isAutoPage: Boolean get() = refs?.readView?.isAutoPage == true val isAutoPage: Boolean get() = refs?.readView?.isAutoPage == true
@@ -136,6 +140,7 @@ class ReadBookController(
refs?.readView?.onDestroy() refs?.readView?.onDestroy()
networkChangedListener.unRegister() networkChangedListener.unRegister()
unregisterTimeBatteryReceiver() unregisterTimeBatteryReceiver()
restoreActivityWindowState()
} }
// Phase 5: Key handling / page turn // Phase 5: Key handling / page turn
@@ -173,6 +178,7 @@ class ReadBookController(
} }
fun onRouteInitialized() { fun onRouteInitialized() {
applyReadBrightness()
upScreenTimeOut() upScreenTimeOut()
} }
@@ -1039,6 +1045,25 @@ class ReadBookController(
screenOffTimerStartInternal() screenOffTimerStartInternal()
} }
private fun applyReadBrightness() {
val lp = activity.window.attributes
lp.screenBrightness = if (ReadBookConfig.brightnessAuto) {
WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE
} else {
ReadBookConfig.readBrightness / 100f
}
activity.window.attributes = lp
}
private fun restoreActivityWindowState() {
activity.requestedOrientation = originalRequestedOrientation
val lp = activity.window.attributes
lp.screenBrightness = originalScreenBrightness
activity.window.attributes = lp
keepScreenOn(originalKeepScreenOn)
handler.removeCallbacks(screenOffRunnable)
}
private fun screenOffTimerStartInternal() { private fun screenOffTimerStartInternal() {
handler.post { handler.post {
if (screenTimeOut < 0) { if (screenTimeOut < 0) {
@@ -1582,6 +1582,7 @@ private fun FloatingIconRow(
), ),
selected = iconDef.isActive, selected = iconDef.isActive,
modifier = Modifier.padding(horizontal = 4.dp), modifier = Modifier.padding(horizontal = 4.dp),
onLongClick = iconDef.onLongClick,
) { ) {
if (isCustom) { if (isCustom) {
AsyncImage( AsyncImage(
@@ -2446,6 +2447,7 @@ private fun ToolButtonItem(
menuConfig = state.menuConfig, menuConfig = state.menuConfig,
glassEnabled = true, glassEnabled = true,
selected = button.isActive, selected = button.isActive,
onLongClick = button.onLongClick,
) { tint -> ) { tint ->
ToolButtonContent( ToolButtonContent(
button = button, button = button,
@@ -2470,6 +2472,7 @@ private fun ToolButtonItem(
indication = LocalIndication.current, indication = LocalIndication.current,
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
role = Role.Button, role = Role.Button,
onLongClick = button.onLongClick,
onClick = button.onClick, onClick = button.onClick,
), ),
) { ) {
@@ -2553,6 +2556,7 @@ private data class ToolButtonDef(
val customIconPath: String?, val customIconPath: String?,
val isActive: Boolean = false, val isActive: Boolean = false,
val onClick: () -> Unit, val onClick: () -> Unit,
val onLongClick: (() -> Unit)? = null,
) )
private fun loadToolButtons( private fun loadToolButtons(
@@ -2561,8 +2565,12 @@ private fun loadToolButtons(
onIntent: (ReadBookIntent) -> Unit, onIntent: (ReadBookIntent) -> Unit,
): List<ToolButtonDef> { ): List<ToolButtonDef> {
val customIcons = state.menuConfig.readMenuCustomIcons val customIcons = state.menuConfig.readMenuCustomIcons
fun ReadMenuButtonInfo.toButton(isActive: Boolean = false, onClick: () -> Unit): ToolButtonDef { fun ReadMenuButtonInfo.toButton(
return ToolButtonDef(id, icon, label, customIcons[id], isActive, onClick) isActive: Boolean = false,
onLongClick: (() -> Unit)? = null,
onClick: () -> Unit,
): ToolButtonDef {
return ToolButtonDef(id, icon, label, customIcons[id], isActive, onClick, onLongClick)
} }
val infoMap = readMenuButtonInfos(context).associateBy { it.id } val infoMap = readMenuButtonInfos(context).associateBy { it.id }
val allButtons = listOf( val allButtons = listOf(
@@ -2572,7 +2580,10 @@ private fun loadToolButtons(
infoMap.getValue("catalog").toButton { infoMap.getValue("catalog").toButton {
onIntent(ReadBookIntent.OpenChapterList) onIntent(ReadBookIntent.OpenChapterList)
}, },
infoMap.getValue("read_aloud").toButton(isActive = state.isReadAloudRunning) { infoMap.getValue("read_aloud").toButton(
isActive = state.isReadAloudRunning,
onLongClick = { onIntent(ReadBookIntent.ShowReadAloudConfig) },
) {
if (state.isReadAloudRunning) { if (state.isReadAloudRunning) {
onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud)) onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud))
} else { } else {
@@ -2906,6 +2917,7 @@ private data class TitleBarIconDef(
val label: String, val label: String,
val isActive: Boolean = false, val isActive: Boolean = false,
val onClick: () -> Unit, val onClick: () -> Unit,
val onLongClick: (() -> Unit)? = null,
) )
private fun loadFloatingIcons( private fun loadFloatingIcons(
@@ -2961,6 +2973,11 @@ private fun loadFloatingIcons(
label = info.label, label = info.label,
isActive = id in activeIds, isActive = id in activeIds,
onClick = actionMap[id] ?: {}, onClick = actionMap[id] ?: {},
onLongClick = if (id == "read_aloud") {
{ onIntent(ReadBookIntent.ShowReadAloudConfig) }
} else {
null
},
) )
} }
.toList() .toList()
@@ -59,7 +59,7 @@ fun ReadBookScreen(
state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch) state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch)
state.menuVisible -> onIntent(ReadBookIntent.ReadMenuBack) state.menuVisible -> onIntent(ReadBookIntent.ReadMenuBack)
state.isAutoPage -> onIntent(ReadBookIntent.StopAutoPage) state.isAutoPage -> onIntent(ReadBookIntent.StopAutoPage)
else -> onBack() else -> onIntent(ReadBookIntent.CloseReadBook)
} }
} }
@@ -69,6 +69,7 @@ fun ReadBookScreen(
val restoreLastProgressDialog = state.activeDialog as? ReadBookDialog.RestoreLastBookProgress val restoreLastProgressDialog = state.activeDialog as? ReadBookDialog.RestoreLastBookProgress
val skipDialog = state.activeDialog as? ReadBookDialog.ConfirmSkipToChapter val skipDialog = state.activeDialog as? ReadBookDialog.ConfirmSkipToChapter
val payDialog = state.activeDialog as? ReadBookDialog.ConfirmChapterPay val payDialog = state.activeDialog as? ReadBookDialog.ConfirmChapterPay
val addToBookshelfDialog = state.activeDialog as? ReadBookDialog.ConfirmAddToBookshelf
AppAlertDialog( AppAlertDialog(
show = restoreDialog != null, show = restoreDialog != null,
@@ -129,6 +130,19 @@ fun ReadBookScreen(
dismissText = stringResource(R.string.cancel), dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.DismissDialog) }, onDismiss = { onIntent(ReadBookIntent.DismissDialog) },
) )
AppAlertDialog(
show = addToBookshelfDialog != null,
onDismissRequest = { onIntent(ReadBookIntent.ExitWithoutAddingCurrentBookToBookshelf) },
title = stringResource(R.string.add_to_bookshelf),
text = stringResource(
R.string.check_add_bookshelf,
addToBookshelfDialog?.bookName.orEmpty()
),
confirmText = stringResource(R.string.ok),
onConfirm = { onIntent(ReadBookIntent.ConfirmAddCurrentBookToBookshelf) },
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(ReadBookIntent.ExitWithoutAddingCurrentBookToBookshelf) },
)
// AppModalBottomSheet-based sheets — always composed, controlled by show flag // AppModalBottomSheet-based sheets — always composed, controlled by show flag
// for proper enter/exit animations // for proper enter/exit animations
@@ -9,6 +9,7 @@ import android.speech.tts.TextToSpeech
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import io.legado.app.BuildConfig import io.legado.app.BuildConfig
import io.legado.app.R import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.base.BaseViewModel import io.legado.app.base.BaseViewModel
import io.legado.app.constant.AppLog import io.legado.app.constant.AppLog
import io.legado.app.constant.BookType import io.legado.app.constant.BookType
@@ -46,6 +47,10 @@ import io.legado.app.help.book.removeType
import io.legado.app.help.book.simulatedTotalChapterNum import io.legado.app.help.book.simulatedTotalChapterNum
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.http.decompressed
import io.legado.app.help.http.newCallResponseBody
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.text
import io.legado.app.help.source.getSourceType import io.legado.app.help.source.getSourceType
import io.legado.app.lib.dialogs.SelectItem import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.model.ImageProvider import io.legado.app.model.ImageProvider
@@ -72,6 +77,8 @@ import io.legado.app.utils.StringUtils
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.hexString import io.legado.app.utils.hexString
import io.legado.app.utils.isAbsUrl import io.legado.app.utils.isAbsUrl
import io.legado.app.utils.isJsonArray
import io.legado.app.utils.isJsonObject
import io.legado.app.utils.isTrue import io.legado.app.utils.isTrue
import io.legado.app.utils.mapParallelSafe import io.legado.app.utils.mapParallelSafe
import io.legado.app.utils.openUrl import io.legado.app.utils.openUrl
@@ -132,6 +139,15 @@ class ReadBookViewModel(
private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16) private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow() val effects = _effects.asSharedFlow()
private suspend fun emitEffectWhenSubscribed(effect: ReadBookEffect) {
_effects.subscriptionCount.first { it > 0 }
_effects.emit(effect)
}
private fun closeReadMenu() {
_uiState.update { it.copy(menuState = ReadBookMenuState()) }
}
private val sysEngines: List<TextToSpeech.EngineInfo> by lazy { private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
val tts = TextToSpeech(context, null) val tts = TextToSpeech(context, null)
val engines = tts.engines val engines = tts.engines
@@ -172,7 +188,7 @@ class ReadBookViewModel(
is ReadBookIntent.NextPage -> ReadBook.moveToNextPage() is ReadBookIntent.NextPage -> ReadBook.moveToNextPage()
is ReadBookIntent.PrevPage -> ReadBook.moveToPrevPage() is ReadBookIntent.PrevPage -> ReadBook.moveToPrevPage()
is ReadBookIntent.NextChapter -> ReadBook.moveToNextChapter(upContent = true) is ReadBookIntent.NextChapter -> ReadBook.moveToNextChapter(upContent = true)
is ReadBookIntent.PrevChapter -> ReadBook.moveToPrevChapter(upContent = true) is ReadBookIntent.PrevChapter -> ReadBook.moveToPrevChapter(upContent = true, toLast = false)
is ReadBookIntent.OpenChapter -> openChapter(intent.index, intent.pos) is ReadBookIntent.OpenChapter -> openChapter(intent.index, intent.pos)
is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex) is ReadBookIntent.SkipToPage -> ReadBook.skipToPage(intent.pageIndex)
is ReadBookIntent.ToggleMenu -> _uiState.update { is ReadBookIntent.ToggleMenu -> _uiState.update {
@@ -220,6 +236,7 @@ class ReadBookViewModel(
} }
is ReadBookIntent.OpenSearch -> { is ReadBookIntent.OpenSearch -> {
closeReadMenu()
_uiState.update { it.copy(searchContentQuery = intent.word ?: "") } _uiState.update { it.copy(searchContentQuery = intent.word ?: "") }
ReadBook.book?.bookUrl?.let { bookUrl -> ReadBook.book?.bookUrl?.let { bookUrl ->
_effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.word, bookUrl)) _effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.word, bookUrl))
@@ -368,6 +385,7 @@ class ReadBookViewModel(
activeSheet = null, activeSheet = null,
contentEditText = "", contentEditText = "",
contentEditTitle = "", contentEditTitle = "",
contentEditCursorOffset = 0,
contentEditLoading = false, contentEditLoading = false,
contentEditSaveToSource = false, contentEditSaveToSource = false,
) )
@@ -407,11 +425,13 @@ class ReadBookViewModel(
} }
is ReadBookIntent.OpenBookInfo -> { is ReadBookIntent.OpenBookInfo -> {
ReadBook.book?.let { book -> ReadBook.book?.let { book ->
closeReadMenu()
_effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl)) _effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl))
} }
} }
is ReadBookIntent.OpenChapterList -> { is ReadBookIntent.OpenChapterList -> {
ReadBook.book?.bookUrl?.let { bookUrl -> ReadBook.book?.bookUrl?.let { bookUrl ->
closeReadMenu()
_effects.tryEmit(ReadBookEffect.OpenChapterList(bookUrl)) _effects.tryEmit(ReadBookEffect.OpenChapterList(bookUrl))
} }
} }
@@ -487,8 +507,12 @@ class ReadBookViewModel(
_uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) } _uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) }
} }
is ReadBookIntent.MenuChapterChangeSource -> handleChapterChangeSource() is ReadBookIntent.MenuChapterChangeSource -> handleChapterChangeSource()
is ReadBookIntent.MenuSettingReplace -> _effects.tryEmit(ReadBookEffect.MenuSettingReplace) is ReadBookIntent.MenuSettingReplace -> {
closeReadMenu()
_effects.tryEmit(ReadBookEffect.MenuSettingReplace)
}
is ReadBookIntent.MenuTocRegex -> { is ReadBookIntent.MenuTocRegex -> {
closeReadMenu()
_effects.tryEmit(ReadBookEffect.MenuTocRegex(ReadBook.book?.tocUrl)) _effects.tryEmit(ReadBookEffect.MenuTocRegex(ReadBook.book?.tocUrl))
} }
is ReadBookIntent.TocRegexResult -> { is ReadBookIntent.TocRegexResult -> {
@@ -661,8 +685,16 @@ class ReadBookViewModel(
is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart)) is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart))
is ReadBookIntent.ReadAloudAction -> { is ReadBookIntent.ReadAloudAction -> {
openReadMenuRoute(ReadBookMenuRoute.ReadAloud) _uiState.update {
it.copy(
speakEngineName = computeSpeakEngineName(),
activeSheet = ReadBookSheet.ReadAloudConfig,
)
}
loadTtsEngineItems()
} }
is ReadBookIntent.ConfirmAddCurrentBookToBookshelf -> addCurrentBookToBookshelfAndFinish()
is ReadBookIntent.ExitWithoutAddingCurrentBookToBookshelf -> removeCurrentNotShelfBookAndFinish()
is ReadBookIntent.ShowReadAloudConfig -> { is ReadBookIntent.ShowReadAloudConfig -> {
_uiState.update { _uiState.update {
@@ -796,17 +828,11 @@ class ReadBookViewModel(
} }
is ReadBookIntent.ImportHttpTtsJson -> { is ReadBookIntent.ImportHttpTtsJson -> {
execute { importHttpTtsSource(intent.json)
HttpTTS.fromJsonArray(intent.json).getOrDefault(arrayListOf()) }
}.onSuccess { list ->
if (list.isNotEmpty()) { is ReadBookIntent.ImportHttpTtsSource -> {
execute { importHttpTtsSource(intent.text)
appDb.httpTTSDao.insert(*list.toTypedArray())
}.onSuccess {
loadTtsEngineItems()
}
}
}
} }
is ReadBookIntent.ExportAllHttpTts -> { is ReadBookIntent.ExportAllHttpTts -> {
@@ -829,20 +855,12 @@ class ReadBookViewModel(
} }
is ReadBookIntent.ImportHttpTtsFileSelected -> { is ReadBookIntent.ImportHttpTtsFileSelected -> {
execute { execute<String?> {
val text = context.contentResolver.openInputStream(intent.uri) val text = context.contentResolver.openInputStream(intent.uri)
?.use { it.reader().readText() } ?.use { it.reader().readText() }
if (!text.isNullOrBlank()) { text
HttpTTS.fromJsonArray(text).getOrDefault(arrayListOf()) }.onSuccess { text ->
} else arrayListOf() if (!text.isNullOrBlank()) importHttpTtsSource(text)
}.onSuccess { list ->
if (list.isNotEmpty()) {
execute {
appDb.httpTTSDao.insert(*list.toTypedArray())
}.onSuccess {
loadTtsEngineItems()
}
}
} }
} }
@@ -870,7 +888,7 @@ class ReadBookViewModel(
if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false) if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false)
} }
is ReadBookIntent.ReadAloudPrevParagraph -> ReadAloud.prevParagraph(context) is ReadBookIntent.ReadAloudPrevParagraph -> ReadAloud.prevParagraph(context)
is ReadBookIntent.ReadAloudTogglePause -> toggleReadAloudPause() is ReadBookIntent.ReadAloudTogglePause -> _effects.tryEmit(ReadBookEffect.ToggleReadAloud)
is ReadBookIntent.ReadAloudStop -> { is ReadBookIntent.ReadAloudStop -> {
ReadAloud.stop(context) ReadAloud.stop(context)
_uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) } _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
@@ -882,6 +900,10 @@ class ReadBookViewModel(
) )
is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true) is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value) is ReadBookIntent.SetReadAloudTtsTimer -> setReadAloudTtsTimer(intent.value)
is ReadBookIntent.SaveReadAloudTtsTimer -> {
viewModelScope.launch { readAloudSettingsRepository.saveTtsTimer(intent.value) }
_effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.save_success)))
}
is ReadBookIntent.SetReadAloudTtsFollowSys -> { is ReadBookIntent.SetReadAloudTtsFollowSys -> {
viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) } viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
_uiState.update { it.copy(readAloudTtsFollowSys = intent.value) } _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
@@ -1066,7 +1088,7 @@ class ReadBookViewModel(
is ReadBookIntent.OnResume -> handleOnResume() is ReadBookIntent.OnResume -> handleOnResume()
is ReadBookIntent.OnPause -> handleOnPause() is ReadBookIntent.OnPause -> handleOnPause()
is ReadBookIntent.OnDispose -> handleOnDispose() is ReadBookIntent.OnDispose -> handleOnDispose()
is ReadBookIntent.CloseReadBook -> _effects.tryEmit(ReadBookEffect.Finish) is ReadBookIntent.CloseReadBook -> closeReadBook()
is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false) is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false)
is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri) is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri)
} }
@@ -1127,9 +1149,12 @@ class ReadBookViewModel(
} }
private fun handleOnDispose() { private fun handleOnDispose() {
// TTS and view cleanup — bridge handles via clearTts()
backupJob?.cancel() backupJob?.cancel()
ReadBook.cancelPreDownloadTask() ReadBook.cancelPreDownloadTask()
if (BaseReadAloudService.isRun) {
ReadAloud.stop(context)
_uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
}
} }
private fun showSpeakEngineConfig() { private fun showSpeakEngineConfig() {
@@ -1171,6 +1196,42 @@ class ReadBookViewModel(
} }
} }
private fun importHttpTtsSource(text: String) {
execute {
importHttpTtsSourceAwait(text.trim()).also { list ->
if (list.isNotEmpty()) {
appDb.httpTTSDao.insert(*list.toTypedArray())
}
}
}.onSuccess { list ->
if (list.isNotEmpty()) {
loadTtsEngineItems()
_effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.success)))
}
}.onError {
AppLog.put("导入朗读引擎失败\n${it.localizedMessage}", it, true)
}
}
private suspend fun importHttpTtsSourceAwait(text: String): List<HttpTTS> {
return when {
text.isJsonObject() -> listOf(HttpTTS.fromJson(text).getOrThrow())
text.isJsonArray() -> HttpTTS.fromJsonArray(text).getOrThrow()
text.isAbsUrl() -> {
val body = okHttpClient.newCallResponseBody {
if (text.endsWith("#requestWithoutUA")) {
url(text.substringBeforeLast("#requestWithoutUA"))
header(AppConst.UA_NAME, "null")
} else {
url(text)
}
}.decompressed().text()
importHttpTtsSourceAwait(body)
}
else -> throw NoStackTraceException(context.getString(R.string.wrong_format))
}
}
private fun computeSpeakEngineName(): String { private fun computeSpeakEngineName(): String {
val ttsEngine = ReadAloud.ttsEngine val ttsEngine = ReadAloud.ttsEngine
?: return context.getString(R.string.system_tts) ?: return context.getString(R.string.system_tts)
@@ -1399,7 +1460,7 @@ class ReadBookViewModel(
} }
}.toSet() }.toSet()
if (actions.isNotEmpty()) { if (actions.isNotEmpty()) {
_effects.tryEmit(ReadBookEffect.UpdateReadViewConfig(actions)) emitEffectWhenSubscribed(ReadBookEffect.UpdateReadViewConfig(actions))
} }
} }
} }
@@ -2092,6 +2153,48 @@ class ReadBookViewModel(
} }
} }
private fun closeReadBook() {
val book = ReadBook.book
if (!ReadBook.inBookshelf && book != null && OtherConfig.showAddToShelfAlert) {
_uiState.update {
it.copy(activeDialog = ReadBookDialog.ConfirmAddToBookshelf(book.name))
}
} else if (!ReadBook.inBookshelf) {
removeCurrentNotShelfBookAndFinish()
} else {
_effects.tryEmit(ReadBookEffect.Finish)
}
}
private fun addCurrentBookToBookshelfAndFinish() {
val book = ReadBook.book ?: return removeCurrentNotShelfBookAndFinish()
execute {
val toc = appDb.bookChapterDao.getChapterList(book.bookUrl)
book.removeType(BookType.notShelf)
if (book.order == 0) {
book.order = appDb.bookDao.minOrder - 1
}
appDb.bookDao.insert(book)
if (toc.isNotEmpty()) {
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
ReadBook.inBookshelf = true
}.onSuccess {
_uiState.update { it.copy(activeDialog = null) }
_effects.tryEmit(ReadBookEffect.Finish)
}.onError {
AppLog.put("添加书籍到书架失败", it)
context.toastOnUi("添加书籍失败")
}
}
private fun removeCurrentNotShelfBookAndFinish() {
_uiState.update { it.copy(activeDialog = null) }
removeFromBookshelf {
_effects.tryEmit(ReadBookEffect.Finish)
}
}
fun upBookSource(success: (() -> Unit)? = null) { fun upBookSource(success: (() -> Unit)? = null) {
execute { execute {
ReadBook.book?.let { book -> ReadBook.book?.let { book ->
@@ -2179,6 +2282,7 @@ class ReadBookViewModel(
it.copy( it.copy(
contentEditText = text, contentEditText = text,
contentEditTitle = title, contentEditTitle = title,
contentEditCursorOffset = ReadBook.durChapterPos.coerceIn(0, text.length),
contentEditIsLocalTxt = book.isLocalTxt, contentEditIsLocalTxt = book.isLocalTxt,
) )
} }
@@ -2219,7 +2323,13 @@ class ReadBookViewModel(
} else { } else {
"" ""
} }
_uiState.update { it.copy(contentEditText = text, contentEditLoading = false) } _uiState.update {
it.copy(
contentEditText = text,
contentEditCursorOffset = ReadBook.durChapterPos.coerceIn(0, text.length),
contentEditLoading = false,
)
}
ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false) ReadBook.loadContent(ReadBook.durChapterIndex, resetPageOffset = false)
}.onError { }.onError {
_uiState.update { it.copy(contentEditLoading = false) } _uiState.update { it.copy(contentEditLoading = false) }
@@ -1079,7 +1079,7 @@ object ChapterProvider {
viewWidth = width viewWidth = width
viewHeight = height viewHeight = height
upLayout() upLayout()
postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5)) postEvent(EventBus.UP_CONFIG, arrayListOf(5))
} }
/** /**
@@ -7,12 +7,17 @@ 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.text.input.TextFieldState
import androidx.compose.foundation.text.input.setTextAndSelectAll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextRange
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
@@ -34,12 +39,31 @@ fun ContentEditSheet(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val editorState = remember { TextFieldState() }
LaunchedEffect(show) { LaunchedEffect(show) {
if (!show) return@LaunchedEffect if (!show) return@LaunchedEffect
onIntent(ReadBookIntent.LoadContentEdit) onIntent(ReadBookIntent.LoadContentEdit)
} }
LaunchedEffect(state.contentEditText, state.contentEditCursorOffset) {
if (editorState.text.toString() != state.contentEditText) {
editorState.setTextAndSelectAll(state.contentEditText)
editorState.edit {
selection = TextRange(state.contentEditCursorOffset.coerceIn(0, length))
}
}
}
LaunchedEffect(editorState) {
snapshotFlow { editorState.text.toString() }
.collect { text ->
if (text != state.contentEditText) {
onIntent(ReadBookIntent.SetContentEditText(text))
}
}
}
AppModalBottomSheet( AppModalBottomSheet(
show = show, show = show,
onDismissRequest = { onDismissRequest = {
@@ -60,7 +84,7 @@ fun ContentEditSheet(
onClick = { onClick = {
onIntent( onIntent(
ReadBookIntent.SaveContentEdit( ReadBookIntent.SaveContentEdit(
state.contentEditText, editorState.text.toString(),
state.contentEditSaveToSource state.contentEditSaveToSource
) )
) )
@@ -76,7 +100,7 @@ fun ContentEditSheet(
) )
SmallTonalButton( SmallTonalButton(
onClick = { onClick = {
context.sendToClip("${state.contentEditTitle}\n${state.contentEditText}") context.sendToClip("${state.contentEditTitle}\n${editorState.text}")
}, text = stringResource(R.string.copy_all) }, text = stringResource(R.string.copy_all)
) )
} }
@@ -94,8 +118,7 @@ fun ContentEditSheet(
} }
} else { } else {
AppTextField( AppTextField(
value = state.contentEditText, state = editorState,
onValueChange = { onIntent(ReadBookIntent.SetContentEditText(it)) },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f, fill = false) .weight(1f, fill = false)
@@ -35,6 +35,8 @@ import io.legado.app.ui.book.read.ReadBookUiState
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.series.SmallTonalButton import io.legado.app.ui.widget.components.button.series.SmallTonalButton
import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu 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.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -155,6 +157,8 @@ fun SpeakEngineConfigSheet(
val items = state.ttsEngineItems val items = state.ttsEngineItems
val selectedValue = state.selectedTtsEngine val selectedValue = state.selectedTtsEngine
var pendingEngineSelection by remember { mutableStateOf<PendingSpeakEngineSelection?>(null) } var pendingEngineSelection by remember { mutableStateOf<PendingSpeakEngineSelection?>(null) }
var showImportSheet by remember { mutableStateOf(false) }
var showUrlInput by remember { mutableStateOf(false) }
AppAlertDialog( AppAlertDialog(
show = pendingEngineSelection != null, show = pendingEngineSelection != null,
@@ -172,6 +176,29 @@ fun SpeakEngineConfigSheet(
pendingEngineSelection = null pendingEngineSelection = null
}, },
) )
SourceInputDialog(
show = showUrlInput,
title = stringResource(R.string.import_on_line),
onDismissRequest = { showUrlInput = false },
onConfirm = {
showUrlInput = false
onIntent(ReadBookIntent.ImportHttpTtsSource(it))
},
)
FilePickerSheet(
show = showImportSheet,
onDismissRequest = { showImportSheet = false },
title = stringResource(R.string.import_tts),
onSelectSysFile = {
showImportSheet = false
onIntent(ReadBookIntent.ImportHttpTtsFile)
},
onManualInput = {
showImportSheet = false
showUrlInput = true
},
allowExtensions = arrayOf("json", "txt"),
)
AppModalBottomSheet( AppModalBottomSheet(
show = show, show = show,
@@ -195,7 +222,7 @@ fun SpeakEngineConfigSheet(
text = stringResource(R.string.import_tts), text = stringResource(R.string.import_tts),
onClick = { onClick = {
expanded = false expanded = false
onIntent(ReadBookIntent.ImportHttpTtsFile) showImportSheet = true
}, },
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
@@ -107,8 +107,8 @@ fun ReadAloudContent(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
MediumTonalButton( MediumTonalButton(
onClick = { onIntent(ReadBookIntent.SetReadAloudTtsTimer(timerMinute)) }, onClick = { onIntent(ReadBookIntent.SaveReadAloudTtsTimer(timerMinute)) },
text = stringResource(R.string.timer_m, timerMinute), text = stringResource(R.string.action_save),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
MediumTonalButton( MediumTonalButton(
@@ -363,16 +363,25 @@ fun TocScreen(
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.use_replace_rule), text = stringResource(R.string.use_replace_rule),
isSelected = useReplace, isSelected = useReplace,
onClick = { viewModel.toggleUseReplace() } onClick = {
dismiss()
viewModel.toggleUseReplace()
}
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.show_word_count), text = stringResource(R.string.show_word_count),
isSelected = showWordCount, isSelected = showWordCount,
onClick = { viewModel.toggleShowWordCount() } onClick = {
dismiss()
viewModel.toggleShowWordCount()
}
) )
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.reverse_toc), text = stringResource(R.string.reverse_toc),
onClick = { viewModel.reverseToc() } onClick = {
dismiss()
viewModel.reverseToc()
}
) )
PillDivider() PillDivider()
RoundDropdownMenuItem( RoundDropdownMenuItem(
@@ -26,7 +26,6 @@ import io.legado.app.utils.putPrefLong
import io.legado.app.utils.putPrefString import io.legado.app.utils.putPrefString
import io.legado.app.utils.putPrefStringSync import io.legado.app.utils.putPrefStringSync
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
@@ -61,12 +60,12 @@ fun <T> prefDelegate(
@Volatile @Volatile
private var currentValue: T = defaultValue private var currentValue: T = defaultValue
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
private var dsObserverJob: Job? = null private var dsObserverJob: Job? = null
init { init {
// 同步从 DataStore 读取初始值,确保构造完成后即为最新值 // 同步从 DataStore 读取初始值,确保构造完成后即为最新值
val initialValue = runBlocking(Dispatchers.IO) { readFromDs() } ?: defaultValue val initialValue = runBlocking { readFromDs() } ?: defaultValue
_value = mutableStateOf(initialValue) _value = mutableStateOf(initialValue)
currentValue = initialValue currentValue = initialValue
@@ -134,7 +133,7 @@ fun <T> prefDelegate(
} }
// 同步写入 DataStore,确保持久化后再返回 // 同步写入 DataStore,确保持久化后再返回
runCatching { runCatching {
runBlocking(Dispatchers.IO) { runBlocking {
when (value) { when (value) {
is String? -> DsSync.putString(key, value) is String? -> DsSync.putString(key, value)
is Int -> DsSync.putInt(key, value) is Int -> DsSync.putInt(key, value)
@@ -262,7 +262,7 @@ fun MainActivity.mainEntryProvider(
entry<MainRouteReadBook> { route -> entry<MainRouteReadBook> { route ->
val readBookViewModel = koinViewModel<ReadBookViewModel>( val readBookViewModel = koinViewModel<ReadBookViewModel>(
key = route.bookUrl ?: "last-read" key = "ReadBook:${route.bookUrl ?: "last-read"}"
) )
val controller = remember(readBookViewModel) { val controller = remember(readBookViewModel) {
ReadBookController(this@mainEntryProvider, readBookViewModel) ReadBookController(this@mainEntryProvider, readBookViewModel)
@@ -361,7 +361,7 @@ fun MainActivity.mainEntryProvider(
entry<MainRouteSearchContent> { route -> entry<MainRouteSearchContent> { route ->
val viewModel = koinViewModel<SearchContentViewModel>( val viewModel = koinViewModel<SearchContentViewModel>(
key = route.bookUrl, key = "SearchContent:${route.bookUrl}",
parameters = { parametersOf(route) } parameters = { parametersOf(route) }
) )
SearchContentScreen( SearchContentScreen(
@@ -549,7 +549,7 @@ fun MainActivity.mainEntryProvider(
} else null } else null
} }
) { route -> ) { route ->
val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = route.bookUrl) val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = "BookInfo:${route.bookUrl}")
BookInfoRouteScreen( BookInfoRouteScreen(
bookUrl = route.bookUrl, bookUrl = route.bookUrl,
name = route.name, name = route.name,