diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt index df0f70e98..360b7a697 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt @@ -6,12 +6,14 @@ import android.content.Intent import android.net.Uri import android.provider.OpenableColumns import android.speech.tts.TextToSpeech +import android.util.Base64 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.AppPattern import io.legado.app.constant.BookType import io.legado.app.constant.EventBus import io.legado.app.constant.PreferKey @@ -70,6 +72,9 @@ import io.legado.app.ui.book.searchContent.SearchResult import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.ui.config.readConfig.ReadConfig import io.legado.app.ui.config.themeConfig.ThemeConfig +import io.legado.app.ui.widget.components.importComponents.BaseImportUiState +import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper +import io.legado.app.ui.widget.components.importComponents.ImportStatus import io.legado.app.utils.GSON import io.legado.app.utils.ImageSaveUtils import io.legado.app.utils.NetworkUtils @@ -77,12 +82,14 @@ 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.isDataUrl 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 import io.legado.app.utils.postEvent +import io.legado.app.utils.sendToClip import io.legado.app.utils.toStringArray import io.legado.app.utils.toastOnUi import kotlinx.collections.immutable.toImmutableList @@ -111,6 +118,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.FileNotFoundException +import java.net.URLEncoder import kotlin.coroutines.coroutineContext /** @@ -181,8 +189,8 @@ class ReadBookViewModel( fun onIntent(intent: ReadBookIntent) { when (intent) { is ReadBookIntent.InitData -> { - justInitData = true initData(intent.intent) + justInitData = true } is ReadBookIntent.InitReadBookConfig -> initReadBookConfig(intent.intent) is ReadBookIntent.NextPage -> ReadBook.moveToNextPage() @@ -685,13 +693,7 @@ class ReadBookViewModel( is ReadBookIntent.TtsProgress -> _effects.tryEmit(ReadBookEffect.UpTtsAloudSpan(intent.chapterStart)) is ReadBookIntent.ReadAloudAction -> { - _uiState.update { - it.copy( - speakEngineName = computeSpeakEngineName(), - activeSheet = ReadBookSheet.ReadAloudConfig, - ) - } - loadTtsEngineItems() + openReadMenuRoute(ReadBookMenuRoute.ReadAloud) } is ReadBookIntent.ConfirmAddCurrentBookToBookshelf -> addCurrentBookToBookshelfAndFinish() is ReadBookIntent.ExitWithoutAddingCurrentBookToBookshelf -> removeCurrentNotShelfBookAndFinish() @@ -839,9 +841,23 @@ class ReadBookViewModel( _effects.tryEmit(ReadBookEffect.OpenHttpTtsExportPicker) } + is ReadBookIntent.ExportAllHttpTtsAsUrl -> { + execute { + val json = exportHttpTtsJson() + val dataUrl = "data:application/json;base64," + Base64.encodeToString( + json.toByteArray(Charsets.UTF_8), + Base64.NO_WRAP + ) + "legado://import/httpTTS?src=" + URLEncoder.encode(dataUrl, "UTF-8") + }.onSuccess { url -> + context.sendToClip(url) + _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.copy_url))) + } + } + is ReadBookIntent.ExportHttpTtsToFile -> { execute { - val json = GSON.toJson(appDb.httpTTSDao.all) + val json = exportHttpTtsJson() context.contentResolver.openOutputStream(intent.uri)?.use { os -> os.write(json.toByteArray()) } @@ -864,6 +880,22 @@ class ReadBookViewModel( } } + ReadBookIntent.CancelHttpTtsImport -> cancelHttpTtsImport() + + is ReadBookIntent.ToggleHttpTtsImportSelection -> { + toggleHttpTtsImportSelection(intent.index) + } + + is ReadBookIntent.ToggleHttpTtsImportAll -> { + toggleHttpTtsImportAll(intent.isSelected) + } + + is ReadBookIntent.UpdateHttpTtsImportItem -> { + updateHttpTtsImportItem(intent.index, intent.httpTTS) + } + + ReadBookIntent.SaveImportedHttpTts -> saveImportedHttpTts() + is ReadBookIntent.SetReadAloudIgnoreAudioFocus -> { viewModelScope.launch { readAloudSettingsRepository.setIgnoreAudioFocus(intent.value) } } @@ -1030,7 +1062,9 @@ class ReadBookViewModel( // Text action menu is ReadBookIntent.TextActionAloud -> { when (ReadConfig.contentSelectSpeakMod) { - 1 -> _effects.tryEmit(ReadBookEffect.TextActionAloudSelect) + 1 -> intent.selectStartPos?.let { + _effects.tryEmit(ReadBookEffect.TextActionAloudSelect(it.copy())) + } ?: _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text)) else -> _effects.tryEmit(ReadBookEffect.TextActionSpeak(intent.text)) } } @@ -1094,7 +1128,7 @@ class ReadBookViewModel( is ReadBookIntent.OnResume -> handleOnResume() is ReadBookIntent.OnPause -> handleOnPause() is ReadBookIntent.OnDispose -> handleOnDispose() - is ReadBookIntent.CloseReadBook -> closeReadBook() + is ReadBookIntent.CloseReadBook -> closeReadBook(intent.keepReadAloud) is ReadBookIntent.OpenBooksDirPicker -> requestBooksDirPicker(reloadChapterList = false) is ReadBookIntent.BooksDirSelected -> onBooksDirSelected(intent.uri) } @@ -1157,10 +1191,6 @@ class ReadBookViewModel( private fun handleOnDispose() { backupJob?.cancel() ReadBook.cancelPreDownloadTask() - if (BaseReadAloudService.isRun) { - ReadAloud.stop(context) - _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) } - } } private fun showSpeakEngineConfig() { @@ -1203,26 +1233,63 @@ class ReadBookViewModel( } private fun importHttpTtsSource(text: String) { + _uiState.update { + it.copy(httpTtsImportState = BaseImportUiState.Loading) + } execute { - importHttpTtsSourceAwait(text.trim()).also { list -> - if (list.isNotEmpty()) { - appDb.httpTTSDao.insert(*list.toTypedArray()) + val list = importHttpTtsSourceAwait(text.trim()) + val items = list.map { httpTTS -> + val old = appDb.httpTTSDao.get(httpTTS.id) + val status = when { + old == null -> ImportStatus.New + httpTTS.lastUpdateTime > old.lastUpdateTime -> ImportStatus.Update + else -> ImportStatus.Existing } + ImportItemWrapper( + data = httpTTS, + oldData = old, + status = status, + isSelected = status != ImportStatus.Existing, + ) } - }.onSuccess { list -> - if (list.isNotEmpty()) { - loadTtsEngineItems() - _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.success))) + if (items.isEmpty()) { + throw NoStackTraceException(context.getString(R.string.wrong_format)) + } + BaseImportUiState.Success( + source = text, + items = items, + ) + }.onSuccess { importState -> + _uiState.update { + it.copy(httpTtsImportState = importState) } }.onError { AppLog.put("导入朗读引擎失败\n${it.localizedMessage}", it, true) + _uiState.update { state -> + state.copy( + httpTtsImportState = BaseImportUiState.Error( + it.localizedMessage ?: context.getString(R.string.wrong_format) + ) + ) + } } } private suspend fun importHttpTtsSourceAwait(text: String): List { return when { + text.isHttpTtsImportUri() -> { + val src = Uri.parse(text).getQueryParameter("src") + ?: throw NoStackTraceException(context.getString(R.string.wrong_format)) + importHttpTtsSourceAwait(src) + } text.isJsonObject() -> listOf(HttpTTS.fromJson(text).getOrThrow()) text.isJsonArray() -> HttpTTS.fromJsonArray(text).getOrThrow() + text.isDataUrl() -> { + val data = AppPattern.dataUriRegex.find(text)?.groupValues?.getOrNull(1) + ?: throw NoStackTraceException(context.getString(R.string.wrong_format)) + val body = Base64.decode(data, Base64.DEFAULT).toString(Charsets.UTF_8) + importHttpTtsSourceAwait(body) + } text.isAbsUrl() -> { val body = okHttpClient.newCallResponseBody { if (text.endsWith("#requestWithoutUA")) { @@ -1238,6 +1305,91 @@ class ReadBookViewModel( } } + private fun cancelHttpTtsImport() { + _uiState.update { + it.copy( + httpTtsImportState = BaseImportUiState.Idle, + activeSheet = ReadBookSheet.SpeakEngineConfig, + ) + } + } + + private fun toggleHttpTtsImportSelection(index: Int) { + val importState = _uiState.value.httpTtsImportState + as? BaseImportUiState.Success ?: return + if (index !in importState.items.indices) return + val items = importState.items.toMutableList() + val item = items[index] + items[index] = item.copy(isSelected = !item.isSelected) + _uiState.update { + it.copy(httpTtsImportState = importState.copy(items = items)) + } + } + + private fun toggleHttpTtsImportAll(isSelected: Boolean) { + val importState = _uiState.value.httpTtsImportState + as? BaseImportUiState.Success ?: return + _uiState.update { + it.copy( + httpTtsImportState = importState.copy( + items = importState.items.map { item -> + item.copy(isSelected = isSelected) + } + ) + ) + } + } + + private fun updateHttpTtsImportItem(index: Int, httpTTS: HttpTTS) { + val importState = _uiState.value.httpTtsImportState + as? BaseImportUiState.Success ?: return + if (index !in importState.items.indices) return + val items = importState.items.toMutableList() + items[index] = items[index].copy(data = httpTTS) + _uiState.update { + it.copy( + httpTtsImportState = importState.copy( + items = items, + version = importState.version + 1, + ) + ) + } + } + + private fun saveImportedHttpTts() { + val importState = _uiState.value.httpTtsImportState + as? BaseImportUiState.Success ?: return + val selected = importState.items + .filter { it.isSelected } + .map { it.data } + if (selected.isEmpty()) return + execute { + appDb.httpTTSDao.insert(*selected.toTypedArray()) + }.onSuccess { + loadTtsEngineItems() + _uiState.update { + it.copy( + httpTtsImportState = BaseImportUiState.Idle, + activeSheet = ReadBookSheet.SpeakEngineConfig, + ) + } + _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.success))) + }.onError { + AppLog.put("保存朗读引擎失败\n${it.localizedMessage}", it, true) + _uiState.update { state -> + state.copy( + httpTtsImportState = BaseImportUiState.Error( + it.localizedMessage ?: context.getString(R.string.wrong_format) + ) + ) + } + } + } + + private fun exportHttpTtsJson(): String { + return GSON.toJson(appDb.httpTTSDao.all) + } + private fun computeSpeakEngineName(): String { val ttsEngine = ReadAloud.ttsEngine ?: return context.getString(R.string.system_tts) @@ -1578,9 +1730,6 @@ class ReadBookViewModel( private fun setReadAloudTtsTimer(value: Int) { val timer = value.coerceIn(0, 180) - viewModelScope.launch { - readAloudSettingsRepository.setTtsTimer(timer) - } ReadAloud.setTimer(context, timer) _uiState.update { it.copy(readAloudTtsTimer = timer) } } @@ -1858,6 +2007,10 @@ class ReadBookViewModel( } } + fun markJustInitData() { + justInitData = true + } + private suspend fun initBook(book: Book) { val isSameBook = ReadBook.book?.bookUrl == book.bookUrl if (isSameBook) { @@ -2159,7 +2312,10 @@ class ReadBookViewModel( } } - private fun closeReadBook() { + private var closeReadBookKeepReadAloud = false + + private fun closeReadBook(keepReadAloud: Boolean = false) { + closeReadBookKeepReadAloud = keepReadAloud val book = ReadBook.book if (!ReadBook.inBookshelf && book != null && OtherConfig.showAddToShelfAlert) { _uiState.update { @@ -2168,10 +2324,19 @@ class ReadBookViewModel( } else if (!ReadBook.inBookshelf) { removeCurrentNotShelfBookAndFinish() } else { + stopReadAloudForClose() _effects.tryEmit(ReadBookEffect.Finish) } } + private fun stopReadAloudForClose() { + if (closeReadBookKeepReadAloud || !BaseReadAloudService.isRun) { + return + } + ReadAloud.stop(context) + _uiState.update { it.copy(isReadAloudRunning = false, isReadAloudPaused = false) } + } + private fun addCurrentBookToBookshelfAndFinish() { val book = ReadBook.book ?: return removeCurrentNotShelfBookAndFinish() execute { @@ -2187,6 +2352,7 @@ class ReadBookViewModel( ReadBook.inBookshelf = true }.onSuccess { _uiState.update { it.copy(activeDialog = null) } + stopReadAloudForClose() _effects.tryEmit(ReadBookEffect.Finish) }.onError { AppLog.put("添加书籍到书架失败", it) @@ -2197,6 +2363,7 @@ class ReadBookViewModel( private fun removeCurrentNotShelfBookAndFinish() { _uiState.update { it.copy(activeDialog = null) } removeFromBookshelf { + stopReadAloudForClose() _effects.tryEmit(ReadBookEffect.Finish) } } @@ -3786,3 +3953,10 @@ private data class SearchTextPoint( private fun Int.coerceSearchResultIndex(resultSize: Int): Int { return if (resultSize <= 0) 0 else coerceIn(0, resultSize - 1) } + +private fun String.isHttpTtsImportUri(): Boolean { + val uri = runCatching { Uri.parse(this) }.getOrNull() ?: return false + return uri.scheme in setOf("legado", "yuedu") + && uri.host == "import" + && uri.path.equals("/httpTTS", ignoreCase = true) +} diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 844be94ce..072f56771 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -351,10 +351,12 @@ fun MainActivity.mainEntryProvider( effectsReady.await() collectorReady[0] = true readBookViewModel.initReadBookConfig(readIntent) - readBookViewModel.initData(readIntent) - controller.onRouteInitialized() - if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { - resumeReader() + readBookViewModel.initData(readIntent) { + readBookViewModel.markJustInitData() + controller.onRouteInitialized() + if (lifecycleOwner.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) { + resumeReader() + } } } }