fix: 阅读时换源与并发数量

This commit is contained in:
HapeLee
2026-06-10 00:42:01 +08:00
parent 7b9a5dca16
commit 38bbe6f3ff
8 changed files with 558 additions and 393 deletions
@@ -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<String, List<BookChapter>>()
private val bookMap = ConcurrentHashMap<String, Book>()
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<SearchBook>,
)
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
@@ -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<List<SearchBook>>(emptyList())
val searchDataFlow: StateFlow<List<SearchBook>> = _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<SearchBook>()
private val bookMap = mutableMapOf<String, SearchBook>()
@@ -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)
@@ -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
@@ -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<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
onAddAsNew: (Book, List<BookChapter>) -> Unit,
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList<String>())
val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList<io.legado.app.data.entities.BookSourcePart>())
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<SearchBook?>(null) }
var mismatchBook by remember { mutableStateOf<SearchBook?>(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<BookSourceActivity>()
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
}
)
}
@@ -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<BookChapter>) : ReadBookIntent
data class AddSourceAsNewBook(val book: Book, val toc: List<BookChapter>) : ReadBookIntent
@@ -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))
@@ -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<BookChapter>) {
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 {
@@ -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<BookChapter>, ChangeSourceMigrationOptions) -> Unit,
onReplaceBook: ((Book) -> Unit)? = null,
onAddAsNew: (Book, List<BookChapter>) -> Unit,
viewModel: ChangeBookSourceComposeViewModel = koinViewModel(key = "source-${oldBook.bookUrl}"),
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val items by viewModel.searchDataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
val progress by viewModel.changeSourceProgress.collectAsStateWithLifecycle()
val groups by viewModel.enabledGroups.collectAsStateWithLifecycle(initialValue = emptyList<String>())
val enabledSources by viewModel.enabledSources.collectAsStateWithLifecycle(initialValue = emptyList<io.legado.app.data.entities.BookSourcePart>())
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<SearchBook?>(null) }
var mismatchBook by remember { mutableStateOf<SearchBook?>(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<BookSourceActivity>()
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
}
)
}