From d00efa4bfccb4c901dfd472f8d8e67e7018caf14 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 15 Aug 2026 01:39:22 +0600 Subject: [PATCH] Make LibraryViewModel collect library only while subscribed (#3762) Assisted-by: Claude:claude-opus-5 --- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 4 +- .../tachiyomi/ui/library/LibraryViewModel.kt | 277 ++++++++++-------- 2 files changed, 149 insertions(+), 132 deletions(-) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 50f5ee9b8..f01127f8d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -11,7 +11,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -21,6 +20,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.util.fastAll +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator @@ -88,7 +88,7 @@ data object LibraryTab : Tab { val viewModel = viewModel() val settingsViewModel = viewModel() - val state by viewModel.state.collectAsState() + val state by viewModel.state.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt index a52f2fb18..4a2957d11 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastMap +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.core.preference.asState @@ -21,21 +22,22 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update -import kotlinx.coroutines.flow.updateAndGet import mihon.core.common.utils.mutate -import mihon.core.viewmodel.StateViewModel import mihon.domain.library.model.search.QueryNode import mihon.feature.library.matches import tachiyomi.core.common.preference.CheckboxState @@ -85,108 +87,113 @@ class LibraryViewModel( private val downloadManager: DownloadManager = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(), private val trackerManager: TrackerManager = Injekt.get(), -) : StateViewModel(State()) { +) : ViewModel() { - init { - mutableState.update { state -> - state.copy(activeCategoryIndex = libraryPreferences.lastUsedCategory.get()) - } - viewModelScope.launchIO { - combine( - state.map { it.searchQuery }.distinctUntilChanged().debounce(0.25.seconds), - getCategories.subscribe(), - getFavoritesFlow(), - combine(getTracksPerManga.subscribe(), getTrackingFiltersFlow(), ::Pair), - getLibraryItemPreferencesFlow(), - ) { searchQuery, categories, favorites, (tracksMap, trackingFilters), itemPreferences -> - val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) } - val filteredFavorites = favorites - .applyFilters(tracksMap, trackingFilters, itemPreferences) - .let { libraryItems -> - if (searchQuery.isNullOrEmpty()) { - libraryItems - } else { - val queryNode = QueryNode.from(searchQuery) - libraryItems.filter { queryNode.matches(it) } - } - } + private val searchQuery = MutableStateFlow(null) - LibraryData( - isInitialized = true, - showSystemCategory = showSystemCategory, - categories = categories, - favorites = filteredFavorites, - tracksMap = tracksMap, - loggedInTrackerIds = trackingFilters.keys, - ) - } - .distinctUntilChanged() - .collectLatest { libraryData -> - mutableState.update { state -> - state.copy(libraryData = libraryData) - } - } - } + private val selection = MutableStateFlow(emptySet()) - viewModelScope.launchIO { - state - .dropWhile { !it.libraryData.isInitialized } - .map { it.libraryData } - .distinctUntilChanged() - .map { data -> - data.favorites - .applyGrouping(data.categories, data.showSystemCategory) - .applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds) - } - .collectLatest { - mutableState.update { state -> - state.copy( - isLoading = false, - groupedFavorites = it, - ) - } - } - } + private val dialog = MutableStateFlow(null) - combine( - libraryPreferences.categoryTabs.changes(), - libraryPreferences.categoryNumberOfItems.changes(), - libraryPreferences.showContinueReadingButton.changes(), - ) { a, b, c -> arrayOf(a, b, c) } - .onEach { (showCategoryTabs, showMangaCount, showMangaContinueButton) -> - mutableState.update { state -> - state.copy( - showCategoryTabs = showCategoryTabs, - showMangaCount = showMangaCount, - showMangaContinueButton = showMangaContinueButton, - ) - } - } - .launchIn(viewModelScope) + private val activeCategoryIndex = MutableStateFlow(libraryPreferences.lastUsedCategory.get()) - combine( - getLibraryItemPreferencesFlow(), - getTrackingFiltersFlow(), - ) { prefs, trackFilters -> - listOf( - prefs.filterDownloaded, - prefs.filterUnread, - prefs.filterStarted, - prefs.filterBookmarked, - prefs.filterCompleted, - prefs.filterIntervalCustom, - *trackFilters.values.toTypedArray(), - ) - .any { it != TriState.DISABLED } - } - .distinctUntilChanged() - .onEach { - mutableState.update { state -> - state.copy(hasActiveFilters = it) - } - } - .launchIn(viewModelScope) + private val displayPreferences = combine( + libraryPreferences.categoryTabs.changes(), + libraryPreferences.categoryNumberOfItems.changes(), + libraryPreferences.showContinueReadingButton.changes(), + ::DisplayPreferences, + ) + + private val hasActiveFilters = combine( + getLibraryItemPreferencesFlow(), + getTrackingFiltersFlow(), + ) { prefs, trackFilters -> + listOf( + prefs.filterDownloaded, + prefs.filterUnread, + prefs.filterStarted, + prefs.filterBookmarked, + prefs.filterCompleted, + prefs.filterIntervalCustom, + *trackFilters.values.toTypedArray(), + ) + .any { it != TriState.DISABLED } } + .distinctUntilChanged() + + // Shared separately so search, selection and dialog changes still reach [state] before the + // first query result, and so returning to the tab doesn't flash empty while it restarts. + private val library = combine( + searchQuery.debounce(0.25.seconds), + getCategories.subscribe(), + getFavoritesFlow(), + combine(getTracksPerManga.subscribe(), getTrackingFiltersFlow(), ::Pair), + getLibraryItemPreferencesFlow(), + ) { searchQuery, categories, favorites, (tracksMap, trackingFilters), itemPreferences -> + val showSystemCategory = favorites.any { it.libraryManga.categories.contains(0) } + val filteredFavorites = favorites + .applyFilters(tracksMap, trackingFilters, itemPreferences) + .let { libraryItems -> + if (searchQuery.isNullOrEmpty()) { + libraryItems + } else { + val queryNode = QueryNode.from(searchQuery) + libraryItems.filter { queryNode.matches(it) } + } + } + + LibraryData( + isInitialized = true, + showSystemCategory = showSystemCategory, + categories = categories, + favorites = filteredFavorites, + tracksMap = tracksMap, + loggedInTrackerIds = trackingFilters.keys, + ) + } + .distinctUntilChanged() + .map { data -> + Library( + data = data, + groupedFavorites = data.favorites + .applyGrouping(data.categories, data.showSystemCategory) + .applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds), + ) + } + .flowOn(Dispatchers.IO) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), null) + + val state: StateFlow = combine( + library, + combine(searchQuery, selection, dialog, ::Triple), + combine(activeCategoryIndex, displayPreferences, hasActiveFilters, ::Triple), + ) { library, (searchQuery, selection, dialog), (activeCategoryIndex, display, hasActiveFilters) -> + State( + isLoading = library == null, + searchQuery = searchQuery, + selection = selection, + hasActiveFilters = hasActiveFilters, + showCategoryTabs = display.showCategoryTabs, + showMangaCount = display.showMangaCount, + showMangaContinueButton = display.showMangaContinueButton, + dialog = dialog, + libraryData = library?.data ?: LibraryData(), + activeCategoryIndex = activeCategoryIndex, + groupedFavorites = library?.groupedFavorites.orEmpty(), + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5.seconds), State()) + + private data class DisplayPreferences( + val showCategoryTabs: Boolean, + val showMangaCount: Boolean, + val showMangaContinueButton: Boolean, + ) + + private data class Library( + val data: LibraryData, + val groupedFavorites: Map>, + ) private fun List.applyFilters( trackMap: Map>, @@ -489,7 +496,7 @@ class LibraryViewModel( } private fun downloadNextChapters(amount: Int?) { - val mangas = state.value.selectedManga + val mangas = selectedManga viewModelScope.launchNonCancellable { mangas.forEach { manga -> val chapters = getNextChapters.await(manga.id) @@ -511,7 +518,7 @@ class LibraryViewModel( } private fun downloadBookmarkedChapters() { - val mangas = state.value.selectedManga + val mangas = selectedManga viewModelScope.launchNonCancellable { mangas.forEach { manga -> val chapters = getBookmarkedChaptersByMangaId.await(manga.id) @@ -534,7 +541,7 @@ class LibraryViewModel( * Marks mangas' chapters read status. */ fun markReadSelection(read: Boolean) { - val selection = state.value.selectedManga + val selection = selectedManga viewModelScope.launchNonCancellable { selection.forEach { manga -> setReadStatus.await( @@ -613,23 +620,33 @@ class LibraryViewModel( } fun showSettingsDialog() { - mutableState.update { it.copy(dialog = Dialog.SettingsSheet) } + dialog.update { Dialog.SettingsSheet } } private var lastSelectionCategory: Long? = null + /** + * Reads from [selection] rather than [state], which is derived asynchronously and can still + * hold the previous selection immediately after a toggle. + */ + private val selectedManga: List + get() { + val favoritesById = state.value.libraryData.favoritesById + return selection.value.mapNotNull { favoritesById[it]?.libraryManga?.manga } + } + fun clearSelection() { lastSelectionCategory = null - mutableState.update { it.copy(selection = setOf()) } + selection.update { setOf() } } fun toggleSelection(category: Category, manga: LibraryManga) { - mutableState.update { state -> - val newSelection = state.selection.mutate { set -> + selection.update { selection -> + val newSelection = selection.mutate { set -> if (!set.remove(manga.id)) set.add(manga.id) } lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() } - state.copy(selection = newSelection) + newSelection } } @@ -638,8 +655,9 @@ class LibraryViewModel( * same category as the given manga */ fun toggleRangeSelection(category: Category, manga: LibraryManga) { - mutableState.update { state -> - val newSelection = state.selection.mutate { list -> + val state = state.value + selection.update { selection -> + val newSelection = selection.mutate { list -> val lastSelected = list.lastOrNull() if (lastSelectionCategory != category.id) { list.add(manga.id) @@ -659,50 +677,49 @@ class LibraryViewModel( selectionRange.mapNotNull { items[it] }.let(list::addAll) } lastSelectionCategory = category.id - state.copy(selection = newSelection) + newSelection } } fun selectAll() { lastSelectionCategory = null - mutableState.update { state -> - val newSelection = state.selection.mutate { list -> + val state = state.value + selection.update { selection -> + selection.mutate { list -> state.getItemsForCategoryId(state.activeCategory?.id).map { it.id }.let(list::addAll) } - state.copy(selection = newSelection) } } fun invertSelection() { lastSelectionCategory = null - mutableState.update { state -> - val newSelection = state.selection.mutate { list -> + val state = state.value + selection.update { selection -> + selection.mutate { list -> val itemIds = state.getItemsForCategoryId(state.activeCategory?.id).fastMap { it.id } val (toRemove, toAdd) = itemIds.partition { it in list } list.removeAll(toRemove) list.addAll(toAdd) } - state.copy(selection = newSelection) } } fun search(query: String?) { - mutableState.update { it.copy(searchQuery = query) } + searchQuery.update { query } } fun updateActiveCategoryIndex(index: Int) { - val newIndex = mutableState.updateAndGet { state -> - state.copy(activeCategoryIndex = index) - } - .coercedActiveCategoryIndex - - libraryPreferences.lastUsedCategory.set(newIndex) + activeCategoryIndex.update { index } + // Coerce here rather than reading it back off [state], which is derived asynchronously + // and would still hold the previous index at this point. + val lastIndex = state.value.displayedCategories.lastIndex.coerceAtLeast(0) + libraryPreferences.lastUsedCategory.set(index.coerceIn(0, lastIndex)) } fun openChangeCategoryDialog() { viewModelScope.launchIO { // Create a copy of selected manga - val mangaList = state.value.selectedManga + val mangaList = selectedManga // Hide the default category because it has a different behavior than the ones from db. val categories = state.value.displayedCategories.filter { it.id != 0L } @@ -720,16 +737,16 @@ class LibraryViewModel( } } - mutableState.update { it.copy(dialog = Dialog.ChangeCategory(mangaList, preselected)) } + dialog.update { Dialog.ChangeCategory(mangaList, preselected) } } } fun openDeleteMangaDialog() { - mutableState.update { it.copy(dialog = Dialog.DeleteManga(state.value.selectedManga)) } + dialog.update { Dialog.DeleteManga(selectedManga) } } fun closeDialog() { - mutableState.update { it.copy(dialog = null) } + dialog.update { null } } sealed interface Dialog {