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.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAll
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.Navigator
@@ -88,7 +88,7 @@ data object LibraryTab : Tab {
val viewModel = viewModel<LibraryViewModel>() val viewModel = viewModel<LibraryViewModel>()
val settingsViewModel = viewModel<LibrarySettingsViewModel>() val settingsViewModel = viewModel<LibrarySettingsViewModel>()
val state by viewModel.state.collectAsState() val state by viewModel.state.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
@@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastFilter
import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.core.preference.PreferenceMutableState
import eu.kanade.core.preference.asState 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.source.online.HttpSource
import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow 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.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.dropWhile
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import mihon.core.common.utils.mutate import mihon.core.common.utils.mutate
import mihon.core.viewmodel.StateViewModel
import mihon.domain.library.model.search.QueryNode import mihon.domain.library.model.search.QueryNode
import mihon.feature.library.matches import mihon.feature.library.matches
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
@@ -85,108 +87,113 @@ class LibraryViewModel(
private val downloadManager: DownloadManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(),
private val downloadCache: DownloadCache = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(),
private val trackerManager: TrackerManager = Injekt.get(), private val trackerManager: TrackerManager = Injekt.get(),
) : StateViewModel<LibraryViewModel.State>(State()) { ) : ViewModel() {
init { private val searchQuery = MutableStateFlow<String?>(null)
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) }
}
}
LibraryData( private val selection = MutableStateFlow(emptySet</* Manga */ Long>())
isInitialized = true,
showSystemCategory = showSystemCategory,
categories = categories,
favorites = filteredFavorites,
tracksMap = tracksMap,
loggedInTrackerIds = trackingFilters.keys,
)
}
.distinctUntilChanged()
.collectLatest { libraryData ->
mutableState.update { state ->
state.copy(libraryData = libraryData)
}
}
}
viewModelScope.launchIO { private val dialog = MutableStateFlow<Dialog?>(null)
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,
)
}
}
}
combine( private val activeCategoryIndex = MutableStateFlow(libraryPreferences.lastUsedCategory.get())
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)
combine( private val displayPreferences = combine(
getLibraryItemPreferencesFlow(), libraryPreferences.categoryTabs.changes(),
getTrackingFiltersFlow(), libraryPreferences.categoryNumberOfItems.changes(),
) { prefs, trackFilters -> libraryPreferences.showContinueReadingButton.changes(),
listOf( ::DisplayPreferences,
prefs.filterDownloaded, )
prefs.filterUnread,
prefs.filterStarted, private val hasActiveFilters = combine(
prefs.filterBookmarked, getLibraryItemPreferencesFlow(),
prefs.filterCompleted, getTrackingFiltersFlow(),
prefs.filterIntervalCustom, ) { prefs, trackFilters ->
*trackFilters.values.toTypedArray(), listOf(
) prefs.filterDownloaded,
.any { it != TriState.DISABLED } prefs.filterUnread,
} prefs.filterStarted,
.distinctUntilChanged() prefs.filterBookmarked,
.onEach { prefs.filterCompleted,
mutableState.update { state -> prefs.filterIntervalCustom,
state.copy(hasActiveFilters = it) *trackFilters.values.toTypedArray(),
} )
} .any { it != TriState.DISABLED }
.launchIn(viewModelScope)
} }
.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( private fun List<LibraryItem>.applyFilters(
trackMap: Map<Long, List<Track>>, trackMap: Map<Long, List<Track>>,
@@ -489,7 +496,7 @@ class LibraryViewModel(
} }
private fun downloadNextChapters(amount: Int?) { private fun downloadNextChapters(amount: Int?) {
val mangas = state.value.selectedManga val mangas = selectedManga
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getNextChapters.await(manga.id) val chapters = getNextChapters.await(manga.id)
@@ -511,7 +518,7 @@ class LibraryViewModel(
} }
private fun downloadBookmarkedChapters() { private fun downloadBookmarkedChapters() {
val mangas = state.value.selectedManga val mangas = selectedManga
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getBookmarkedChaptersByMangaId.await(manga.id) val chapters = getBookmarkedChaptersByMangaId.await(manga.id)
@@ -534,7 +541,7 @@ class LibraryViewModel(
* Marks mangas' chapters read status. * Marks mangas' chapters read status.
*/ */
fun markReadSelection(read: Boolean) { fun markReadSelection(read: Boolean) {
val selection = state.value.selectedManga val selection = selectedManga
viewModelScope.launchNonCancellable { viewModelScope.launchNonCancellable {
selection.forEach { manga -> selection.forEach { manga ->
setReadStatus.await( setReadStatus.await(
@@ -613,23 +620,33 @@ class LibraryViewModel(
} }
fun showSettingsDialog() { fun showSettingsDialog() {
mutableState.update { it.copy(dialog = Dialog.SettingsSheet) } dialog.update { Dialog.SettingsSheet }
} }
private var lastSelectionCategory: Long? = null 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() { fun clearSelection() {
lastSelectionCategory = null lastSelectionCategory = null
mutableState.update { it.copy(selection = setOf()) } selection.update { setOf() }
} }
fun toggleSelection(category: Category, manga: LibraryManga) { fun toggleSelection(category: Category, manga: LibraryManga) {
mutableState.update { state -> selection.update { selection ->
val newSelection = state.selection.mutate { set -> val newSelection = selection.mutate { set ->
if (!set.remove(manga.id)) set.add(manga.id) if (!set.remove(manga.id)) set.add(manga.id)
} }
lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() } lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() }
state.copy(selection = newSelection) newSelection
} }
} }
@@ -638,8 +655,9 @@ class LibraryViewModel(
* same category as the given manga * same category as the given manga
*/ */
fun toggleRangeSelection(category: Category, manga: LibraryManga) { fun toggleRangeSelection(category: Category, manga: LibraryManga) {
mutableState.update { state -> val state = state.value
val newSelection = state.selection.mutate { list -> selection.update { selection ->
val newSelection = selection.mutate { list ->
val lastSelected = list.lastOrNull() val lastSelected = list.lastOrNull()
if (lastSelectionCategory != category.id) { if (lastSelectionCategory != category.id) {
list.add(manga.id) list.add(manga.id)
@@ -659,50 +677,49 @@ class LibraryViewModel(
selectionRange.mapNotNull { items[it] }.let(list::addAll) selectionRange.mapNotNull { items[it] }.let(list::addAll)
} }
lastSelectionCategory = category.id lastSelectionCategory = category.id
state.copy(selection = newSelection) newSelection
} }
} }
fun selectAll() { fun selectAll() {
lastSelectionCategory = null lastSelectionCategory = null
mutableState.update { state -> val state = state.value
val newSelection = state.selection.mutate { list -> selection.update { selection ->
selection.mutate { list ->
state.getItemsForCategoryId(state.activeCategory?.id).map { it.id }.let(list::addAll) state.getItemsForCategoryId(state.activeCategory?.id).map { it.id }.let(list::addAll)
} }
state.copy(selection = newSelection)
} }
} }
fun invertSelection() { fun invertSelection() {
lastSelectionCategory = null lastSelectionCategory = null
mutableState.update { state -> val state = state.value
val newSelection = state.selection.mutate { list -> selection.update { selection ->
selection.mutate { list ->
val itemIds = state.getItemsForCategoryId(state.activeCategory?.id).fastMap { it.id } val itemIds = state.getItemsForCategoryId(state.activeCategory?.id).fastMap { it.id }
val (toRemove, toAdd) = itemIds.partition { it in list } val (toRemove, toAdd) = itemIds.partition { it in list }
list.removeAll(toRemove) list.removeAll(toRemove)
list.addAll(toAdd) list.addAll(toAdd)
} }
state.copy(selection = newSelection)
} }
} }
fun search(query: String?) { fun search(query: String?) {
mutableState.update { it.copy(searchQuery = query) } searchQuery.update { query }
} }
fun updateActiveCategoryIndex(index: Int) { fun updateActiveCategoryIndex(index: Int) {
val newIndex = mutableState.updateAndGet { state -> activeCategoryIndex.update { index }
state.copy(activeCategoryIndex = index) // Coerce here rather than reading it back off [state], which is derived asynchronously
} // and would still hold the previous index at this point.
.coercedActiveCategoryIndex val lastIndex = state.value.displayedCategories.lastIndex.coerceAtLeast(0)
libraryPreferences.lastUsedCategory.set(index.coerceIn(0, lastIndex))
libraryPreferences.lastUsedCategory.set(newIndex)
} }
fun openChangeCategoryDialog() { fun openChangeCategoryDialog() {
viewModelScope.launchIO { viewModelScope.launchIO {
// Create a copy of selected manga // 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. // Hide the default category because it has a different behavior than the ones from db.
val categories = state.value.displayedCategories.filter { it.id != 0L } 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() { fun openDeleteMangaDialog() {
mutableState.update { it.copy(dialog = Dialog.DeleteManga(state.value.selectedManga)) } dialog.update { Dialog.DeleteManga(selectedManga) }
} }
fun closeDialog() { fun closeDialog() {
mutableState.update { it.copy(dialog = null) } dialog.update { null }
} }
sealed interface Dialog { sealed interface Dialog {