Bläddra i källkod

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

HapeLee 4 månader sedan
förälder
incheckning
4f6ee460ae

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

@@ -82,7 +82,10 @@ class ReadAloudSettingsRepository(
     suspend fun setTtsTimer(value: Int) {
         val timer = value.coerceIn(0, 180)
         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) {

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

@@ -133,6 +133,7 @@ data class ReadBookUiState(
     val contentEditLoading: Boolean = false,
     val contentEditText: String = "",
     val contentEditTitle: String = "",
+    val contentEditCursorOffset: Int = 0,
     val contentEditIsLocalTxt: Boolean = false,
     val contentEditSaveToSource: Boolean = false,
     val ttsEngineItems: ImmutableList<ReadBookTtsEngineItem> = persistentListOf(),
@@ -446,6 +447,8 @@ sealed interface ReadBookIntent {
 
     // Dialog callback bridge
     data object ReadAloudAction : ReadBookIntent
+    data object ConfirmAddCurrentBookToBookshelf : ReadBookIntent
+    data object ExitWithoutAddingCurrentBookToBookshelf : ReadBookIntent
 
     // Read aloud config (needs Activity for DialogFragment)
     data object ShowReadAloudConfig : ReadBookIntent
@@ -461,6 +464,7 @@ sealed interface ReadBookIntent {
     data class ApplySpeakEnginePerBook(val value: String?) : ReadBookIntent
     data class OpenHttpTtsLogin(val engineId: Long) : ReadBookIntent
     data class ImportHttpTtsJson(val json: String) : ReadBookIntent
+    data class ImportHttpTtsSource(val text: String) : ReadBookIntent
     data object ImportHttpTtsFile : ReadBookIntent
     data class ImportHttpTtsFileSelected(val uri: Uri) : ReadBookIntent
     data object ExportAllHttpTts : ReadBookIntent
@@ -479,6 +483,7 @@ sealed interface ReadBookIntent {
     data object ReadAloudPrevChapter : ReadBookIntent
     data object ReadAloudNextChapter : ReadBookIntent
     data class SetReadAloudTtsTimer(val value: Int) : ReadBookIntent
+    data class SaveReadAloudTtsTimer(val value: Int) : ReadBookIntent
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data object OpenSystemTtsSettings : ReadBookIntent
@@ -688,6 +693,7 @@ sealed interface ReadBookDialog {
     data object RestoreLastBookProgress : ReadBookDialog
     data object ConfirmSkipToChapter : ReadBookDialog
     data class ConfirmChapterPay(val chapterTitle: String) : ReadBookDialog
+    data class ConfirmAddToBookshelf(val bookName: String) : ReadBookDialog
 }
 
 /**

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

@@ -117,6 +117,10 @@ class ReadBookController(
     private val popupAction by lazy { PopupAction(activity) }
     private var screenTimeOut: Long = 0
     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)
 
     val isAutoPage: Boolean get() = refs?.readView?.isAutoPage == true
@@ -136,6 +140,7 @@ class ReadBookController(
         refs?.readView?.onDestroy()
         networkChangedListener.unRegister()
         unregisterTimeBatteryReceiver()
+        restoreActivityWindowState()
     }
 
     // Phase 5: Key handling / page turn
@@ -173,6 +178,7 @@ class ReadBookController(
     }
 
     fun onRouteInitialized() {
+        applyReadBrightness()
         upScreenTimeOut()
     }
 
@@ -1039,6 +1045,25 @@ class ReadBookController(
         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() {
         handler.post {
             if (screenTimeOut < 0) {

+ 20 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -1582,6 +1582,7 @@ private fun FloatingIconRow(
                 ),
                 selected = iconDef.isActive,
                 modifier = Modifier.padding(horizontal = 4.dp),
+                onLongClick = iconDef.onLongClick,
             ) {
                 if (isCustom) {
                     AsyncImage(
@@ -2446,6 +2447,7 @@ private fun ToolButtonItem(
                 menuConfig = state.menuConfig,
                 glassEnabled = true,
                 selected = button.isActive,
+                onLongClick = button.onLongClick,
             ) { tint ->
                 ToolButtonContent(
                     button = button,
@@ -2470,6 +2472,7 @@ private fun ToolButtonItem(
                         indication = LocalIndication.current,
                         interactionSource = remember { MutableInteractionSource() },
                         role = Role.Button,
+                        onLongClick = button.onLongClick,
                         onClick = button.onClick,
                     ),
             ) {
@@ -2553,6 +2556,7 @@ private data class ToolButtonDef(
     val customIconPath: String?,
     val isActive: Boolean = false,
     val onClick: () -> Unit,
+    val onLongClick: (() -> Unit)? = null,
 )
 
 private fun loadToolButtons(
@@ -2561,8 +2565,12 @@ private fun loadToolButtons(
     onIntent: (ReadBookIntent) -> Unit,
 ): List<ToolButtonDef> {
     val customIcons = state.menuConfig.readMenuCustomIcons
-    fun ReadMenuButtonInfo.toButton(isActive: Boolean = false, onClick: () -> Unit): ToolButtonDef {
-        return ToolButtonDef(id, icon, label, customIcons[id], isActive, onClick)
+    fun ReadMenuButtonInfo.toButton(
+        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 allButtons = listOf(
@@ -2572,7 +2580,10 @@ private fun loadToolButtons(
         infoMap.getValue("catalog").toButton {
             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) {
                 onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud))
             } else {
@@ -2906,6 +2917,7 @@ private data class TitleBarIconDef(
     val label: String,
     val isActive: Boolean = false,
     val onClick: () -> Unit,
+    val onLongClick: (() -> Unit)? = null,
 )
 
 private fun loadFloatingIcons(
@@ -2961,6 +2973,11 @@ private fun loadFloatingIcons(
                 label = info.label,
                 isActive = id in activeIds,
                 onClick = actionMap[id] ?: {},
+                onLongClick = if (id == "read_aloud") {
+                    { onIntent(ReadBookIntent.ShowReadAloudConfig) }
+                } else {
+                    null
+                },
             )
         }
         .toList()

+ 15 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -59,7 +59,7 @@ fun ReadBookScreen(
             state.isShowingSearchResult -> onIntent(ReadBookIntent.ExitSearch)
             state.menuVisible -> onIntent(ReadBookIntent.ReadMenuBack)
             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 skipDialog = state.activeDialog as? ReadBookDialog.ConfirmSkipToChapter
     val payDialog = state.activeDialog as? ReadBookDialog.ConfirmChapterPay
+    val addToBookshelfDialog = state.activeDialog as? ReadBookDialog.ConfirmAddToBookshelf
 
     AppAlertDialog(
         show = restoreDialog != null,
@@ -129,6 +130,19 @@ fun ReadBookScreen(
         dismissText = stringResource(R.string.cancel),
         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
     // for proper enter/exit animations

+ 141 - 31
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -9,6 +9,7 @@ import android.speech.tts.TextToSpeech
 import androidx.lifecycle.viewModelScope
 import io.legado.app.BuildConfig
 import io.legado.app.R
+import io.legado.app.constant.AppConst
 import io.legado.app.base.BaseViewModel
 import io.legado.app.constant.AppLog
 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.config.ReadBookConfig
 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.lib.dialogs.SelectItem
 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.hexString
 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.mapParallelSafe
 import io.legado.app.utils.openUrl
@@ -132,6 +139,15 @@ class ReadBookViewModel(
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     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 {
         val tts = TextToSpeech(context, null)
         val engines = tts.engines
@@ -172,7 +188,7 @@ class ReadBookViewModel(
             is ReadBookIntent.NextPage -> ReadBook.moveToNextPage()
             is ReadBookIntent.PrevPage -> ReadBook.moveToPrevPage()
             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.SkipToPage -> ReadBook.skipToPage(intent.pageIndex)
             is ReadBookIntent.ToggleMenu -> _uiState.update {
@@ -220,6 +236,7 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.OpenSearch -> {
+                closeReadMenu()
                 _uiState.update { it.copy(searchContentQuery = intent.word ?: "") }
                 ReadBook.book?.bookUrl?.let { bookUrl ->
                     _effects.tryEmit(ReadBookEffect.OpenSearchActivity(intent.word, bookUrl))
@@ -368,6 +385,7 @@ class ReadBookViewModel(
                         activeSheet = null,
                         contentEditText = "",
                         contentEditTitle = "",
+                        contentEditCursorOffset = 0,
                         contentEditLoading = false,
                         contentEditSaveToSource = false,
                     )
@@ -407,11 +425,13 @@ class ReadBookViewModel(
             }
             is ReadBookIntent.OpenBookInfo -> {
                 ReadBook.book?.let { book ->
+                    closeReadMenu()
                     _effects.tryEmit(ReadBookEffect.OpenBookInfo(book.name, book.author, book.bookUrl))
                 }
             }
             is ReadBookIntent.OpenChapterList -> {
                 ReadBook.book?.bookUrl?.let { bookUrl ->
+                    closeReadMenu()
                     _effects.tryEmit(ReadBookEffect.OpenChapterList(bookUrl))
                 }
             }
@@ -487,8 +507,12 @@ class ReadBookViewModel(
                 _uiState.update { it.copy(activeSheet = ReadBookSheet.ChangeBookSource) }
             }
             is ReadBookIntent.MenuChapterChangeSource -> handleChapterChangeSource()
-            is ReadBookIntent.MenuSettingReplace -> _effects.tryEmit(ReadBookEffect.MenuSettingReplace)
+            is ReadBookIntent.MenuSettingReplace -> {
+                closeReadMenu()
+                _effects.tryEmit(ReadBookEffect.MenuSettingReplace)
+            }
             is ReadBookIntent.MenuTocRegex -> {
+                closeReadMenu()
                 _effects.tryEmit(ReadBookEffect.MenuTocRegex(ReadBook.book?.tocUrl))
             }
             is ReadBookIntent.TocRegexResult -> {
@@ -661,8 +685,16 @@ class ReadBookViewModel(
 
             is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart))
             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 -> {
                 _uiState.update {
@@ -796,17 +828,11 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.ImportHttpTtsJson -> {
-                execute {
-                    HttpTTS.fromJsonArray(intent.json).getOrDefault(arrayListOf())
-                }.onSuccess { list ->
-                    if (list.isNotEmpty()) {
-                        execute {
-                            appDb.httpTTSDao.insert(*list.toTypedArray())
-                        }.onSuccess {
-                            loadTtsEngineItems()
-                        }
-                    }
-                }
+                importHttpTtsSource(intent.json)
+            }
+
+            is ReadBookIntent.ImportHttpTtsSource -> {
+                importHttpTtsSource(intent.text)
             }
 
             is ReadBookIntent.ExportAllHttpTts -> {
@@ -829,20 +855,12 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.ImportHttpTtsFileSelected -> {
-                execute {
+                execute<String?> {
                     val text = context.contentResolver.openInputStream(intent.uri)
                         ?.use { it.reader().readText() }
-                    if (!text.isNullOrBlank()) {
-                        HttpTTS.fromJsonArray(text).getOrDefault(arrayListOf())
-                    } else arrayListOf()
-                }.onSuccess { list ->
-                    if (list.isNotEmpty()) {
-                        execute {
-                            appDb.httpTTSDao.insert(*list.toTypedArray())
-                        }.onSuccess {
-                            loadTtsEngineItems()
-                        }
-                    }
+                    text
+                }.onSuccess { text ->
+                    if (!text.isNullOrBlank()) importHttpTtsSource(text)
                 }
             }
 
@@ -870,7 +888,7 @@ class ReadBookViewModel(
                 if (intent.value) postEvent(EventBus.MEDIA_BUTTON, false)
             }
             is ReadBookIntent.ReadAloudPrevParagraph -> ReadAloud.prevParagraph(context)
-            is ReadBookIntent.ReadAloudTogglePause -> toggleReadAloudPause()
+            is ReadBookIntent.ReadAloudTogglePause -> _effects.tryEmit(ReadBookEffect.ToggleReadAloud)
             is ReadBookIntent.ReadAloudStop -> {
                 ReadAloud.stop(context)
                 _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
@@ -882,6 +900,10 @@ class ReadBookViewModel(
             )
             is ReadBookIntent.ReadAloudNextChapter -> ReadBook.moveToNextChapter(true)
             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 -> {
                 viewModelScope.launch { readAloudSettingsRepository.setTtsFollowSys(intent.value) }
                 _uiState.update { it.copy(readAloudTtsFollowSys = intent.value) }
@@ -1066,7 +1088,7 @@ class ReadBookViewModel(
             is ReadBookIntent.OnResume -> handleOnResume()
             is ReadBookIntent.OnPause -> handleOnPause()
             is ReadBookIntent.OnDispose -> handleOnDispose()
-            is ReadBookIntent.CloseReadBook -> _effects.tryEmit(ReadBookEffect.Finish)
+            is ReadBookIntent.CloseReadBook -> closeReadBook()
             is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false)
             is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri)
         }
@@ -1127,9 +1149,12 @@ class ReadBookViewModel(
     }
 
     private fun handleOnDispose() {
-        // TTS and view cleanup — bridge handles via clearTts()
         backupJob?.cancel()
         ReadBook.cancelPreDownloadTask()
+        if (BaseReadAloudService.isRun) {
+            ReadAloud.stop(context)
+            _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) }
+        }
     }
 
     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 {
         val ttsEngine = ReadAloud.ttsEngine
             ?: return context.getString(R.string.system_tts)
@@ -1399,7 +1460,7 @@ class ReadBookViewModel(
                     }
                 }.toSet()
                 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) {
         execute {
             ReadBook.book?.let { book ->
@@ -2179,6 +2282,7 @@ class ReadBookViewModel(
                 it.copy(
                     contentEditText = text,
                     contentEditTitle = title,
+                    contentEditCursorOffset = ReadBook.durChapterPos.coerceIn(0, text.length),
                     contentEditIsLocalTxt = book.isLocalTxt,
                 )
             }
@@ -2219,7 +2323,13 @@ class ReadBookViewModel(
             } 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)
         }.onError {
             _uiState.update { it.copy(contentEditLoading = false) }

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/read/page/provider/ChapterProvider.kt

@@ -1079,7 +1079,7 @@ object ChapterProvider {
         viewWidth = width
         viewHeight = height
         upLayout()
-        postEvent(EventBus.UP_CONFIG, arrayListOf(10, 5))
+        postEvent(EventBus.UP_CONFIG, arrayListOf(5))
     }
 
     /**

+ 27 - 4
app/src/main/java/io/legado/app/ui/book/read/sheet/ContentEditSheet.kt

@@ -7,12 +7,17 @@ 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.text.input.TextFieldState
+import androidx.compose.foundation.text.input.setTextAndSelectAll
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.book.read.ReadBookIntent
@@ -34,12 +39,31 @@ fun ContentEditSheet(
     onDismissRequest: () -> Unit,
 ) {
     val context = LocalContext.current
+    val editorState = remember { TextFieldState() }
 
     LaunchedEffect(show) {
         if (!show) return@LaunchedEffect
         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(
         show = show,
         onDismissRequest = {
@@ -60,7 +84,7 @@ fun ContentEditSheet(
                     onClick = {
                         onIntent(
                             ReadBookIntent.SaveContentEdit(
-                                state.contentEditText,
+                                editorState.text.toString(),
                                 state.contentEditSaveToSource
                             )
                         )
@@ -76,7 +100,7 @@ fun ContentEditSheet(
                 )
                 SmallTonalButton(
                     onClick = {
-                    context.sendToClip("${state.contentEditTitle}\n${state.contentEditText}")
+                    context.sendToClip("${state.contentEditTitle}\n${editorState.text}")
                     }, text = stringResource(R.string.copy_all)
                 )
             }
@@ -94,8 +118,7 @@ fun ContentEditSheet(
                 }
             } else {
                 AppTextField(
-                    value = state.contentEditText,
-                    onValueChange = { onIntent(ReadBookIntent.SetContentEditText(it)) },
+                    state = editorState,
                     modifier = Modifier
                         .fillMaxWidth()
                         .weight(1f, fill = false)

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

@@ -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.alert.AppAlertDialog
 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.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
@@ -155,6 +157,8 @@ fun SpeakEngineConfigSheet(
     val items = state.ttsEngineItems
     val selectedValue = state.selectedTtsEngine
     var pendingEngineSelection by remember { mutableStateOf<PendingSpeakEngineSelection?>(null) }
+    var showImportSheet by remember { mutableStateOf(false) }
+    var showUrlInput by remember { mutableStateOf(false) }
 
     AppAlertDialog(
         show = pendingEngineSelection != null,
@@ -172,6 +176,29 @@ fun SpeakEngineConfigSheet(
             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(
         show = show,
@@ -195,7 +222,7 @@ fun SpeakEngineConfigSheet(
                         text = stringResource(R.string.import_tts),
                         onClick = {
                             expanded = false
-                            onIntent(ReadBookIntent.ImportHttpTtsFile)
+                            showImportSheet = true
                         },
                     )
                     RoundDropdownMenuItem(

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

@@ -107,8 +107,8 @@ fun ReadAloudContent(
                 modifier = Modifier.weight(1f),
             )
             MediumTonalButton(
-                onClick = { onIntent(ReadBookIntent.SetReadAloudTtsTimer(timerMinute)) },
-                text = stringResource(R.string.timer_m, timerMinute),
+                onClick = { onIntent(ReadBookIntent.SaveReadAloudTtsTimer(timerMinute)) },
+                text = stringResource(R.string.action_save),
                 modifier = Modifier.weight(1f),
             )
             MediumTonalButton(

+ 12 - 3
app/src/main/java/io/legado/app/ui/book/toc/TocScreen.kt

@@ -363,16 +363,25 @@ fun TocScreen(
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.use_replace_rule),
                                 isSelected = useReplace,
-                                onClick = { viewModel.toggleUseReplace() }
+                                onClick = {
+                                    dismiss()
+                                    viewModel.toggleUseReplace()
+                                }
                             )
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.show_word_count),
                                 isSelected = showWordCount,
-                                onClick = { viewModel.toggleShowWordCount() }
+                                onClick = {
+                                    dismiss()
+                                    viewModel.toggleShowWordCount()
+                                }
                             )
                             RoundDropdownMenuItem(
                                 text = stringResource(R.string.reverse_toc),
-                                onClick = { viewModel.reverseToc() }
+                                onClick = {
+                                    dismiss()
+                                    viewModel.reverseToc()
+                                }
                             )
                             PillDivider()
                             RoundDropdownMenuItem(

+ 3 - 4
app/src/main/java/io/legado/app/ui/config/PrefDelegate.kt

@@ -26,7 +26,6 @@ import io.legado.app.utils.putPrefLong
 import io.legado.app.utils.putPrefString
 import io.legado.app.utils.putPrefStringSync
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.distinctUntilChanged
@@ -61,12 +60,12 @@ fun <T> prefDelegate(
 
         @Volatile
         private var currentValue: T = defaultValue
-        private val scope = CoroutineScope(Dispatchers.IO)
+        private val scope = CoroutineScope(kotlinx.coroutines.Dispatchers.IO)
         private var dsObserverJob: Job? = null
 
         init {
             // 同步从 DataStore 读取初始值,确保构造完成后即为最新值
-            val initialValue = runBlocking(Dispatchers.IO) { readFromDs() } ?: defaultValue
+            val initialValue = runBlocking { readFromDs() } ?: defaultValue
             _value = mutableStateOf(initialValue)
             currentValue = initialValue
 
@@ -134,7 +133,7 @@ fun <T> prefDelegate(
                 }
                 // 同步写入 DataStore,确保持久化后再返回
                 runCatching {
-                    runBlocking(Dispatchers.IO) {
+                    runBlocking {
                         when (value) {
                             is String? -> DsSync.putString(key, value)
                             is Int -> DsSync.putInt(key, value)

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

@@ -262,7 +262,7 @@ fun MainActivity.mainEntryProvider(
 
     entry<MainRouteReadBook> { route ->
         val readBookViewModel = koinViewModel<ReadBookViewModel>(
-            key = route.bookUrl ?: "last-read"
+            key = "ReadBook:${route.bookUrl ?: "last-read"}"
         )
         val controller = remember(readBookViewModel) {
             ReadBookController(this@mainEntryProvider, readBookViewModel)
@@ -361,7 +361,7 @@ fun MainActivity.mainEntryProvider(
 
     entry<MainRouteSearchContent> { route ->
         val viewModel = koinViewModel<SearchContentViewModel>(
-            key = route.bookUrl,
+            key = "SearchContent:${route.bookUrl}",
             parameters = { parametersOf(route) }
         )
         SearchContentScreen(
@@ -549,7 +549,7 @@ fun MainActivity.mainEntryProvider(
             } else null
         }
     ) { route ->
-        val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = route.bookUrl)
+        val bookInfoViewModel = koinViewModel<BookInfoViewModel>(key = "BookInfo:${route.bookUrl}")
         BookInfoRouteScreen(
             bookUrl = route.bookUrl,
             name = route.name,