Make LibraryViewModel collect library only while subscribed (#3762)

Assisted-by: Claude:claude-opus-5
This commit is contained in:
AntsyLich
2026-08-15 01:39:22 +06:00
committed by GitHub
parent b1a9a05926
commit d00efa4bfc
2 changed files with 149 additions and 132 deletions
@@ -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<LibraryViewModel>()
val settingsViewModel = viewModel<LibrarySettingsViewModel>()
val state by viewModel.state.collectAsState()
val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
@@ -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<LibraryViewModel.State>(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<String?>(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</* Manga */ Long>())
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<Dialog?>(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<State> = 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<Category, List</* LibraryItem */ Long>>,
)
private fun List<LibraryItem>.applyFilters(
trackMap: Map<Long, List<Track>>,
@@ -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<Manga>
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 {