diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt index 6672742cb..01a863cfc 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt index f4975493f..384e56523 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookController.kt +++ b/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, diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt index 6e87e6245..ac0aad890 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt +++ b/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) 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 f051eb638..470278f74 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 @@ -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(extraBufferCapacity = 16) val effects = _effects.asSharedFlow() + private val sysEngines: List 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( diff --git a/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt b/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt index a7a1908f0..6cea9124c 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigSheet.kt +++ b/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(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 + ) + }, + endAction = { + var expanded by remember { mutableStateOf(false) } + Box { 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.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, ) }