diff --git a/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt index e1126c76b..7d3bd5bae 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt @@ -17,13 +17,17 @@ import io.legado.app.ui.config.otherConfig.OtherConfig import io.legado.app.utils.internString import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withTimeout import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger sealed interface ChangeSourceSearchEvent { data object Started : ChangeSourceSearchEvent @@ -50,8 +54,9 @@ class ChangeSourceSearchUseCase( // Shared state for TOC cache private val tocMap = ConcurrentHashMap>() private val bookMap = ConcurrentHashMap() - private var tocMapChapterCount = 0 + private val tocMapChapterCount = AtomicInteger(0) + @OptIn(ExperimentalCoroutinesApi::class) fun search( name: String, author: String, @@ -64,45 +69,64 @@ class ChangeSourceSearchUseCase( if (bookSourceParts.isEmpty()) { throw io.legado.app.exception.NoStackTraceException("启用书源为空") } + val sources = bookSourceParts.mapNotNull { it.getBookSource() } + if (sources.isEmpty()) { + throw io.legado.app.exception.NoStackTraceException("启用书源为空") + } tocMap.clear() bookMap.clear() - tocMapChapterCount = 0 + tocMapChapterCount.set(0) emit(ChangeSourceSearchEvent.Started) var processedSources = 0 - val totalSources = bookSourceParts.size + var resultCount = 0 + val totalSources = sources.size + val concurrency = threadCount.coerceAtLeast(1) - for (bs in bookSourceParts) { - currentCoroutineContext().ensureActive() - val source = bs.getBookSource() ?: continue - try { - withTimeout(60000L) { - searchSource( - source, name, author, oldBook, fromReadBookActivity, - contentProcessor - ) - }.forEach { searchBook -> + sources.asFlow() + .flatMapMerge(concurrency) { source -> + flow { + val books = try { + withTimeout(60000L) { + searchSource( + source, name, author, oldBook, fromReadBookActivity, + contentProcessor + ) + } + } catch (_: Throwable) { + currentCoroutineContext().ensureActive() + emptyList() + } + emit(ChangeSourceResult(source, books)) + }.flowOn(Dispatchers.IO) + } + .collect { result -> + currentCoroutineContext().ensureActive() + result.books.forEach { searchBook -> + resultCount++ emit(ChangeSourceSearchEvent.Result(searchBook)) } - } catch (_: Throwable) { - currentCoroutineContext().ensureActive() - } - processedSources++ - emit( - ChangeSourceSearchEvent.Progress( - processedSources = processedSources, - totalSources = totalSources, - resultCount = 0, - sourceName = source.bookSourceName, + processedSources++ + emit( + ChangeSourceSearchEvent.Progress( + processedSources = processedSources, + totalSources = totalSources, + resultCount = resultCount, + sourceName = result.source.bookSourceName, + ) ) - ) - } + } - emit(ChangeSourceSearchEvent.Finished(isEmpty = true)) + emit(ChangeSourceSearchEvent.Finished(isEmpty = resultCount == 0)) }.flowOn(Dispatchers.IO) + private data class ChangeSourceResult( + val source: BookSource, + val books: List, + ) + private suspend fun searchSource( source: BookSource, name: String, @@ -191,8 +215,8 @@ class ChangeSourceSearchUseCase( for (chapter in chapters) { chapter.internString() } - if (tocMapChapterCount < 30000) { - tocMapChapterCount += chapters.size + if (tocMapChapterCount.get() < 30000) { + tocMapChapterCount.addAndGet(chapters.size) tocMap[book.primaryStr()] = chapters } bookMap[book.primaryStr()] = book diff --git a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt index 7e63cad79..9a29f0569 100644 --- a/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt @@ -11,6 +11,7 @@ import io.legado.app.data.repository.SearchRepository import io.legado.app.domain.usecase.ChangeSourceSearchEvent import io.legado.app.domain.usecase.ChangeSourceSearchUseCase import io.legado.app.domain.usecase.GetChapterContentUseCase +import io.legado.app.help.book.isWebFile import io.legado.app.help.book.primaryStr import io.legado.app.ui.book.search.SearchScope import kotlinx.coroutines.Job @@ -57,8 +58,8 @@ class ChangeBookSourceComposeViewModel( private val _searchDataFlow = MutableStateFlow>(emptyList()) val searchDataFlow: StateFlow> = _searchDataFlow.asStateFlow() - val totalSourceCount: Int - get() = searchResults.size + var totalSourceCount: Int = 0 + private set fun getBookFromMap(key: String): Book? = bookMap[key]?.toBook() @@ -71,6 +72,7 @@ class ChangeBookSourceComposeViewModel( // Internal state private var searchJob: Job? = null private var oldBook: Book? = null + private var fromReadBookActivity: Boolean = false private var screenKey: String = "" private val searchResults = mutableListOf() private val bookMap = mutableMapOf() @@ -84,6 +86,7 @@ class ChangeBookSourceComposeViewModel( fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) { this.oldBook = book + this.fromReadBookActivity = fromReadBookActivity if (searchJob?.isActive != true) { startSearch() } @@ -95,6 +98,8 @@ class ChangeBookSourceComposeViewModel( searchResults.clear() bookMap.clear() tocMap.clear() + totalSourceCount = 0 + _changeSourceProgress.value = 0 to "" _searchDataFlow.value = emptyList() searchJob = viewModelScope.launch { @@ -103,7 +108,7 @@ class ChangeBookSourceComposeViewModel( author = book.author, scope = SearchScope(ChangeSourceConfig.searchScope), oldBook = book, - fromReadBookActivity = false, + fromReadBookActivity = fromReadBookActivity, ).collect { event -> when (event) { is ChangeSourceSearchEvent.Started -> { @@ -111,6 +116,7 @@ class ChangeBookSourceComposeViewModel( } is ChangeSourceSearchEvent.Progress -> { + totalSourceCount = event.totalSources _changeSourceProgress.value = event.processedSources to event.sourceName } @@ -196,6 +202,12 @@ class ChangeBookSourceComposeViewModel( return@launch } } + if (book.isWebFile) { + val source = io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin) + ?: throw io.legado.app.exception.NoStackTraceException("书源不存在") + onSuccess(emptyList(), source) + return@launch + } val (toc, source) = getChapterContentUseCase.getToc(book) tocMap[book.primaryStr()] = toc onSuccess(toc, source) diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt index d199708f5..87631b80b 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt @@ -89,6 +89,7 @@ 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.card.GlassCard import io.legado.app.ui.widget.components.card.TextCard +import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet import io.legado.app.ui.widget.components.icon.AppIcon import io.legado.app.ui.widget.components.image.cover.BookCoverImage import io.legado.app.ui.widget.components.image.cover.CoilBookCover diff --git a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt index 9799488d5..69bc24dba 100644 --- a/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt +++ b/app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt @@ -76,6 +76,7 @@ import io.legado.app.ui.widget.components.button.series.MediumPlainButton import io.legado.app.ui.widget.components.button.series.SmallPlainButton import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.SelectionItemCard +import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet import io.legado.app.ui.widget.components.checkBox.AppCheckbox import io.legado.app.ui.widget.components.image.cover.CoilBookCover import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu @@ -303,356 +304,3 @@ fun ChangeSourceSheet( ) } } - -@Composable -fun ChangeSourceSheet( - show: Boolean, - oldBook: Book, - onDismissRequest: () -> Unit, - onReplace: (BookSource, Book, List, ChangeSourceMigrationOptions) -> Unit, - onAddAsNew: (Book, List) -> Unit, - viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), -) { - val context = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) - val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() - val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle() - val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList()) - val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList()) - val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle() - var searchQuery by rememberSaveable { mutableStateOf("") } - val checkAuthor = viewModel.checkAuthor - val loadInfo = viewModel.loadInfo - val loadToc = viewModel.loadToc - val loadWordCount = viewModel.loadWordCount - var actionBook by remember { mutableStateOf(null) } - var mismatchBook by remember { mutableStateOf(null) } - var showMigrationOptions by remember { mutableStateOf(false) } - var loadingAction by remember { mutableStateOf(false) } - var showOptionsMenu by rememberSaveable { mutableStateOf(false) } - var showFilterSheet by rememberSaveable { mutableStateOf(false) } - val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf) - - val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { - val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult - viewModel.startSearch(origin) - } - - LaunchedEffect(oldBook.bookUrl) { - viewModel.initData(oldBook.name, oldBook.author, oldBook, false) - } - - DisposableEffect(lifecycleOwner, viewModel) { - val observer = LifecycleEventObserver { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> viewModel.resume() - Lifecycle.Event.ON_PAUSE -> viewModel.pause() - else -> Unit - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - DisposableEffect(oldBook.bookUrl) { - onDispose { - viewModel.stopSearch() - } - } - - AppModalBottomSheet( - show = show, - onDismissRequest = onDismissRequest, - title = stringResource(R.string.book_source), - startAction = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Box { - MediumPlainButton( - onClick = { showOptionsMenu = true }, - icon = Icons.Default.MoreVert - ) - RoundDropdownMenu( - expanded = showOptionsMenu, - onDismissRequest = { showOptionsMenu = false } - ) { dismiss -> - RoundDropdownMenuItem( - text = "校验作者", - isSelected = checkAuthor, - onClick = { - viewModel.onCheckAuthorChange(!checkAuthor) - dismiss() - } - ) - RoundDropdownMenuItem( - text = "加载详情", - isSelected = loadInfo, - onClick = { - viewModel.onLoadInfoChange(!loadInfo) - dismiss() - } - ) - RoundDropdownMenuItem( - text = "加载目录", - isSelected = loadToc, - onClick = { - viewModel.onLoadTocChange(!loadToc) - dismiss() - } - ) - RoundDropdownMenuItem( - text = "显示更多信息", - isSelected = loadWordCount, - onClick = { - viewModel.onLoadWordCountChange(!loadWordCount) - dismiss() - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.book_source_manage), - onClick = { - context.startActivity() - dismiss() - } - ) - } - } - MediumPlainButton( - onClick = { showMigrationOptions = true }, - icon = Icons.Outlined.Settings - ) - } - }, - endAction = { - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { - MediumPlainButton( - onClick = { viewModel.startOrStopSearch() }, - icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, - ) - MediumPlainButton( - onClick = { showFilterSheet = true }, - icon = Icons.Default.FilterList - ) - } - } - ) { - AppTextField( - value = searchQuery, - backgroundColor = LegadoTheme.colorScheme.surface, - onValueChange = { - searchQuery = it - viewModel.screen(it) - }, - label = stringResource(R.string.screen), - modifier = Modifier.fillMaxWidth() - ) - Spacer(modifier = Modifier.height(12.dp)) - if (isSearching) { - AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Spacer(modifier = Modifier.height(8.dp)) - AppText( - text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}", - style = LegadoTheme.typography.bodySmall - ) - Spacer(modifier = Modifier.height(12.dp)) - } - - if (items.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 40.dp), - contentAlignment = Alignment.Center - ) { - EmptyMessage( - message = stringResource(R.string.search_empty) - ) - } - } else { - LazyColumn( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(items, key = { it.bookUrl + it.origin }) { item -> - val bookScore by remember(item.origin, item.name, item.author) { - viewModel.bookScoreFlow(item) - }.collectAsStateWithLifecycle() - SelectionItemCard( - title = item.originName, - containerColor = LegadoTheme.colorScheme.onSheetContent, - selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), - leadingContent = { - MediumPlainButton( - onClick = { - viewModel.onBookScoreClick(item) - }, - icon = Icons.Default.PushPin, - tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, - contentDescription = null - ) - }, - supportingContent = { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - AppText( - text = item.author, - style = LegadoTheme.typography.labelLargeEmphasized - ) - AppText( - text = item.getDisplayLastChapterTitle(), - style = LegadoTheme.typography.labelMediumEmphasized - ) - item.chapterWordCountText?.takeIf { loadWordCount }?.let { - AppText( - text = it, - style = LegadoTheme.typography.labelSmallEmphasized, - color = LegadoTheme.colorScheme.primary - ) - } - } - }, - isSelected = item.bookUrl == oldBook.bookUrl, - onToggleSelection = { - if (item.bookUrl != oldBook.bookUrl) { - if (!item.sameBookTypeLocal(oldBook.type)) mismatchBook = item else actionBook = - item - } - }, - dropdownContent = { onDismiss: () -> Unit -> - RoundDropdownMenuItem( - text = stringResource(R.string.to_top), - onClick = { - viewModel.topSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = "置底", - onClick = { - viewModel.bottomSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.edit), - onClick = { - onDismiss() - editSourceResult.launch { putExtra("sourceUrl", item.origin) } - } - ) - RoundDropdownMenuItem( - text = "禁用", - onClick = { - viewModel.disableSource(item) - onDismiss() - } - ) - RoundDropdownMenuItem( - text = stringResource(R.string.delete), - color = LegadoTheme.colorScheme.error, - onClick = { - viewModel.del(item) - if (oldBook.bookUrl == item.bookUrl) { - viewModel.autoChangeSource(oldBook.type) { book, toc, source -> - onReplace( - source, - book, - toc, - ChangeSourceConfig.getMigrationOptions() - ) - } - } - onDismiss() - } - ) - } - ) - } - } - } - Spacer(modifier = Modifier.height(16.dp)) - } - - val performAction: (SearchBook, Boolean) -> Unit = { searchBook, replace -> - loadingAction = true - val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook() - viewModel.getToc( - book, - onSuccess = { toc, source -> - loadingAction = false - if (replace) { - onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions()) - onDismissRequest() - } else { - onAddAsNew(book, toc) - context.toastOnUi(bookAddedToShelfText) - } - actionBook = null - }, - onError = { - loadingAction = false - context.toastOnUi(if (replace) "换源失败" else "添加书籍失败") - } - ) - } - - AppAlertDialog( - data = mismatchBook, - onDismissRequest = { mismatchBook = null }, - title = stringResource(R.string.book_type_different), - text = stringResource(R.string.soure_change_source), - confirmText = stringResource(android.R.string.ok), - onConfirm = { searchBook -> - actionBook = searchBook - mismatchBook = null - }, - dismissText = stringResource(android.R.string.cancel), - onDismiss = { mismatchBook = null } - ) - AppAlertDialog( - data = actionBook, - onDismissRequest = { actionBook = null }, - title = stringResource(R.string.change_source_option_title), - dismissText = stringResource(R.string.add_as_new_book), - onDismiss = { actionBook?.let { performAction(it, false) } }, - confirmText = stringResource(R.string.replace_current_book), - onConfirm = { performAction(it, true) } - ) - AppAlertDialog( - show = loadingAction, - onDismissRequest = {}, - content = { - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - AppCircularProgressIndicator() - } - } - ) - ChangeSourceMigrationOptionsSheet( - show = showMigrationOptions, - title = "换源选项", - onDismissRequest = { showMigrationOptions = false }, - onConfirm = { options -> - ChangeSourceConfig.setMigrationOptions(options) - showMigrationOptions = false - } - ) - - ScopeSelectSheet( - show = showFilterSheet, - onDismissRequest = { showFilterSheet = false }, - isAll = scopeState.isAll, - onSelectAll = { viewModel.selectAllScope() }, - groups = groups, - selectedGroups = scopeState.displayNames, - onToggleGroup = { viewModel.toggleScopeGroup(it) }, - sources = enabledSources, - selectedSources = scopeState.sourceUrls, - onToggleSource = { viewModel.toggleScopeSource(it) }, - isSourceScope = scopeState.isSource, - onConfirm = { - viewModel.startSearch() - showFilterSheet = false - } - ) -} 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 11d163855..d6623af0b 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 @@ -260,6 +260,7 @@ sealed interface ReadBookIntent { data object ToggleTranslation : ReadBookIntent // Change source + data class ChangeSourceBook(val book: Book) : ReadBookIntent data class ChangeSource(val book: Book, val toc: List) : ReadBookIntent data class AddSourceAsNewBook(val book: Book, val toc: List) : ReadBookIntent diff --git a/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt b/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt index eac6bf344..5dd5a004f 100644 --- a/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt +++ b/app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle import io.legado.app.R -import io.legado.app.ui.book.info.ChangeSourceSheet import io.legado.app.ui.widget.components.log.AppLogSheet import io.legado.app.ui.book.read.sheet.BgTextConfigSheet import io.legado.app.ui.book.read.sheet.ChangeChapterSourceSheet @@ -36,6 +35,7 @@ import io.legado.app.ui.book.read.sheet.TitleBarIconSheet import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.changeSource.ChangeSourceSheet import io.legado.app.utils.toastOnUi import kotlinx.coroutines.flow.collectLatest @@ -397,11 +397,17 @@ fun ReadBookScreen( ChangeSourceSheet( show = true, oldBook = book, + fromReadBookActivity = true, + allowAddAsNew = false, + dismissOnReplaceStart = true, onDismissRequest = { onIntent(ReadBookIntent.DismissSheet) }, onReplace = { _, newBook, toc, _ -> onIntent(ReadBookIntent.DismissSheet) onIntent(ReadBookIntent.ChangeSource(newBook, toc)) }, + onReplaceBook = { newBook -> + onIntent(ReadBookIntent.ChangeSourceBook(newBook)) + }, onAddAsNew = { newBook, toc -> onIntent(ReadBookIntent.DismissSheet) onIntent(ReadBookIntent.AddSourceAsNewBook(newBook, toc)) 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 7e0be067a..e8698959e 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 @@ -259,6 +259,7 @@ class ReadBookViewModel( is ReadBookIntent.RefreshContentAfter -> refreshContentAfter() is ReadBookIntent.ChangeReplaceRule -> changeReplaceRule(intent.enabled) is ReadBookIntent.ToggleTranslation -> toggleTranslation() + is ReadBookIntent.ChangeSourceBook -> changeTo(intent.book) is ReadBookIntent.ChangeSource -> changeTo(intent.book, intent.toc) is ReadBookIntent.AddSourceAsNewBook -> addToBookshelf(intent.book, intent.toc) is ReadBookIntent.OpenChapterResult -> openChapter(intent.index, intent.chapterPos) @@ -1810,14 +1811,7 @@ class ReadBookViewModel( changeSourceCoroutine?.cancel() changeSourceCoroutine = execute { ReadBook.upMsg(context.getString(R.string.loading)) - ReadBook.book?.migrateTo(book, toc) - book.removeType(BookType.updateError) - ReadBook.book?.delete() - appDb.bookDao.insert(book) - appDb.bookChapterDao.insert(*toc.toTypedArray()) - ReadBook.resetData(book) - ReadBook.upMsg(null) - ReadBook.loadContent(resetPageOffset = true) + applyChangeSource(book, toc) }.onError { AppLog.put("换源失败\n$it", it, true) ReadBook.upMsg(null) @@ -1826,6 +1820,39 @@ class ReadBookViewModel( } } + fun changeTo(book: Book) { + changeSourceCoroutine?.cancel() + changeSourceCoroutine = execute { + ReadBook.upMsg(context.getString(R.string.loading)) + val source = appDb.bookSourceDao.getBookSource(book.origin) + ?: throw NoStackTraceException("书源不存在") + if (book.tocUrl.isEmpty()) { + WebBook.getBookInfoAwait(source, book) + } + val toc = WebBook.getChapterListAwait(source, book).getOrThrow() + applyChangeSource(book, toc) + }.onError { + AppLog.put("换源失败\n$it", it, true) + ReadBook.upMsg(null) + }.onFinally { + postEvent(EventBus.SOURCE_CHANGED, book.bookUrl) + } + } + + private suspend fun applyChangeSource(book: Book, toc: List) { + if (toc.isEmpty()) { + throw NoStackTraceException("换源目录为空") + } + ReadBook.book?.migrateTo(book, toc) + book.removeType(BookType.updateError) + ReadBook.book?.delete() + appDb.bookDao.insert(book) + appDb.bookChapterDao.insert(*toc.toTypedArray()) + ReadBook.resetData(book) + ReadBook.upMsg(null) + ReadBook.loadContent(resetPageOffset = true) + } + private fun autoChangeSource(name: String, author: String) { if (!AppConfig.autoChangeSource) return execute { diff --git a/app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt b/app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt new file mode 100644 index 000000000..284cec8a6 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt @@ -0,0 +1,446 @@ +package io.legado.app.ui.widget.components.changeSource + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.PauseCircleOutline +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import io.legado.app.R +import io.legado.app.data.entities.Book +import io.legado.app.data.entities.BookChapter +import io.legado.app.data.entities.BookSource +import io.legado.app.data.entities.SearchBook +import io.legado.app.domain.usecase.ChangeSourceMigrationOptions +import io.legado.app.ui.book.changesource.ChangeBookSourceComposeViewModel +import io.legado.app.ui.book.changesource.ChangeSourceConfig +import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet +import io.legado.app.ui.book.search.ScopeSelectSheet +import io.legado.app.ui.book.source.edit.BookSourceEditActivity +import io.legado.app.ui.book.source.manage.BookSourceActivity +import io.legado.app.ui.theme.LegadoTheme +import io.legado.app.ui.widget.components.AppTextField +import io.legado.app.ui.widget.components.EmptyMessage +import io.legado.app.ui.widget.components.alert.AppAlertDialog +import io.legado.app.ui.widget.components.button.series.MediumPlainButton +import io.legado.app.ui.widget.components.card.SelectionItemCard +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 +import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator +import io.legado.app.ui.widget.components.progressIndicator.AppLinearProgressIndicator +import io.legado.app.ui.widget.components.text.AppText +import io.legado.app.utils.StartActivityContract +import io.legado.app.utils.startActivity +import io.legado.app.utils.toastOnUi +import org.koin.androidx.compose.koinViewModel + +@Composable +fun ChangeSourceSheet( + show: Boolean, + oldBook: Book, + fromReadBookActivity: Boolean = false, + allowAddAsNew: Boolean = true, + dismissOnReplaceStart: Boolean = false, + onDismissRequest: () -> Unit, + onReplace: (BookSource, Book, List, ChangeSourceMigrationOptions) -> Unit, + onReplaceBook: ((Book) -> Unit)? = null, + onAddAsNew: (Book, List) -> Unit, + viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"), +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList()) + val isSearching by viewModel.isSearching.collectAsStateWithLifecycle() + val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle() + val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList()) + val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList()) + val scopeState by viewModel.scopeUiState.collectAsStateWithLifecycle() + var searchQuery by rememberSaveable { mutableStateOf("") } + val checkAuthor = viewModel.checkAuthor + val loadInfo = viewModel.loadInfo + val loadToc = viewModel.loadToc + val loadWordCount = viewModel.loadWordCount + var actionBook by remember { mutableStateOf(null) } + var mismatchBook by remember { mutableStateOf(null) } + var showMigrationOptions by remember { mutableStateOf(false) } + var loadingAction by remember { mutableStateOf(false) } + var showOptionsMenu by rememberSaveable { mutableStateOf(false) } + var showFilterSheet by rememberSaveable { mutableStateOf(false) } + val bookAddedToShelfText = stringResource(R.string.book_added_to_shelf) + + val editSourceResult = rememberLauncherForActivityResult(StartActivityContract(BookSourceEditActivity::class.java)) { + val origin = it.data?.getStringExtra("origin") ?: return@rememberLauncherForActivityResult + viewModel.startSearch(origin) + } + + LaunchedEffect(oldBook.bookUrl, fromReadBookActivity) { + viewModel.initData(oldBook.name, oldBook.author, oldBook, fromReadBookActivity) + } + + DisposableEffect(lifecycleOwner, viewModel) { + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> viewModel.resume() + Lifecycle.Event.ON_PAUSE -> viewModel.pause() + else -> Unit + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + } + } + + DisposableEffect(oldBook.bookUrl) { + onDispose { + viewModel.stopSearch() + } + } + + val performAction = fun(searchBook: SearchBook, replace: Boolean) { + val book = viewModel.getBookFromMap(searchBook.primaryStr()) ?: searchBook.toBook() + if (replace && dismissOnReplaceStart && onReplaceBook != null) { + onDismissRequest() + onReplaceBook(book) + actionBook = null + return + } + val dismissBeforeLoading = replace && dismissOnReplaceStart + if (dismissBeforeLoading) { + onDismissRequest() + } else { + loadingAction = true + } + viewModel.getToc( + book, + onSuccess = { toc, source -> + loadingAction = false + if (replace) { + onReplace(source, book, toc, ChangeSourceConfig.getMigrationOptions()) + if (!dismissBeforeLoading) { + onDismissRequest() + } + } else { + onAddAsNew(book, toc) + context.toastOnUi(bookAddedToShelfText) + } + actionBook = null + }, + onError = { + loadingAction = false + context.toastOnUi(if (replace) "换源失败" else "添加书籍失败") + } + ) + } + + AppModalBottomSheet( + show = show, + onDismissRequest = onDismissRequest, + title = stringResource(R.string.book_source), + startAction = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Box { + MediumPlainButton( + onClick = { showOptionsMenu = true }, + icon = Icons.Default.MoreVert + ) + RoundDropdownMenu( + expanded = showOptionsMenu, + onDismissRequest = { showOptionsMenu = false } + ) { dismiss -> + RoundDropdownMenuItem( + text = "校验作者", + isSelected = checkAuthor, + onClick = { + viewModel.onCheckAuthorChange(!checkAuthor) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载详情", + isSelected = loadInfo, + onClick = { + viewModel.onLoadInfoChange(!loadInfo) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "加载目录", + isSelected = loadToc, + onClick = { + viewModel.onLoadTocChange(!loadToc) + dismiss() + } + ) + RoundDropdownMenuItem( + text = "显示更多信息", + isSelected = loadWordCount, + onClick = { + viewModel.onLoadWordCountChange(!loadWordCount) + dismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.book_source_manage), + onClick = { + context.startActivity() + dismiss() + } + ) + } + } + MediumPlainButton( + onClick = { showMigrationOptions = true }, + icon = Icons.Outlined.Settings + ) + } + }, + endAction = { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + MediumPlainButton( + onClick = { viewModel.startOrStopSearch() }, + icon = if (isSearching) Icons.Default.PauseCircleOutline else Icons.Default.Refresh, + ) + MediumPlainButton( + onClick = { showFilterSheet = true }, + icon = Icons.Default.FilterList + ) + } + } + ) { + AppTextField( + value = searchQuery, + backgroundColor = LegadoTheme.colorScheme.surface, + onValueChange = { + searchQuery = it + viewModel.screen(it) + }, + label = stringResource(R.string.screen), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(12.dp)) + if (isSearching) { + AppLinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Spacer(modifier = Modifier.height(8.dp)) + AppText( + text = "${progress.first} / ${viewModel.totalSourceCount} · ${items.size}", + style = LegadoTheme.typography.bodySmall + ) + Spacer(modifier = Modifier.height(12.dp)) + } + + if (items.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 40.dp), + contentAlignment = Alignment.Center + ) { + EmptyMessage( + message = stringResource(R.string.search_empty) + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(items, key = { it.bookUrl + it.origin }) { item -> + val bookScore by remember(item.origin, item.name, item.author) { + viewModel.bookScoreFlow(item) + }.collectAsStateWithLifecycle() + SelectionItemCard( + title = item.originName, + containerColor = LegadoTheme.colorScheme.onSheetContent, + selectedContainerColor = LegadoTheme.colorScheme.primaryContainer.copy(alpha = 0.32f), + leadingContent = { + MediumPlainButton( + onClick = { + viewModel.onBookScoreClick(item) + }, + icon = Icons.Default.PushPin, + tint = if (bookScore > 0) LegadoTheme.colorScheme.primary else LegadoTheme.colorScheme.outline, + contentDescription = null + ) + }, + supportingContent = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + AppText( + text = item.author, + style = LegadoTheme.typography.labelLargeEmphasized + ) + AppText( + text = item.getDisplayLastChapterTitle(), + style = LegadoTheme.typography.labelMediumEmphasized + ) + item.chapterWordCountText?.takeIf { loadWordCount }?.let { + AppText( + text = it, + style = LegadoTheme.typography.labelSmallEmphasized, + color = LegadoTheme.colorScheme.primary + ) + } + } + }, + isSelected = item.bookUrl == oldBook.bookUrl, + onToggleSelection = { + if (item.bookUrl != oldBook.bookUrl) { + if (!item.sameBookTypeLocal(oldBook.type)) { + mismatchBook = item + } else if (allowAddAsNew) { + actionBook = item + } else { + performAction(item, true) + } + } + }, + dropdownContent = { onDismiss: () -> Unit -> + RoundDropdownMenuItem( + text = stringResource(R.string.to_top), + onClick = { + viewModel.topSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = "置底", + onClick = { + viewModel.bottomSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.edit), + onClick = { + onDismiss() + editSourceResult.launch { putExtra("sourceUrl", item.origin) } + } + ) + RoundDropdownMenuItem( + text = "禁用", + onClick = { + viewModel.disableSource(item) + onDismiss() + } + ) + RoundDropdownMenuItem( + text = stringResource(R.string.delete), + color = LegadoTheme.colorScheme.error, + onClick = { + viewModel.del(item) + if (oldBook.bookUrl == item.bookUrl) { + viewModel.autoChangeSource(oldBook.type) { book, toc, source -> + onReplace( + source, + book, + toc, + ChangeSourceConfig.getMigrationOptions() + ) + } + } + onDismiss() + } + ) + } + ) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } + + AppAlertDialog( + data = mismatchBook, + onDismissRequest = { mismatchBook = null }, + title = stringResource(R.string.book_type_different), + text = stringResource(R.string.soure_change_source), + confirmText = stringResource(android.R.string.ok), + onConfirm = { searchBook -> + mismatchBook = null + if (allowAddAsNew) { + actionBook = searchBook + } else { + performAction(searchBook, true) + } + }, + dismissText = stringResource(android.R.string.cancel), + onDismiss = { mismatchBook = null } + ) + if (allowAddAsNew) { + AppAlertDialog( + data = actionBook, + onDismissRequest = { actionBook = null }, + title = stringResource(R.string.change_source_option_title), + dismissText = stringResource(R.string.add_as_new_book), + onDismiss = { actionBook?.let { performAction(it, false) } }, + confirmText = stringResource(R.string.replace_current_book), + onConfirm = { performAction(it, true) } + ) + } + AppAlertDialog( + show = loadingAction, + onDismissRequest = {}, + content = { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + AppCircularProgressIndicator() + } + } + ) + ChangeSourceMigrationOptionsSheet( + show = showMigrationOptions, + title = "换源选项", + onDismissRequest = { showMigrationOptions = false }, + onConfirm = { options -> + ChangeSourceConfig.setMigrationOptions(options) + showMigrationOptions = false + } + ) + + ScopeSelectSheet( + show = showFilterSheet, + onDismissRequest = { showFilterSheet = false }, + isAll = scopeState.isAll, + onSelectAll = { viewModel.selectAllScope() }, + groups = groups, + selectedGroups = scopeState.displayNames, + onToggleGroup = { viewModel.toggleScopeGroup(it) }, + sources = enabledSources, + selectedSources = scopeState.sourceUrls, + onToggleSource = { viewModel.toggleScopeSource(it) }, + isSourceScope = scopeState.isSource, + onConfirm = { + viewModel.startSearch() + showFilterSheet = false + } + ) +}