HapeLee 5 месяцев назад
Родитель
Сommit
3a8c4af968

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

@@ -449,7 +449,10 @@ 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 object ImportHttpTtsFile : ReadBookIntent
+    data class ImportHttpTtsFileSelected(val uri: Uri) : ReadBookIntent
     data object ExportAllHttpTts : ReadBookIntent
+    data class ExportHttpTtsToFile(val uri: Uri) : ReadBookIntent
     data class SetReadAloudIgnoreAudioFocus(val value: Boolean) : ReadBookIntent
     data class SetReadAloudPauseOnPhoneCall(val value: Boolean) : ReadBookIntent
     data class SetReadAloudWakeLock(val value: Boolean) : ReadBookIntent
@@ -585,6 +588,8 @@ sealed interface ReadBookEffect {
     data class OpenMenuCustomIconPicker(val id: String) : ReadBookEffect
     data class OpenTitleBarCustomIconPicker(val id: String) : ReadBookEffect
     data object OpenSystemTtsSettings : ReadBookEffect
+    data object OpenHttpTtsImportPicker : ReadBookEffect
+    data object OpenHttpTtsExportPicker : ReadBookEffect
     data class OpenHttpTtsLogin(val engineId: Long) : ReadBookEffect
 
     // Day/night toggle

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

@@ -771,6 +771,8 @@ class ReadBookController(
             is ReadBookEffect.OpenMenuCustomIconPicker,
             is ReadBookEffect.OpenTitleBarCustomIconPicker,
             is ReadBookEffect.OpenSystemTtsSettings,
+            is ReadBookEffect.OpenHttpTtsImportPicker,
+            is ReadBookEffect.OpenHttpTtsExportPicker,
             is ReadBookEffect.OpenHttpTtsLogin,
             is ReadBookEffect.TtsCacheCleared,
             is ReadBookEffect.ExportJson,

+ 26 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt

@@ -29,9 +29,6 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentData
 import io.legado.app.help.IntentHelp
-import io.legado.app.model.ReadBook
-import io.legado.app.utils.toastOnUi
-import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ReadView
@@ -41,11 +38,13 @@ import io.legado.app.ui.book.searchContent.SearchResult
 import io.legado.app.ui.book.source.edit.BookSourceEditActivity
 import io.legado.app.ui.book.toc.TocActivityResult
 import io.legado.app.ui.book.toc.rule.TxtTocRuleActivity
+import io.legado.app.ui.browser.WebViewActivity
 import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.replace.ReplaceEditRoute
 import io.legado.app.ui.replace.ReplaceRuleActivity
 import io.legado.app.utils.StartActivityContract
 import io.legado.app.utils.takePersistablePermissionSafely
+import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.onSubscription
 import kotlinx.coroutines.launch
 
@@ -223,6 +222,18 @@ fun ReadBookRouteScreen(
         }
     }
 
+    val importHttpTtsPicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.OpenDocument()
+    ) { uri ->
+        uri?.let { viewModel.onIntent(ReadBookIntent.ImportHttpTtsFileSelected(it)) }
+    }
+
+    val exportHttpTtsPicker = rememberLauncherForActivityResult(
+        ActivityResultContracts.CreateDocument("application/json")
+    ) { uri ->
+        uri?.let { viewModel.onIntent(ReadBookIntent.ExportHttpTtsToFile(it)) }
+    }
+
     val bookInfoLauncher = rememberLauncherForActivityResult(
         StartActivityContract(BookInfoActivity::class.java)
     ) { result ->
@@ -353,6 +364,18 @@ fun ReadBookRouteScreen(
                             is ReadBookEffect.TtsCacheCleared -> {
                                 context.toastOnUi(effect.message)
                             }
+                            is ReadBookEffect.OpenHttpTtsImportPicker -> {
+                                importHttpTtsPicker.launch(
+                                    arrayOf(
+                                        "application/json",
+                                        "text/plain"
+                                    )
+                                )
+                            }
+
+                            is ReadBookEffect.OpenHttpTtsExportPicker -> {
+                                exportHttpTtsPicker.launch("httpTTS.json")
+                            }
 
                             // All other effects — delegate to bridge (View/Window/Activity operations)
                             else -> controller.handleEffect(effect)

+ 48 - 3
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -5,6 +5,7 @@ import android.content.Context
 import android.content.Intent
 import android.net.Uri
 import android.provider.OpenableColumns
+import android.speech.tts.TextToSpeech
 import androidx.lifecycle.viewModelScope
 import io.legado.app.BuildConfig
 import io.legado.app.R
@@ -133,6 +134,13 @@ class ReadBookViewModel(
     private val _effects = MutableSharedFlow<ReadBookEffect>(extraBufferCapacity = 16)
     val effects = _effects.asSharedFlow()
 
+    private val sysEngines: List<TextToSpeech.EngineInfo> by lazy {
+        val tts = TextToSpeech(context, null)
+        val engines = tts.engines
+        tts.shutdown()
+        engines
+    }
+
     private val _readPreferences = MutableStateFlow(ReadPreferences())
     val readPreferences = _readPreferences.asStateFlow()
 
@@ -737,10 +745,39 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.ExportAllHttpTts -> {
+                _effects.tryEmit(ReadBookEffect.OpenHttpTtsExportPicker)
+            }
+
+            is ReadBookIntent.ExportHttpTtsToFile -> {
                 execute {
-                    GSON.toJson(appDb.httpTTSDao.all)
-                }.onSuccess { json ->
-                    _effects.tryEmit(ReadBookEffect.ExportJson(json))
+                    val json = GSON.toJson(appDb.httpTTSDao.all)
+                    context.contentResolver.openOutputStream(intent.uri)?.use { os ->
+                        os.write(json.toByteArray())
+                    }
+                }.onSuccess {
+                    _effects.tryEmit(ReadBookEffect.ShowToast(context.getString(R.string.export_success)))
+                }
+            }
+
+            is ReadBookIntent.ImportHttpTtsFile -> {
+                _effects.tryEmit(ReadBookEffect.OpenHttpTtsImportPicker)
+            }
+
+            is ReadBookIntent.ImportHttpTtsFileSelected -> {
+                execute {
+                    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()
+                        }
+                    }
                 }
             }
 
@@ -1032,6 +1069,14 @@ class ReadBookViewModel(
         execute {
             buildList {
                 add(ReadBookTtsEngineItem(context.getString(R.string.system_tts), null))
+                sysEngines.forEach { engine ->
+                    add(
+                        ReadBookTtsEngineItem(
+                            title = engine.label,
+                            value = GSON.toJson(SelectItem(engine.label, engine.name)),
+                        )
+                    )
+                }
                 appDb.httpTTSDao.all.forEach { httpTts ->
                     add(
                         ReadBookTtsEngineItem(

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

@@ -8,30 +8,21 @@ import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Description
 import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Edit
-import androidx.compose.material.icons.filled.FileDownload
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Save
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.Icon
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalClipboardManager
 import androidx.compose.ui.res.stringResource
@@ -40,7 +31,6 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.ui.book.read.ReadBookIntent
-import io.legado.app.ui.book.read.ReadBookTtsEngineItem
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
@@ -162,7 +152,6 @@ fun SpeakEngineConfigSheet(
     onIntent: (ReadBookIntent) -> Unit,
     onDismissRequest: () -> Unit,
 ) {
-    val clipboardManager = LocalClipboardManager.current
     val items = state.ttsEngineItems
     val selectedValue = state.selectedTtsEngine
     var pendingEngineValue by remember { mutableStateOf<String?>(null) }
@@ -189,27 +178,41 @@ fun SpeakEngineConfigSheet(
         onDismissRequest = onDismissRequest,
         title = stringResource(R.string.speak_engine),
         startAction = {
-            Row(
-                horizontalArrangement = Arrangement.spacedBy(2.dp)
-            ) {
-                SmallTonalButton(
-                    onClick = { onIntent(ReadBookIntent.EditHttpTts()) },
-                    icon = Icons.Default.Add
-                )
-                SmallTonalButton(
-                    onClick = {
-                        clipboardManager.getText()?.text?.let { text ->
-                            if (text.isNotBlank()) {
-                                onIntent(ReadBookIntent.ImportHttpTtsJson(text))
-                            }
-                        }
-                    },
-                    icon = Icons.Default.Description
-                )
+            SmallTonalButton(
+                onClick = { onIntent(ReadBookIntent.EditHttpTts()) },
+                icon = Icons.Default.Add
+            )
+        },
+        endAction = {
+            var expanded by remember { mutableStateOf(false) }
+            Box {
                 SmallTonalButton(
-                    onClick = { onIntent(ReadBookIntent.ExportAllHttpTts) },
-                    icon = Icons.Default.FileDownload
+                    onClick = { expanded = true },
+                    icon = Icons.Default.MoreVert
                 )
+                RoundDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.import_tts),
+                        onClick = {
+                            expanded = false
+                            onIntent(ReadBookIntent.ImportHttpTtsFile)
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.export),
+                        onClick = {
+                            expanded = false
+                            onIntent(ReadBookIntent.ExportAllHttpTts)
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.clear_cache),
+                        onClick = {
+                            expanded = false
+                            onIntent(ReadBookIntent.ClearTtsCache)
+                        },
+                    )
+                }
             }
         },
     ) {
@@ -220,7 +223,7 @@ fun SpeakEngineConfigSheet(
                 .padding(bottom = 8.dp),
         ) {
             items(items) { item ->
-                val isHttpTts = item.value != null
+                val httpTtsId = item.value?.toLongOrNull()
                 val isSelected = item.value == selectedValue
                 TinyClickableSettingItem(
                     title = item.title,
@@ -230,18 +233,18 @@ fun SpeakEngineConfigSheet(
                         null
                     },
                     onClick = { pendingEngineValue = item.value },
-                    onLongClick = if (isHttpTts && !item.loginUrl.isNullOrBlank()) {
-                        { onIntent(ReadBookIntent.OpenHttpTtsLogin(item.value!!.toLong())) }
+                    onLongClick = if (httpTtsId != null && !item.loginUrl.isNullOrBlank()) {
+                        { onIntent(ReadBookIntent.OpenHttpTtsLogin(httpTtsId)) }
                     } else null,
-                    trailingContent = if (isHttpTts) {
+                    trailingContent = if (httpTtsId != null) {
                         {
                             Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
                                 SmallTonalButton(
-                                    onClick = { onIntent(ReadBookIntent.EditHttpTts(item.value!!.toLong())) },
+                                    onClick = { onIntent(ReadBookIntent.EditHttpTts(httpTtsId)) },
                                     icon = Icons.Default.Edit,
                                 )
                                 SmallTonalButton(
-                                    onClick = { onIntent(ReadBookIntent.DeleteHttpTts(item.value!!.toLong())) },
+                                    onClick = { onIntent(ReadBookIntent.DeleteHttpTts(httpTtsId)) },
                                     icon = Icons.Default.Delete,
                                 )
                             }