Просмотр исходного кода

fix: 修复 justInitData 在 initData 完成前被设置

HapeLee 4 месяцев назад
Родитель
Сommit
ce5cd3ade7

+ 201 - 27
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<HttpTTS> {
         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<HttpTTS> ?: 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<HttpTTS> ?: 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<HttpTTS> ?: 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<HttpTTS> ?: 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)
+}

+ 6 - 4
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()
+                }
             }
         }
     }