Prechádzať zdrojové kódy

fix: 对齐一下原版换源的颗粒度

HapeLee 4 mesiacov pred
rodič
commit
034661f2cc

+ 71 - 1
app/src/main/java/io/legado/app/domain/usecase/ChangeSourceSearchUseCase.kt

@@ -1,5 +1,6 @@
 package io.legado.app.domain.usecase
 
+import io.legado.app.constant.AppConst
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
@@ -69,7 +70,7 @@ class ChangeSourceSearchUseCase(
 
         var processedSources = 0
         var resultCount = 0
-        val concurrency = threadCount.coerceAtLeast(1)
+        val concurrency = threadCount.coerceIn(1, AppConst.MAX_THREAD)
 
         bookSourceParts.asFlow()
             .mapNotNull { it.getBookSource() }
@@ -115,11 +116,80 @@ class ChangeSourceSearchUseCase(
         emit(ChangeSourceSearchEvent.Finished(isEmpty = resultCount == 0))
     }.flowOn(Dispatchers.IO)
 
+    @OptIn(ExperimentalCoroutinesApi::class)
+    fun refresh(
+        books: List<SearchBook>,
+        oldBook: Book,
+        fromReadBookActivity: Boolean,
+    ): Flow<ChangeSourceSearchEvent> = flow {
+        val contentProcessor = ContentProcessor.get(oldBook)
+        val totalBooks = books.size
+        val concurrency = threadCount.coerceIn(1, AppConst.MAX_THREAD)
+        var processedBooks = 0
+        var resultCount = 0
+
+        emit(ChangeSourceSearchEvent.Started(totalBooks))
+
+        books.asFlow()
+            .flatMapMerge(concurrency) { searchBook ->
+                flow {
+                    val loadedBook = try {
+                        gateway.getBookSource(searchBook.origin)?.let { source ->
+                            withTimeout(60000L) {
+                                loadBookInfo(
+                                    source = source,
+                                    book = searchBook.toBook(),
+                                    loadToc = ChangeSourceConfig.loadToc,
+                                    loadWordCount = ChangeSourceConfig.loadWordCount,
+                                    oldBook = oldBook,
+                                    fromReadBookActivity = fromReadBookActivity,
+                                    contentProcessor = contentProcessor,
+                                )
+                            }
+                        }
+                    } catch (_: Throwable) {
+                        currentCoroutineContext().ensureActive()
+                        null
+                    }
+                    emit(ChangeSourceRefreshResult(searchBook.originName, loadedBook))
+                }.flowOn(Dispatchers.IO)
+            }
+            .collect { result ->
+                currentCoroutineContext().ensureActive()
+                result.loadedBook?.let { loadedBook ->
+                    resultCount++
+                    emit(
+                        ChangeSourceSearchEvent.Result(
+                            searchBook = loadedBook.searchBook,
+                            book = loadedBook.book,
+                            toc = loadedBook.toc,
+                        )
+                    )
+                }
+                processedBooks++
+                emit(
+                    ChangeSourceSearchEvent.Progress(
+                        processedSources = processedBooks,
+                        totalSources = totalBooks,
+                        resultCount = resultCount,
+                        sourceName = result.sourceName,
+                    )
+                )
+            }
+
+        emit(ChangeSourceSearchEvent.Finished(isEmpty = resultCount == 0))
+    }.flowOn(Dispatchers.IO)
+
     private data class ChangeSourceResult(
         val source: BookSource,
         val books: List<LoadedSearchBook>,
     )
 
+    private data class ChangeSourceRefreshResult(
+        val sourceName: String,
+        val loadedBook: LoadedSearchBook?,
+    )
+
     private data class LoadedSearchBook(
         val searchBook: SearchBook,
         val book: Book,

+ 225 - 140
app/src/main/java/io/legado/app/ui/book/changesource/ChangeBookSourceComposeViewModel.kt

@@ -1,7 +1,11 @@
 package io.legado.app.ui.book.changesource
 
+import androidx.annotation.StringRes
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.constant.AppLog
+import io.legado.app.constant.AppPattern
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
@@ -15,13 +19,25 @@ 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.CancellationException
 import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Dispatchers.Main
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import java.util.Collections
+import java.util.concurrent.ConcurrentHashMap
+
+sealed interface ChangeBookSourceEffect {
+    data class ShowMessage(val message: String) : ChangeBookSourceEffect
+    data class ShowMessageResource(@StringRes val messageRes: Int) : ChangeBookSourceEffect
+}
 
 class ChangeBookSourceComposeViewModel(
     private val changeSourceSearchUseCase: ChangeSourceSearchUseCase,
@@ -62,17 +78,23 @@ class ChangeBookSourceComposeViewModel(
     private val _searchDataFlow = MutableStateFlow<List<SearchBook>>(emptyList())
     val searchDataFlow: StateFlow<List<SearchBook>> = _searchDataFlow.asStateFlow()
 
+    private val _emptyScopeName = MutableStateFlow<String?>(null)
+    val emptyScopeName = _emptyScopeName.asStateFlow()
+
+    private val _effects =
+        MutableSharedFlow<ChangeBookSourceEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    @Volatile
     var totalSourceCount: Int = 0
         private set
 
     fun getBookFromMap(key: String): Book? = bookMap[key]
 
     fun findShelfConflict(book: Book, onResult: (Book?) -> Unit) {
-        viewModelScope.launch {
-            val conflict = withContext(IO) {
-                bookDao.getShelfBookConflict(book.name, book.author)
-            }
-            onResult(conflict)
+        viewModelScope.launch(IO) {
+            val conflict = bookDao.getShelfBookConflict(book.name, book.author)
+            onMain { onResult(conflict) }
         }
     }
 
@@ -89,53 +111,28 @@ class ChangeBookSourceComposeViewModel(
     private var bookName: String = ""
     private var bookAuthor: String = ""
     private var fromReadBookActivity: Boolean = false
+    @Volatile
     private var screenKey: String = ""
-    private var knownEnabledSourceUrls: Set<String>? = null
     private var cachedTocChapterCount = 0
-    private val searchResults = mutableListOf<SearchBook>()
-    private val bookMap = mutableMapOf<String, Book>()
-    private val tocMap = mutableMapOf<String, List<BookChapter>>()
-
-    init {
-        viewModelScope.launch {
-            searchRepository.enabledSources.collect { sources ->
-                val sourceUrls = sources.mapTo(linkedSetOf()) { it.bookSourceUrl }
-                val previousSourceUrls = knownEnabledSourceUrls
-                knownEnabledSourceUrls = sourceUrls
-                if (oldBook != null && previousSourceUrls != null) {
-                    val addedSources = sources.filter {
-                        it.bookSourceUrl !in previousSourceUrls
-                    }
-                    if (addedSources.isNotEmpty()) {
-                        startSearch(addedSources)
-                    }
-                }
-            }
-        }
-    }
+    private val searchResults = Collections.synchronizedList(mutableListOf<SearchBook>())
+    private val bookMap = ConcurrentHashMap<String, Book>()
+    private val tocMap = ConcurrentHashMap<String, List<BookChapter>>()
 
     fun initData(name: String, author: String, book: Book, fromReadBookActivity: Boolean) {
         this.oldBook = book
         this.bookName = name
-        this.bookAuthor = author
+        this.bookAuthor = author.replace(AppPattern.authorRegex, "")
         this.fromReadBookActivity = fromReadBookActivity
         if (searchJob?.isActive != true) {
-            viewModelScope.launch {
-                val dbBooks = withContext(IO) { getDbSearchBooks() }
+            viewModelScope.launch(IO) {
+                val dbBooks = getDbSearchBooks()
                 if (dbBooks.isNotEmpty()) {
-                    searchResults.clear()
-                    searchResults.addAll(dbBooks)
-                    searchResults.forEach { bookMap[it.primaryStr()] = it.toBook() }
-                    filterResults()
-                    val cachedOrigins = dbBooks.mapTo(hashSetOf()) { it.origin }
-                    val missingSources = withContext(IO) {
-                        SearchScope(ChangeSourceConfig.searchScope)
-                            .getBookSourceParts()
-                            .filter { it.bookSourceUrl !in cachedOrigins }
-                    }
-                    if (missingSources.isNotEmpty()) {
-                        startSearch(missingSources)
+                    synchronized(searchResults) {
+                        searchResults.clear()
+                        searchResults.addAll(dbBooks)
                     }
+                    dbBooks.forEach { bookMap[it.primaryStr()] = it.toBook() }
+                    filterResults()
                 } else {
                     startSearch()
                 }
@@ -144,24 +141,16 @@ class ChangeBookSourceComposeViewModel(
     }
 
     private fun getDbSearchBooks(): List<SearchBook> {
-        val searchScope = SearchScope(ChangeSourceConfig.searchScope)
-        val group = when {
-            searchScope.isAll() || searchScope.isSource() -> ""
-            else -> {
-                val names = searchScope.displayNames
-                if (names.size == 1) names.first() else ""
-            }
-        }
-        val bookAuthor = if (checkAuthor) bookAuthor else ""
-        return if (screenKey.isEmpty()) {
-            io.legado.app.data.appDb.searchBookDao.changeSourceByGroup(
-                bookName, bookAuthor, group
-            )
-        } else {
-            io.legado.app.data.appDb.searchBookDao.changeSourceSearch(
-                bookName, bookAuthor, screenKey, group
-            )
-        }
+        val scope = SearchScope(ChangeSourceConfig.searchScope)
+        val author = if (checkAuthor) bookAuthor else ""
+        val dbBooks = io.legado.app.data.appDb.searchBookDao.changeSourceByGroup(
+            bookName, author, ""
+        )
+        if (scope.isAll()) return dbBooks
+
+        val allowedOrigins = scope.getBookSourceParts()
+            .mapTo(hashSetOf()) { it.bookSourceUrl }
+        return dbBooks.filter { it.origin in allowedOrigins }
     }
 
     fun startSearch() {
@@ -172,10 +161,9 @@ class ChangeBookSourceComposeViewModel(
     }
 
     fun startSearch(origin: String) {
-        viewModelScope.launch {
-            val source = withContext(IO) {
-                io.legado.app.data.appDb.bookSourceDao.getBookSourcePart(origin)
-            } ?: return@launch
+        viewModelScope.launch(IO) {
+            val source = io.legado.app.data.appDb.bookSourceDao
+                .getBookSourcePart(origin) ?: return@launch
             startSearch(listOf(source))
         }
     }
@@ -194,13 +182,16 @@ class ChangeBookSourceComposeViewModel(
     ) {
         val book = oldBook ?: return
         stopSearch()
-        val removedResults = if (replacedOrigins == null) {
-            searchResults.toList()
-        } else {
-            searchResults.filter { it.origin in replacedOrigins }
+        val removedResults = synchronized(searchResults) {
+            val results = if (replacedOrigins == null) {
+                searchResults.toList()
+            } else {
+                searchResults.filter { it.origin in replacedOrigins }
+            }
+            searchResults.removeAll(results.toSet())
+            results
         }
         val removedKeys = removedResults.mapTo(hashSetOf()) { it.primaryStr() }
-        searchResults.removeAll(removedResults.toSet())
         removedKeys.forEach {
             bookMap.remove(it)
             tocMap.remove(it)
@@ -211,65 +202,108 @@ class ChangeBookSourceComposeViewModel(
         _changeSourceProgress.value = 0 to ""
         filterResults()
 
-        searchJob = viewModelScope.launch {
-            if (removedResults.isNotEmpty()) {
-                withContext(IO) {
+        searchJob = viewModelScope.launch(IO) {
+            try {
+                if (removedResults.isNotEmpty()) {
                     io.legado.app.data.appDb.searchBookDao.delete(*removedResults.toTypedArray())
                 }
+                collectSearchEvents(
+                    events = changeSourceSearchUseCase.search(
+                        name = bookName,
+                        author = bookAuthor,
+                        scope = scope,
+                        oldBook = book,
+                        fromReadBookActivity = fromReadBookActivity,
+                    ),
+                    emptyScope = scope,
+                )
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Throwable) {
+                reportSearchError(error)
             }
-            val pendingBooks = arrayListOf<SearchBook>()
-            try {
-                changeSourceSearchUseCase.search(
-                    name = book.name,
-                    author = book.author,
-                    scope = scope,
-                    oldBook = book,
-                    fromReadBookActivity = fromReadBookActivity,
-                ).collect { event ->
-                    when (event) {
-                        is ChangeSourceSearchEvent.Started -> {
-                            _isSearching.value = true
-                            totalSourceCount = event.totalSources
-                        }
+        }
+    }
 
-                        is ChangeSourceSearchEvent.Progress -> {
-                            totalSourceCount = event.totalSources
-                            _changeSourceProgress.value =
-                                event.processedSources to event.sourceName
-                            filterResults()
-                        }
+    private suspend fun collectSearchEvents(
+        events: Flow<ChangeSourceSearchEvent>,
+        emptyScope: SearchScope? = null,
+    ) {
+        try {
+            events.collect { event ->
+                when (event) {
+                    is ChangeSourceSearchEvent.Started -> {
+                        _isSearching.value = true
+                        totalSourceCount = event.totalSources
+                    }
+
+                    is ChangeSourceSearchEvent.Progress -> {
+                        totalSourceCount = event.totalSources
+                        _changeSourceProgress.value =
+                            event.processedSources to event.sourceName
+                        filterResults()
+                    }
 
-                        is ChangeSourceSearchEvent.Result -> {
-                            val key = event.searchBook.primaryStr()
-                            searchResults.add(event.searchBook)
-                            pendingBooks.add(event.searchBook)
-                            bookMap[key] = event.book
-                            event.toc?.let { toc ->
-                                if (cachedTocChapterCount < MAX_CACHED_CHAPTERS) {
-                                    cachedTocChapterCount += toc.size
-                                    tocMap[key] = toc
-                                }
+                    is ChangeSourceSearchEvent.Result -> {
+                        event.searchBook.releaseHtmlData()
+                        searchRepository.saveSearchBook(event.searchBook)
+                        val key = event.searchBook.primaryStr()
+                        searchResults.add(event.searchBook)
+                        bookMap[key] = event.book
+                        event.toc?.let { toc ->
+                            if (cachedTocChapterCount < MAX_CACHED_CHAPTERS) {
+                                cachedTocChapterCount += toc.size
+                                tocMap[key] = toc
                             }
                         }
+                    }
 
-                        is ChangeSourceSearchEvent.Finished -> {
-                            filterResults()
-                            searchRepository.saveSearchBooks(pendingBooks)
+                    is ChangeSourceSearchEvent.Finished -> {
+                        filterResults()
+                        val isResultEmpty = synchronized(searchResults) {
+                            searchResults.isEmpty()
+                        }
+                        if (event.isEmpty && isResultEmpty && emptyScope?.isAll() == false) {
+                            _emptyScopeName.value = emptyScope.display
                         }
                     }
                 }
-            } finally {
-                _isSearching.value = false
             }
+        } catch (error: CancellationException) {
+            throw error
+        } catch (error: Throwable) {
+            reportSearchError(error)
+        } finally {
+            _isSearching.value = false
         }
     }
 
+    private fun reportSearchError(error: Throwable) {
+        AppLog.put("换源搜索出错\n${error.localizedMessage}", error)
+        _effects.tryEmit(
+            ChangeBookSourceEffect.ShowMessage(
+                error.localizedMessage?.takeIf { it.isNotBlank() } ?: error.toString()
+            )
+        )
+    }
+
     fun stopSearch() {
         searchJob?.cancel()
         searchJob = null
         _isSearching.value = false
     }
 
+    fun confirmEmptyScopeSearch() {
+        _emptyScopeName.value = null
+        searchScope.update("")
+        updateScopeState()
+        startSearch()
+    }
+
+    fun dismissEmptyScopeSearch() {
+        _emptyScopeName.value = null
+    }
+
     fun screen(key: String?) {
         screenKey = key?.trim() ?: ""
         filterResults()
@@ -288,11 +322,15 @@ class ChangeBookSourceComposeViewModel(
     fun resume() = Unit
 
     private fun filterResults() {
-        val filtered = if (screenKey.isEmpty()) {
-            searchResults.toList()
-        } else {
-            searchResults.filter {
-                it.name.contains(screenKey) || it.originName.contains(screenKey)
+        val key = screenKey
+        val filtered = synchronized(searchResults) {
+            if (key.isEmpty()) {
+                searchResults.toList()
+            } else {
+                searchResults.filter {
+                    it.originName.contains(key) ||
+                        it.latestChapterTitle?.contains(key) == true
+                }
             }
         }
         val comparator = if (ChangeSourceConfig.loadWordCount) {
@@ -311,8 +349,8 @@ class ChangeBookSourceComposeViewModel(
     }
 
     private fun getChapterNum(text: String?): Int {
-        if (text.isNullOrBlank()) return 0
-        return chapterNumRegex.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: 0
+        if (text.isNullOrBlank()) return -1
+        return chapterNumRegex.find(text)?.groupValues?.get(1)?.toIntOrNull() ?: -1
     }
 
     fun getToc(
@@ -321,31 +359,29 @@ class ChangeBookSourceComposeViewModel(
         onError: (e: Throwable) -> Unit,
     ) {
         stopSearch()
-        viewModelScope.launch {
+        viewModelScope.launch(IO) {
             try {
                 val cachedToc = tocMap[book.primaryStr()]
                 if (cachedToc != null) {
-                    val source = withContext(IO) {
+                    val source =
                         io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
-                    }
                     if (source != null) {
-                        onSuccess(cachedToc, source)
+                        onMain { onSuccess(cachedToc, source) }
                         return@launch
                     }
                 }
                 if (book.isWebFile) {
-                    val source = withContext(IO) {
+                    val source =
                         io.legado.app.data.appDb.bookSourceDao.getBookSource(book.origin)
-                    }
                         ?: throw io.legado.app.exception.NoStackTraceException("书源不存在")
-                    onSuccess(emptyList(), source)
+                    onMain { onSuccess(emptyList(), source) }
                     return@launch
                 }
                 val (toc, source) = getChapterContentUseCase.getToc(book)
                 tocMap[book.primaryStr()] = toc
-                onSuccess(toc, source)
+                onMain { onSuccess(toc, source) }
             } catch (e: Exception) {
-                onError(e)
+                onMain { onError(e) }
             }
         }
     }
@@ -371,24 +407,52 @@ class ChangeBookSourceComposeViewModel(
         if (ChangeSourceConfig.loadWordCount == enabled) return
         ChangeSourceConfig.loadWordCount = enabled
         if (enabled) {
-            startSearch()
+            refreshMissingWordCounts()
         } else {
             refresh()
         }
     }
 
-    fun refresh() {
-        viewModelScope.launch {
-            val dbBooks = withContext(IO) { getDbSearchBooks() }
-            searchResults.clear()
+    private fun refreshMissingWordCounts() {
+        val book = oldBook ?: return
+        stopSearch()
+        val refreshBooks = synchronized(searchResults) {
+            searchResults
+                .filter { it.chapterWordCountText == null }
+                .also { searchResults.removeAll(it.toSet()) }
+        }
+        if (refreshBooks.isEmpty()) {
+            filterResults()
+            return
+        }
+        filterResults()
+        _changeSourceProgress.value = 0 to ""
+        searchJob = viewModelScope.launch(IO) {
+            collectSearchEvents(
+                changeSourceSearchUseCase.refresh(
+                    books = refreshBooks,
+                    oldBook = book,
+                    fromReadBookActivity = fromReadBookActivity,
+                )
+            )
+        }
+    }
+
+    fun refresh(startSearchIfEmpty: Boolean = false) {
+        viewModelScope.launch(IO) {
+            val dbBooks = getDbSearchBooks()
+            synchronized(searchResults) {
+                searchResults.clear()
+                searchResults.addAll(dbBooks)
+            }
             bookMap.clear()
             tocMap.clear()
             cachedTocChapterCount = 0
             if (dbBooks.isNotEmpty()) {
-                searchResults.addAll(dbBooks)
-                searchResults.forEach { bookMap[it.primaryStr()] = it.toBook() }
-                filterResults()
-            } else {
+                dbBooks.forEach { bookMap[it.primaryStr()] = it.toBook() }
+            }
+            filterResults()
+            if (dbBooks.isEmpty() && startSearchIfEmpty) {
                 startSearch()
             }
         }
@@ -406,30 +470,41 @@ class ChangeBookSourceComposeViewModel(
     }
 
     fun disableSource(searchBook: SearchBook) {
-        changeSourceSearchUseCase.disableSource(searchBook)
         searchResults.remove(searchBook)
         filterResults()
+        viewModelScope.launch(IO) {
+            changeSourceSearchUseCase.disableSource(searchBook)
+        }
     }
 
     fun del(searchBook: SearchBook) {
-        changeSourceSearchUseCase.deleteSource(searchBook)
         searchResults.remove(searchBook)
         filterResults()
+        viewModelScope.launch(IO) {
+            changeSourceSearchUseCase.deleteSource(searchBook)
+        }
     }
 
     fun autoChangeSource(
         bookType: Int?,
         onSuccess: (book: Book, toc: List<BookChapter>, source: BookSource) -> Unit,
     ) {
-        viewModelScope.launch {
-            val found = searchResults.firstOrNull { it.type == bookType }
-            if (found != null) {
+        viewModelScope.launch(IO) {
+            val matchingBooks = synchronized(searchResults) {
+                searchResults.filter { it.type == bookType }
+            }
+            for (found in matchingBooks) {
                 try {
-                    val (toc, source) = getChapterContentUseCase.getToc(found.toBook())
-                    onSuccess(found.toBook(), toc, source)
+                    val book = found.toBook()
+                    val (toc, source) = getChapterContentUseCase.getToc(book)
+                    onMain { onSuccess(book, toc, source) }
+                    return@launch
                 } catch (_: Exception) {
                 }
             }
+            _effects.tryEmit(
+                ChangeBookSourceEffect.ShowMessageResource(R.string.auto_change_source_failed)
+            )
         }
     }
 
@@ -502,6 +577,11 @@ class ChangeBookSourceComposeViewModel(
     }
 
     private fun saveScope() {
+        updateScopeState()
+        refresh(startSearchIfEmpty = true)
+    }
+
+    private fun updateScopeState() {
         ChangeSourceConfig.searchScope = searchScope.toString()
         _scopeUiState.value = ScopeUiState(
             isAll = searchScope.isAll(),
@@ -509,7 +589,12 @@ class ChangeBookSourceComposeViewModel(
             displayNames = searchScope.displayNames,
             sourceUrls = searchScope.sourceUrls
         )
-        refresh()
+    }
+
+    private suspend fun onMain(block: () -> Unit) {
+        withContext(Main.immediate) {
+            block()
+        }
     }
 
     private companion object {

+ 28 - 0
app/src/main/java/io/legado/app/ui/widget/components/changeSource/ChangeSourceSheet.kt

@@ -43,6 +43,7 @@ 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.ChangeBookSourceEffect
 import io.legado.app.ui.book.changesource.ChangeSourceConfig
 import io.legado.app.ui.book.changesource.ChangeSourceMigrationOptionsSheet
 import io.legado.app.ui.book.search.ScopeSelectSheet
@@ -63,6 +64,7 @@ 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 kotlinx.coroutines.flow.collectLatest
 import org.koin.androidx.compose.koinViewModel
 
 @Stable
@@ -101,6 +103,7 @@ fun ChangeSourceSheet(
     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()
+    val emptyScopeName by viewModel.emptyScopeName.collectAsStateWithLifecycle()
     var searchQuery by rememberSaveable { mutableStateOf("") }
     val checkAuthor = viewModel.checkAuthor
     val loadInfo = viewModel.loadInfo
@@ -124,6 +127,16 @@ fun ChangeSourceSheet(
         viewModel.initData(oldBook.name, oldBook.author, oldBook, fromReadBookActivity)
     }
 
+    LaunchedEffect(viewModel) {
+        viewModel.effects.collectLatest { effect ->
+            when (effect) {
+                is ChangeBookSourceEffect.ShowMessage -> context.toastOnUi(effect.message)
+                is ChangeBookSourceEffect.ShowMessageResource ->
+                    context.toastOnUi(context.getString(effect.messageRes))
+            }
+        }
+    }
+
     DisposableEffect(lifecycleOwner, viewModel) {
         val observer = LifecycleEventObserver { _, event ->
             when (event) {
@@ -512,6 +525,21 @@ fun ChangeSourceSheet(
             }
         },
     )
+    AppAlertDialog(
+        data = emptyScopeName,
+        onDismissRequest = viewModel::dismissEmptyScopeSearch,
+        title = stringResource(R.string.draw),
+        textProvider = {
+            stringResource(
+                R.string.search_empty_scope_switch_all,
+                emptyScopeName.orEmpty(),
+            )
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { viewModel.confirmEmptyScopeSearch() },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = viewModel::dismissEmptyScopeSearch,
+    )
     AppAlertDialog(
         show = loadingAction,
         onDismissRequest = {},