From e62cd0e816402303fdf12513816894624f77e208 Mon Sep 17 00:00:00 2001 From: AntsyLich <59261191+AntsyLich@users.noreply.github.com> Date: Sat, 2 Aug 2025 09:04:23 +0600 Subject: [PATCH] Optimize and cleanup library code (#2329) --- .editorconfig | 3 + CHANGELOG.md | 1 + .../components/LibraryComfortableGrid.kt | 5 +- .../library/components/LibraryCompactGrid.kt | 5 +- .../library/components/LibraryContent.kt | 51 +- .../library/components/LibraryList.kt | 5 +- .../library/components/LibraryPager.kt | 30 +- .../library/components/LibraryTabs.kt | 8 +- .../data/library/LibraryUpdateJob.kt | 23 +- .../tachiyomi/ui/library/LibraryItem.kt | 5 +- .../ui/library/LibraryScreenModel.kt | 445 +++++++++--------- .../kanade/tachiyomi/ui/library/LibraryTab.kt | 42 +- .../tachiyomi/ui/stats/StatsScreenModel.kt | 25 +- .../kotlin/mihon/core/common/utils/Set.kt | 5 + .../java/tachiyomi/data/manga/MangaMapper.kt | 4 +- .../sqldelight/tachiyomi/migrations/6.sqm | 39 ++ .../sqldelight/tachiyomi/view/libraryView.sq | 10 +- .../domain/library/model/LibraryManga.kt | 2 +- 18 files changed, 370 insertions(+), 338 deletions(-) create mode 100644 core/common/src/main/kotlin/mihon/core/common/utils/Set.kt create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/6.sqm diff --git a/.editorconfig b/.editorconfig index 144253bde..a02bac8fd 100644 --- a/.editorconfig +++ b/.editorconfig @@ -23,6 +23,9 @@ ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 ktlint_code_style = intellij_idea ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_class-signature = disabled +ktlint_standard_comment-wrapping = disabled ktlint_standard_discouraged-comment-location = disabled ktlint_standard_function-expression-body = disabled ktlint_standard_function-signature = disabled +ktlint_standard_type-argument-comment = disabled +ktlint_standard_type-parameter-comment = disabled diff --git a/CHANGELOG.md b/CHANGELOG.md index c6ef2b019..c4d9790b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co - Added autofill support to tracker login dialog ([@AntsyLich](https://github.com/AntsyLich)) ([#2069](https://github.com/mihonapp/mihon/pull/2069)) - Added option to hide missing chapter count ([@User826](https://github.com/User826), [@AntsyLich](https://github.com/AntsyLich)) ([#2108](https://github.com/mihonapp/mihon/pull/2108)) - Use median to determine smart update interval, making it more resilient to long hiatuses ([@Kladki](https://github.com/Kladki)) ([#2251](https://github.com/mihonapp/mihon/pull/2251)) +- Optimize library code to potentially better handle big user libraries ([@AntsyLich](https://github.com/AntsyLich)) ([#2329](https://github.com/mihonapp/mihon/pull/2329)) ### Changed - Display all similarly named duplicates in duplicate manga dialogue ([@NarwhalHorns](https://github.com/NarwhalHorns), [@AntsyLich](https://github.com/AntsyLich)) ([#1861](https://github.com/mihonapp/mihon/pull/1861)) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt index 379d4054c..ed89e77f0 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryComfortableGrid.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.util.fastAny import eu.kanade.tachiyomi.ui.library.LibraryItem import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.MangaCover @@ -15,7 +14,7 @@ internal fun LibraryComfortableGrid( items: List, columns: Int, contentPadding: PaddingValues, - selection: List, + selection: Set, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?, @@ -35,7 +34,7 @@ internal fun LibraryComfortableGrid( ) { libraryItem -> val manga = libraryItem.libraryManga.manga MangaComfortableGridItem( - isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, + isSelected = manga.id in selection, title = manga.title, coverData = MangaCover( mangaId = manga.id, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt index 56caa99e0..741bef6f5 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryCompactGrid.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.util.fastAny import eu.kanade.tachiyomi.ui.library.LibraryItem import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.MangaCover @@ -16,7 +15,7 @@ internal fun LibraryCompactGrid( showTitle: Boolean, columns: Int, contentPadding: PaddingValues, - selection: List, + selection: Set, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?, @@ -36,7 +35,7 @@ internal fun LibraryCompactGrid( ) { libraryItem -> val manga = libraryItem.libraryManga.manga MangaCompactGridItem( - isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, + isSelected = manga.id in selection, title = manga.title.takeIf { showTitle }, coverData = MangaCover( mangaId = manga.id, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt index 61da10345..7a5d118de 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryContent.kt @@ -29,22 +29,22 @@ import kotlin.time.Duration.Companion.seconds fun LibraryContent( categories: List, searchQuery: String?, - selection: List, + selection: Set, contentPadding: PaddingValues, currentPage: () -> Int, hasActiveFilters: Boolean, showPageTabs: Boolean, onChangeCurrentPage: (Int) -> Unit, - onMangaClicked: (Long) -> Unit, + onClickManga: (Long) -> Unit, onContinueReadingClicked: ((LibraryManga) -> Unit)?, - onToggleSelection: (LibraryManga) -> Unit, - onToggleRangeSelection: (LibraryManga) -> Unit, - onRefresh: (Category?) -> Boolean, + onToggleSelection: (Category, LibraryManga) -> Unit, + onToggleRangeSelection: (Category, LibraryManga) -> Unit, + onRefresh: () -> Boolean, onGlobalSearchClicked: () -> Unit, - getNumberOfMangaForCategory: (Category) -> Int?, + getItemCountForCategory: (Category) -> Int?, getDisplayMode: (Int) -> PreferenceMutableState, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, - getLibraryForPage: (Int) -> List, + getItemsForCategory: (Category) -> List, ) { Column( modifier = Modifier.padding( @@ -59,7 +59,7 @@ fun LibraryContent( val scope = rememberCoroutineScope() var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } - if (showPageTabs && categories.size > 1) { + if (showPageTabs && categories.isNotEmpty()) { LaunchedEffect(categories) { if (categories.size <= pagerState.currentPage) { pagerState.scrollToPage(categories.size - 1) @@ -68,23 +68,20 @@ fun LibraryContent( LibraryTabs( categories = categories, pagerState = pagerState, - getNumberOfMangaForCategory = getNumberOfMangaForCategory, - ) { scope.launch { pagerState.animateScrollToPage(it) } } - } - - val notSelectionMode = selection.isEmpty() - val onClickManga = { manga: LibraryManga -> - if (notSelectionMode) { - onMangaClicked(manga.manga.id) - } else { - onToggleSelection(manga) - } + getItemCountForCategory = getItemCountForCategory, + onTabItemClick = { + scope.launch { + pagerState.animateScrollToPage(it) + } + }, + ) } PullRefresh( refreshing = isRefreshing, + enabled = selection.isEmpty(), onRefresh = { - val started = onRefresh(categories[currentPage()]) + val started = onRefresh() if (!started) return@PullRefresh scope.launch { // Fake refresh status but hide it after a second as it's a long running task @@ -93,19 +90,25 @@ fun LibraryContent( isRefreshing = false } }, - enabled = notSelectionMode, ) { LibraryPager( state = pagerState, contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), hasActiveFilters = hasActiveFilters, - selectedManga = selection, + selection = selection, searchQuery = searchQuery, onGlobalSearchClicked = onGlobalSearchClicked, + getCategoryForPage = { page -> categories[page] }, getDisplayMode = getDisplayMode, getColumnsForOrientation = getColumnsForOrientation, - getLibraryForPage = getLibraryForPage, - onClickManga = onClickManga, + getItemsForCategory = getItemsForCategory, + onClickManga = { category, manga -> + if (selection.isNotEmpty()) { + onToggleSelection(category, manga) + } else { + onClickManga(manga.manga.id) + } + }, onLongClickManga = onToggleRangeSelection, onClickContinueReading = onContinueReadingClicked, ) diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt index cb57bae0e..bb9369d5f 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryList.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.ui.util.fastAny import eu.kanade.tachiyomi.ui.library.LibraryItem import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.manga.model.MangaCover @@ -18,7 +17,7 @@ import tachiyomi.presentation.core.util.plus internal fun LibraryList( items: List, contentPadding: PaddingValues, - selection: List, + selection: Set, onClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?, @@ -45,7 +44,7 @@ internal fun LibraryList( ) { libraryItem -> val manga = libraryItem.libraryManga.manga MangaListItem( - isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, + isSelected = manga.id in selection, title = manga.title, coverData = MangaCover( mangaId = manga.id, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt index 6487ab39f..b3bfd0f3c 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.tachiyomi.ui.library.LibraryItem +import tachiyomi.domain.category.model.Category import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryManga import tachiyomi.i18n.MR @@ -31,14 +32,15 @@ fun LibraryPager( state: PagerState, contentPadding: PaddingValues, hasActiveFilters: Boolean, - selectedManga: List, + selection: Set, searchQuery: String?, onGlobalSearchClicked: () -> Unit, + getCategoryForPage: (Int) -> Category, getDisplayMode: (Int) -> PreferenceMutableState, getColumnsForOrientation: (Boolean) -> PreferenceMutableState, - getLibraryForPage: (Int) -> List, - onClickManga: (LibraryManga) -> Unit, - onLongClickManga: (LibraryManga) -> Unit, + getItemsForCategory: (Category) -> List, + onClickManga: (Category, LibraryManga) -> Unit, + onLongClickManga: (Category, LibraryManga) -> Unit, onClickContinueReading: ((LibraryManga) -> Unit)?, ) { HorizontalPager( @@ -50,9 +52,10 @@ fun LibraryPager( // To make sure only one offscreen page is being composed return@HorizontalPager } - val library = getLibraryForPage(page) + val category = getCategoryForPage(page) + val items = getItemsForCategory(category) - if (library.isEmpty()) { + if (items.isEmpty()) { LibraryPagerEmptyScreen( searchQuery = searchQuery, hasActiveFilters = hasActiveFilters, @@ -72,12 +75,15 @@ fun LibraryPager( remember { mutableIntStateOf(0) } } + val onClickManga: (LibraryManga) -> Unit = { onClickManga(category, it) } + val onLongClickManga: (LibraryManga) -> Unit = { onLongClickManga(category, it) } + when (displayMode) { LibraryDisplayMode.List -> { LibraryList( - items = library, + items = items, contentPadding = contentPadding, - selection = selectedManga, + selection = selection, onClick = onClickManga, onLongClick = onLongClickManga, onClickContinueReading = onClickContinueReading, @@ -87,11 +93,11 @@ fun LibraryPager( } LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { LibraryCompactGrid( - items = library, + items = items, showTitle = displayMode is LibraryDisplayMode.CompactGrid, columns = columns, contentPadding = contentPadding, - selection = selectedManga, + selection = selection, onClick = onClickManga, onLongClick = onLongClickManga, onClickContinueReading = onClickContinueReading, @@ -101,10 +107,10 @@ fun LibraryPager( } LibraryDisplayMode.ComfortableGrid -> { LibraryComfortableGrid( - items = library, + items = items, columns = columns, contentPadding = contentPadding, - selection = selectedManga, + selection = selection, onClick = onClickManga, onLongClick = onLongClickManga, onClickContinueReading = onClickContinueReading, diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt index a3d48d404..6aa87a01e 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryTabs.kt @@ -18,13 +18,11 @@ import tachiyomi.presentation.core.components.material.TabText internal fun LibraryTabs( categories: List, pagerState: PagerState, - getNumberOfMangaForCategory: (Category) -> Int?, + getItemCountForCategory: (Category) -> Int?, onTabItemClick: (Int) -> Unit, ) { val currentPageIndex = pagerState.currentPage.coerceAtMost(categories.lastIndex) - Column( - modifier = Modifier.zIndex(1f), - ) { + Column(modifier = Modifier.zIndex(2f)) { PrimaryScrollableTabRow( selectedTabIndex = currentPageIndex, edgePadding = 0.dp, @@ -39,7 +37,7 @@ internal fun LibraryTabs( text = { TabText( text = category.visualName, - badgeCount = getNumberOfMangaForCategory(category), + badgeCount = getItemCountForCategory(category), ) }, unselectedContentColor = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index ecb6af7a5..f3adddf43 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -158,25 +158,16 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet val libraryManga = getLibraryManga.await() val listToUpdate = if (categoryId != -1L) { - libraryManga.filter { it.category == categoryId } + libraryManga.filter { categoryId in it.categories } } else { - val categoriesToUpdate = libraryPreferences.updateCategories().get().map { it.toLong() } - val includedManga = if (categoriesToUpdate.isNotEmpty()) { - libraryManga.filter { it.category in categoriesToUpdate } - } else { - libraryManga - } + val includedCategories = libraryPreferences.updateCategories().get().map { it.toLong() } + val excludedCategories = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() } - val categoriesToExclude = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() } - val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) { - libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id } - } else { - emptyList() + libraryManga.filter { + val included = includedCategories.isEmpty() || it.categories.intersect(includedCategories).isNotEmpty() + val excluded = it.categories.intersect(excludedCategories).isNotEmpty() + included && !excluded } - - includedManga - .filterNot { it.manga.id in excludedMangaIds } - .distinctBy { it.manga.id } } val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt index eb3f3c601..0818bf4a2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryItem.kt @@ -14,6 +14,8 @@ data class LibraryItem( val sourceLanguage: String = "", private val sourceManager: SourceManager = Injekt.get(), ) { + val id: Long = libraryManga.id + /** * Checks if a query matches the manga * @@ -23,8 +25,7 @@ data class LibraryItem( fun matches(constraint: String): Boolean { val sourceName by lazy { sourceManager.getOrStub(libraryManga.manga.source).getNameForMangaInfo() } if (constraint.startsWith("id:", true)) { - val id = constraint.substringAfter("id:").toLongOrNull() - return libraryManga.id == id + return id == constraint.substringAfter("id:").toLongOrNull() } return libraryManga.manga.title.contains(constraint, true) || (libraryManga.manga.author?.contains(constraint, true) ?: false) || diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index 9b34276a1..838d839b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -4,16 +4,13 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.util.fastAny -import androidx.compose.ui.util.fastDistinctBy import androidx.compose.ui.util.fastFilter import androidx.compose.ui.util.fastMap -import androidx.compose.ui.util.fastMapNotNull import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.core.preference.asState import eu.kanade.core.util.fastFilterNot -import eu.kanade.core.util.fastPartition import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.manga.interactor.UpdateManga @@ -29,28 +26,25 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.getNextUnread import eu.kanade.tachiyomi.util.removeCovers import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.PersistentList -import kotlinx.collections.immutable.mutate -import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update +import mihon.core.common.utils.mutate import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.util.lang.compareToWithCollator import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchNonCancellable -import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.model.Category @@ -74,11 +68,6 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import kotlin.random.Random -/** - * Typealias for the library manga, using the category as keys, and list of manga as values. - */ -typealias LibraryMap = Map> - class LibraryScreenModel( private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), @@ -98,32 +87,52 @@ class LibraryScreenModel( ) : StateScreenModel(State()) { var activeCategoryIndex: Int by libraryPreferences.lastUsedCategory().asState(screenModelScope) + val activeCategory: Category get() = state.value.displayedCategories[activeCategoryIndex] init { screenModelScope.launchIO { combine( state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS), - getLibraryFlow(), - getTracksPerManga.subscribe(), - getTrackingFilterFlow(), - downloadCache.changes, - ) { searchQuery, library, tracks, trackingFilter, _ -> - library - .applyFilters(tracks, trackingFilter) - .applySort(tracks, trackingFilter.keys) - .mapValues { (_, value) -> - if (searchQuery != null) { - value.filter { it.matches(searchQuery) } - } else { - value - } - } + getCategories.subscribe(), + getFavoritesFlow(), + combine(getTracksPerManga.subscribe(), getTrackingFiltersFlow(), ::Pair), + getLibraryItemPreferencesFlow(), + ) { searchQuery, categories, favorites, (tracksMap, trackingFilters), itemPreferences -> + val filteredFavorites = favorites + .applyFilters(tracksMap, trackingFilters, itemPreferences) + .let { if (searchQuery == null) it else it.filter { m -> m.matches(searchQuery) } } + + LibraryData( + isInitialized = true, + categories = categories, + favorites = filteredFavorites, + tracksMap = tracksMap, + loggedInTrackerIds = trackingFilters.keys, + ) } + .distinctUntilChanged() + .collectLatest { libraryData -> + mutableState.update { state -> + state.copy(libraryData = libraryData) + } + } + } + + screenModelScope.launchIO { + state + .dropWhile { !it.libraryData.isInitialized } + .map { it.libraryData } + .distinctUntilChanged() + .map { data -> + data.favorites + .applyGrouping(data.categories) + .applySort(data.favoritesById, data.tracksMap, data.loggedInTrackerIds) + } .collectLatest { mutableState.update { state -> state.copy( isLoading = false, - library = it, + groupedFavorites = it, ) } } @@ -147,18 +156,18 @@ class LibraryScreenModel( combine( getLibraryItemPreferencesFlow(), - getTrackingFilterFlow(), - ) { prefs, trackFilter -> - ( - listOf( - prefs.filterDownloaded, - prefs.filterUnread, - prefs.filterStarted, - prefs.filterBookmarked, - prefs.filterCompleted, - prefs.filterIntervalCustom, - ) + trackFilter.values - ).any { it != TriState.DISABLED } + 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 { @@ -169,19 +178,19 @@ class LibraryScreenModel( .launchIn(screenModelScope) } - private suspend fun LibraryMap.applyFilters( + private fun List.applyFilters( trackMap: Map>, trackingFilter: Map, - ): LibraryMap { - val prefs = getLibraryItemPreferencesFlow().first() - val downloadedOnly = prefs.globalFilterDownloaded - val skipOutsideReleasePeriod = prefs.skipOutsideReleasePeriod - val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else prefs.filterDownloaded - val filterUnread = prefs.filterUnread - val filterStarted = prefs.filterStarted - val filterBookmarked = prefs.filterBookmarked - val filterCompleted = prefs.filterCompleted - val filterIntervalCustom = prefs.filterIntervalCustom + preferences: ItemPreferences, + ): List { + val downloadedOnly = preferences.globalFilterDownloaded + val skipOutsideReleasePeriod = preferences.skipOutsideReleasePeriod + val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else preferences.filterDownloaded + val filterUnread = preferences.filterUnread + val filterStarted = preferences.filterStarted + val filterBookmarked = preferences.filterBookmarked + val filterCompleted = preferences.filterCompleted + val filterIntervalCustom = preferences.filterIntervalCustom val isNotLoggedInAnyTrack = trackingFilter.isEmpty() @@ -225,7 +234,7 @@ class LibraryScreenModel( if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true val mangaTracks = trackMap - .mapValues { entry -> entry.value.map { it.trackerId } }[item.libraryManga.id] + .mapValues { entry -> entry.value.map { it.trackerId } }[item.id] .orEmpty() val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks } @@ -234,7 +243,7 @@ class LibraryScreenModel( !isExcluded && isIncluded } - val filterFn: (LibraryItem) -> Boolean = { + return fastFilter { filterFnDownloaded(it) && filterFnUnread(it) && filterFnStarted(it) && @@ -243,13 +252,31 @@ class LibraryScreenModel( filterFnIntervalCustom(it) && filterFnTracking(it) } - - return mapValues { (_, value) -> value.fastFilter(filterFn) } } - private fun LibraryMap.applySort(trackMap: Map>, loggedInTrackerIds: Set): LibraryMap { - val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> - i1.libraryManga.manga.title.lowercase().compareToWithCollator(i2.libraryManga.manga.title.lowercase()) + private fun List.applyGrouping( + categories: List, + ): Map> { + val groupCache = mutableMapOf>() + forEach { item -> + item.libraryManga.categories.forEach { categoryId -> + groupCache.getOrPut(categoryId) { mutableListOf() }.add(item.id) + } + } + val showSystemCategory = groupCache.containsKey(0L) + return categories.filter { showSystemCategory || !it.isSystemCategory } + .associateWith { groupCache[it.id]?.toList().orEmpty() } + } + + private fun Map>.applySort( + favoritesById: Map, + trackMap: Map>, + loggedInTrackerIds: Set, + ): Map> { + val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { manga1, manga2 -> + val title1 = manga1.libraryManga.manga.title.lowercase() + val title2 = manga2.libraryManga.manga.title.lowercase() + title1.compareToWithCollator(title2) } val defaultTrackerScoreSortValue = -1.0 @@ -266,39 +293,39 @@ class LibraryScreenModel( } } - fun LibrarySort.comparator(): Comparator = Comparator { i1, i2 -> + fun LibrarySort.comparator(): Comparator = Comparator { manga1, manga2 -> when (this.type) { LibrarySort.Type.Alphabetical -> { - sortAlphabetically(i1, i2) + sortAlphabetically(manga1, manga2) } LibrarySort.Type.LastRead -> { - i1.libraryManga.lastRead.compareTo(i2.libraryManga.lastRead) + manga1.libraryManga.lastRead.compareTo(manga2.libraryManga.lastRead) } LibrarySort.Type.LastUpdate -> { - i1.libraryManga.manga.lastUpdate.compareTo(i2.libraryManga.manga.lastUpdate) + manga1.libraryManga.manga.lastUpdate.compareTo(manga2.libraryManga.manga.lastUpdate) } LibrarySort.Type.UnreadCount -> when { // Ensure unread content comes first - i1.libraryManga.unreadCount == i2.libraryManga.unreadCount -> 0 - i1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1 - i2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1 - else -> i1.libraryManga.unreadCount.compareTo(i2.libraryManga.unreadCount) + manga1.libraryManga.unreadCount == manga2.libraryManga.unreadCount -> 0 + manga1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1 + manga2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1 + else -> manga1.libraryManga.unreadCount.compareTo(manga2.libraryManga.unreadCount) } LibrarySort.Type.TotalChapters -> { - i1.libraryManga.totalChapters.compareTo(i2.libraryManga.totalChapters) + manga1.libraryManga.totalChapters.compareTo(manga2.libraryManga.totalChapters) } LibrarySort.Type.LatestChapter -> { - i1.libraryManga.latestUpload.compareTo(i2.libraryManga.latestUpload) + manga1.libraryManga.latestUpload.compareTo(manga2.libraryManga.latestUpload) } LibrarySort.Type.ChapterFetchDate -> { - i1.libraryManga.chapterFetchedAt.compareTo(i2.libraryManga.chapterFetchedAt) + manga1.libraryManga.chapterFetchedAt.compareTo(manga2.libraryManga.chapterFetchedAt) } LibrarySort.Type.DateAdded -> { - i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded) + manga1.libraryManga.manga.dateAdded.compareTo(manga2.libraryManga.manga.dateAdded) } LibrarySort.Type.TrackerMean -> { - val item1Score = trackerScores[i1.libraryManga.id] ?: defaultTrackerScoreSortValue - val item2Score = trackerScores[i2.libraryManga.id] ?: defaultTrackerScoreSortValue + val item1Score = trackerScores[manga1.id] ?: defaultTrackerScoreSortValue + val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue item1Score.compareTo(item2Score) } LibrarySort.Type.Random -> { @@ -312,11 +339,13 @@ class LibraryScreenModel( return@mapValues value.shuffled(Random(libraryPreferences.randomSortSeed().get())) } + val manga = value.mapNotNull { favoritesById[it] } + val comparator = key.sort.comparator() .let { if (key.sort.isAscending) it else it.reversed() } .thenComparator(sortAlphabetically) - value.sortedWith(comparator) + manga.sortedWith(comparator).map { it.id } } } @@ -353,45 +382,37 @@ class LibraryScreenModel( } } - /** - * Get the categories and all its manga from the database. - */ - private fun getLibraryFlow(): Flow { - val libraryMangasFlow = combine( + private fun getFavoritesFlow(): Flow> { + return combine( getLibraryManga.subscribe(), getLibraryItemPreferencesFlow(), downloadCache.changes, - ) { libraryMangaList, prefs, _ -> - libraryMangaList - .map { libraryManga -> - // Display mode based on user preference: take it from global library setting or category - LibraryItem( - libraryManga, - downloadCount = if (prefs.downloadBadge) { - downloadManager.getDownloadCount(libraryManga.manga).toLong() - } else { - 0 - }, - unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0, - isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false, - sourceLanguage = if (prefs.languageBadge) { - sourceManager.getOrStub(libraryManga.manga.source).lang - } else { - "" - }, - ) - } - .groupBy { it.libraryManga.category } - } - - return combine(getCategories.subscribe(), libraryMangasFlow) { categories, libraryManga -> - val displayCategories = if (libraryManga.isNotEmpty() && !libraryManga.containsKey(0)) { - categories.fastFilterNot { it.isSystemCategory } - } else { - categories + ) { libraryManga, preferences, _ -> + libraryManga.map { manga -> + LibraryItem( + libraryManga = manga, + downloadCount = if (preferences.downloadBadge) { + downloadManager.getDownloadCount(manga.manga).toLong() + } else { + 0 + }, + unreadCount = if (preferences.unreadBadge) { + manga.unreadCount + } else { + 0 + }, + isLocal = if (preferences.localBadge) { + manga.manga.isLocal() + } else { + false + }, + sourceLanguage = if (preferences.languageBadge) { + sourceManager.getOrStub(manga.manga.source).lang + } else { + "" + }, + ) } - - displayCategories.associateWith { libraryManga[it.id].orEmpty() } } } @@ -400,17 +421,15 @@ class LibraryScreenModel( * * @return map of track id with the filter value */ - private fun getTrackingFilterFlow(): Flow> { + private fun getTrackingFiltersFlow(): Flow> { return trackerManager.loggedInTrackersFlow().flatMapLatest { loggedInTrackers -> - if (loggedInTrackers.isEmpty()) return@flatMapLatest flowOf(emptyMap()) - - val prefFlows = loggedInTrackers.map { tracker -> - libraryPreferences.filterTracking(tracker.id.toInt()).changes() - } - combine(prefFlows) { - loggedInTrackers - .mapIndexed { index, tracker -> tracker.id to it[index] } - .toMap() + if (loggedInTrackers.isEmpty()) { + flowOf(emptyMap()) + } else { + val filterFlows = loggedInTrackers.map { tracker -> + libraryPreferences.filterTracking(tracker.id.toInt()).changes().map { tracker.id to it } + } + combine(filterFlows) { it.toMap() } } } } @@ -443,26 +462,19 @@ class LibraryScreenModel( return mangaCategories.flatten().distinct().subtract(common) } - fun runDownloadActionSelection(action: DownloadAction) { - val selection = state.value.selection - val mangas = selection.map { it.manga }.toList() - when (action) { - DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1) - DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5) - DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10) - DownloadAction.NEXT_25_CHAPTERS -> downloadUnreadChapters(mangas, 25) - DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null) + /** + * Queues the amount specified of unread chapters from the list of selected manga + */ + fun performDownloadAction(action: DownloadAction) { + val mangas = state.value.selectedManga + val amount = when (action) { + DownloadAction.NEXT_1_CHAPTER -> 1 + DownloadAction.NEXT_5_CHAPTERS -> 5 + DownloadAction.NEXT_10_CHAPTERS -> 10 + DownloadAction.NEXT_25_CHAPTERS -> 25 + DownloadAction.UNREAD_CHAPTERS -> null } clearSelection() - } - - /** - * Queues the amount specified of unread chapters from the list of mangas given. - * - * @param mangas the list of manga. - * @param amount the amount to queue or null to queue all - */ - private fun downloadUnreadChapters(mangas: List, amount: Int?) { screenModelScope.launchNonCancellable { mangas.forEach { manga -> val chapters = getNextChapters.await(manga.id) @@ -486,11 +498,10 @@ class LibraryScreenModel( * Marks mangas' chapters read status. */ fun markReadSelection(read: Boolean) { - val mangas = state.value.selection.toList() screenModelScope.launchNonCancellable { - mangas.forEach { manga -> + state.value.selectedManga.forEach { manga -> setReadStatus.await( - manga = manga.manga, + manga = manga, read = read, ) } @@ -501,16 +512,14 @@ class LibraryScreenModel( /** * Remove the selected manga. * - * @param mangaList the list of manga to delete. + * @param mangas the list of manga to delete. * @param deleteFromLibrary whether to delete manga from library. * @param deleteChapters whether to delete downloaded chapters. */ - fun removeMangas(mangaList: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { + fun removeMangas(mangas: List, deleteFromLibrary: Boolean, deleteChapters: Boolean) { screenModelScope.launchNonCancellable { - val mangaToDelete = mangaList.distinctBy { it.id } - if (deleteFromLibrary) { - val toDelete = mangaToDelete.map { + val toDelete = mangas.map { it.removeCovers(coverCache) MangaUpdate( favorite = false, @@ -521,7 +530,7 @@ class LibraryScreenModel( } if (deleteChapters) { - mangaToDelete.forEach { manga -> + mangas.forEach { manga -> val source = sourceManager.get(manga.source) as? HttpSource if (source != null) { downloadManager.deleteManga(manga, source) @@ -556,38 +565,32 @@ class LibraryScreenModel( return libraryPreferences.displayMode().asState(screenModelScope) } - fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState { + fun getColumnsForOrientation(isLandscape: Boolean): PreferenceMutableState { return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()) .asState(screenModelScope) } - suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? { - if (state.value.categories.isEmpty()) return null - - return withIOContext { - state.value - .getLibraryItemsByCategoryId(state.value.categories[activeCategoryIndex].id) - ?.randomOrNull() - } + fun getRandomLibraryItemForCurrentCategory(): LibraryItem? { + return state.value.getItemsForCategoryId(activeCategory.id).randomOrNull() } fun showSettingsDialog() { mutableState.update { it.copy(dialog = Dialog.SettingsSheet) } } + private var lastSelectionCategory: Long? = null + fun clearSelection() { - mutableState.update { it.copy(selection = persistentListOf()) } + lastSelectionCategory = null + mutableState.update { it.copy(selection = setOf()) } } - fun toggleSelection(manga: LibraryManga) { + fun toggleSelection(category: Category, manga: LibraryManga) { mutableState.update { state -> - val newSelection = state.selection.mutate { list -> - if (list.fastAny { it.id == manga.id }) { - list.removeAll { it.id == manga.id } - } else { - list.add(manga) - } + val newSelection = state.selection.mutate { set -> + if (!set.remove(manga.id)) set.add(manga.id) } + lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() } state.copy(selection = newSelection) } } @@ -596,60 +599,49 @@ class LibraryScreenModel( * Selects all mangas between and including the given manga and the last pressed manga from the * same category as the given manga */ - fun toggleRangeSelection(manga: LibraryManga) { + fun toggleRangeSelection(category: Category, manga: LibraryManga) { mutableState.update { state -> val newSelection = state.selection.mutate { list -> val lastSelected = list.lastOrNull() - if (lastSelected?.category != manga.category) { - list.add(manga) + if (lastSelectionCategory != category.id) { + list.add(manga.id) return@mutate } - val items = state.getLibraryItemsByCategoryId(manga.category) - ?.fastMap { it.libraryManga }.orEmpty() + val items = state.getItemsForCategoryId(category.id).fastMap { it.id } val lastMangaIndex = items.indexOf(lastSelected) - val curMangaIndex = items.indexOf(manga) + val curMangaIndex = items.indexOf(manga.id) - val selectedIds = list.fastMap { it.id } val selectionRange = when { - lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) - curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) + lastMangaIndex < curMangaIndex -> lastMangaIndex..curMangaIndex + curMangaIndex < lastMangaIndex -> curMangaIndex..lastMangaIndex // We shouldn't reach this point else -> return@mutate } - val newSelections = selectionRange.mapNotNull { index -> - items[index].takeUnless { it.id in selectedIds } - } - list.addAll(newSelections) + selectionRange.mapNotNull { items[it] }.let(list::addAll) + } + lastSelectionCategory = category.id + state.copy(selection = newSelection) + } + } + + fun selectAll() { + lastSelectionCategory = null + mutableState.update { state -> + val newSelection = state.selection.mutate { list -> + state.getItemsForCategoryId(activeCategory.id).map { it.id }.let(list::addAll) } state.copy(selection = newSelection) } } - fun selectAll(index: Int) { + fun invertSelection() { + lastSelectionCategory = null mutableState.update { state -> val newSelection = state.selection.mutate { list -> - val categoryId = state.categories.getOrNull(index)?.id ?: -1 - val selectedIds = list.fastMap { it.id } - state.getLibraryItemsByCategoryId(categoryId) - ?.fastMapNotNull { item -> - item.libraryManga.takeUnless { it.id in selectedIds } - } - ?.let { list.addAll(it) } - } - state.copy(selection = newSelection) - } - } - - fun invertSelection(index: Int) { - mutableState.update { state -> - val newSelection = state.selection.mutate { list -> - val categoryId = state.categories[index].id - val items = state.getLibraryItemsByCategoryId(categoryId)?.fastMap { it.libraryManga }.orEmpty() - val selectedIds = list.fastMap { it.id } - val (toRemove, toAdd) = items.fastPartition { it.id in selectedIds } - val toRemoveIds = toRemove.fastMap { it.id } - list.removeAll { it.id in toRemoveIds } + val itemIds = state.getItemsForCategoryId(activeCategory.id).fastMap { it.id } + val (toRemove, toAdd) = itemIds.partition { it in list } + list.removeAll(toRemove) list.addAll(toAdd) } state.copy(selection = newSelection) @@ -663,10 +655,10 @@ class LibraryScreenModel( fun openChangeCategoryDialog() { screenModelScope.launchIO { // Create a copy of selected manga - val mangaList = state.value.selection.map { it.manga } + val mangaList = state.value.selectedManga // Hide the default category because it has a different behavior than the ones from db. - val categories = state.value.categories.filter { it.id != 0L } + val categories = state.value.displayedCategories.filter { it.id != 0L } // Get indexes of the common categories to preselect. val common = getCommonCategories(mangaList) @@ -686,8 +678,7 @@ class LibraryScreenModel( } fun openDeleteMangaDialog() { - val mangaList = state.value.selection.map { it.manga } - mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) } + mutableState.update { it.copy(dialog = Dialog.DeleteManga(state.value.selectedManga)) } } fun closeDialog() { @@ -720,41 +711,50 @@ class LibraryScreenModel( val filterIntervalCustom: TriState, ) + @Immutable + data class LibraryData( + val isInitialized: Boolean = false, + val categories: List = emptyList(), + val favorites: List = emptyList(), + val tracksMap: Map> = emptyMap(), + val loggedInTrackerIds: Set = emptySet(), + ) { + val favoritesById by lazy { favorites.associateBy { it.id } } + } + @Immutable data class State( + val isInitialized: Boolean = false, val isLoading: Boolean = true, - val library: LibraryMap = emptyMap(), val searchQuery: String? = null, - val selection: PersistentList = persistentListOf(), + val selection: Set = setOf(), val hasActiveFilters: Boolean = false, val showCategoryTabs: Boolean = false, val showMangaCount: Boolean = false, val showMangaContinueButton: Boolean = false, val dialog: Dialog? = null, + val libraryData: LibraryData = LibraryData(), + private val groupedFavorites: Map> = emptyMap(), ) { - private val libraryCount by lazy { - library.values - .flatten() - .fastDistinctBy { it.libraryManga.manga.id } - .size - } - - val isLibraryEmpty by lazy { libraryCount == 0 } + val isLibraryEmpty = libraryData.favorites.isEmpty() val selectionMode = selection.isNotEmpty() - val categories = library.keys.toList() + val selectedManga by lazy { selection.mapNotNull { libraryData.favoritesById[it]?.libraryManga?.manga } } - fun getLibraryItemsByCategoryId(categoryId: Long): List? { - return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } } + val displayedCategories = groupedFavorites.keys.toList() + + fun getItemsForCategoryId(categoryId: Long): List { + val category = displayedCategories.find { it.id == categoryId } ?: return emptyList() + return getItemsForCategory(category) } - fun getLibraryItemsByPage(page: Int): List { - return library.values.toTypedArray().getOrNull(page).orEmpty() + fun getItemsForCategory(category: Category): List { + return groupedFavorites[category].orEmpty().mapNotNull { libraryData.favoritesById[it] } } - fun getMangaCountForCategory(category: Category): Int? { - return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null + fun getItemCountForCategory(category: Category): Int? { + return if (showMangaCount || !searchQuery.isNullOrEmpty()) groupedFavorites[category]?.size else null } fun getToolbarTitle( @@ -762,18 +762,17 @@ class LibraryScreenModel( defaultCategoryTitle: String, page: Int, ): LibraryToolbarTitle { - val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle) + val category = displayedCategories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle) val categoryName = category.let { if (it.isSystemCategory) defaultCategoryTitle else it.name } val title = if (showCategoryTabs) defaultTitle else categoryName val count = when { !showMangaCount -> null - !showCategoryTabs -> getMangaCountForCategory(category) + !showCategoryTabs -> getItemCountForCategory(category) // Whole library count - else -> libraryCount + else -> libraryData.favorites.size } - return LibraryToolbarTitle(title, count) } } 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 a42a9bac0..cd2e5c01e 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 @@ -112,16 +112,15 @@ data object LibraryTab : Tab { defaultCategoryTitle = stringResource(MR.strings.label_default), page = screenModel.activeCategoryIndex, ) - val tabVisible = state.showCategoryTabs && state.categories.size > 1 LibraryToolbar( hasActiveFilters = state.hasActiveFilters, selectedCount = state.selection.size, title = title, onClickUnselectAll = screenModel::clearSelection, - onClickSelectAll = { screenModel.selectAll(screenModel.activeCategoryIndex) }, - onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategoryIndex) }, + onClickSelectAll = screenModel::selectAll, + onClickInvertSelection = screenModel::invertSelection, onClickFilter = screenModel::showSettingsDialog, - onClickRefresh = { onClickRefresh(state.categories[screenModel.activeCategoryIndex]) }, + onClickRefresh = { onClickRefresh(screenModel.activeCategory) }, onClickGlobalUpdate = { onClickRefresh(null) }, onClickOpenRandomManga = { scope.launch { @@ -137,7 +136,8 @@ data object LibraryTab : Tab { }, searchQuery = state.searchQuery, onSearchQueryChange = screenModel::search, - scrollBehavior = scrollBehavior.takeIf { !tabVisible }, // For scroll overlay when no tab + // For scroll overlay when no tab + scrollBehavior = scrollBehavior.takeIf { !state.showCategoryTabs }, ) }, bottomBar = { @@ -146,15 +146,17 @@ data object LibraryTab : Tab { onChangeCategoryClicked = screenModel::openChangeCategoryDialog, onMarkAsReadClicked = { screenModel.markReadSelection(true) }, onMarkAsUnreadClicked = { screenModel.markReadSelection(false) }, - onDownloadClicked = screenModel::runDownloadActionSelection - .takeIf { state.selection.fastAll { !it.manga.isLocal() } }, + onDownloadClicked = screenModel::performDownloadAction + .takeIf { state.selectedManga.fastAll { !it.isLocal() } }, onDeleteClicked = screenModel::openDeleteMangaDialog, ) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, ) { contentPadding -> when { - state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) + state.isLoading -> { + LoadingScreen(Modifier.padding(contentPadding)) + } state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { val handler = LocalUriHandler.current EmptyScreen( @@ -171,7 +173,7 @@ data object LibraryTab : Tab { } else -> { LibraryContent( - categories = state.categories, + categories = state.displayedCategories, searchQuery = state.searchQuery, selection = state.selection, contentPadding = contentPadding, @@ -179,7 +181,7 @@ data object LibraryTab : Tab { hasActiveFilters = state.hasActiveFilters, showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(), onChangeCurrentPage = { screenModel.activeCategoryIndex = it }, - onMangaClicked = { navigator.push(MangaScreen(it)) }, + onClickManga = { navigator.push(MangaScreen(it)) }, onContinueReadingClicked = { it: LibraryManga -> scope.launchIO { val chapter = screenModel.getNextUnreadChapter(it.manga) @@ -194,18 +196,19 @@ data object LibraryTab : Tab { Unit }.takeIf { state.showMangaContinueButton }, onToggleSelection = screenModel::toggleSelection, - onToggleRangeSelection = { - screenModel.toggleRangeSelection(it) + onToggleRangeSelection = { category, manga -> + screenModel.toggleRangeSelection(category, manga) haptic.performHapticFeedback(HapticFeedbackType.LongPress) }, - onRefresh = onClickRefresh, + onRefresh = { onClickRefresh(screenModel.activeCategory) }, onGlobalSearchClicked = { navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: "")) }, - getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, + getItemCountForCategory = { state.getItemCountForCategory(it) }, getDisplayMode = { screenModel.getDisplayMode() }, - getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) }, - ) { state.getLibraryItemsByPage(it) } + getColumnsForOrientation = { screenModel.getColumnsForOrientation(it) }, + getItemsForCategory = { state.getItemsForCategory(it) }, + ) } } } @@ -213,15 +216,10 @@ data object LibraryTab : Tab { val onDismissRequest = screenModel::closeDialog when (val dialog = state.dialog) { is LibraryScreenModel.Dialog.SettingsSheet -> run { - val category = state.categories.getOrNull(screenModel.activeCategoryIndex) - if (category == null) { - onDismissRequest() - return@run - } LibrarySettingsDialog( onDismissRequest = onDismissRequest, screenModel = settingsScreenModel, - category = category, + category = screenModel.activeCategory, ) } is LibraryScreenModel.Dialog.ChangeCategory -> { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt index 41f02060d..b5096814b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/stats/StatsScreenModel.kt @@ -2,11 +2,9 @@ package eu.kanade.tachiyomi.ui.stats import androidx.compose.ui.util.fastDistinctBy import androidx.compose.ui.util.fastFilter -import androidx.compose.ui.util.fastMapNotNull import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.core.util.fastCountNot -import eu.kanade.core.util.fastFilterNot import eu.kanade.presentation.more.stats.StatsScreenState import eu.kanade.presentation.more.stats.data.StatsData import eu.kanade.tachiyomi.data.download.DownloadManager @@ -88,25 +86,14 @@ class StatsScreenModel( private fun getGlobalUpdateItemCount(libraryManga: List): Int { val includedCategories = preferences.updateCategories().get().map { it.toLong() } - val includedManga = if (includedCategories.isNotEmpty()) { - libraryManga.filter { it.category in includedCategories } - } else { - libraryManga - } - val excludedCategories = preferences.updateCategoriesExclude().get().map { it.toLong() } - val excludedMangaIds = if (excludedCategories.isNotEmpty()) { - libraryManga.fastMapNotNull { manga -> - manga.id.takeIf { manga.category in excludedCategories } - } - } else { - emptyList() - } - val updateRestrictions = preferences.autoUpdateMangaRestrictions().get() - return includedManga - .fastFilterNot { it.manga.id in excludedMangaIds } - .fastDistinctBy { it.manga.id } + + return libraryManga.filter { + val included = includedCategories.isEmpty() || it.categories.intersect(includedCategories).isNotEmpty() + val excluded = it.categories.intersect(excludedCategories).isNotEmpty() + included && !excluded + } .fastCountNot { (MANGA_NON_COMPLETED in updateRestrictions && it.manga.status.toInt() == SManga.COMPLETED) || (MANGA_HAS_UNREAD in updateRestrictions && it.unreadCount != 0L) || diff --git a/core/common/src/main/kotlin/mihon/core/common/utils/Set.kt b/core/common/src/main/kotlin/mihon/core/common/utils/Set.kt new file mode 100644 index 000000000..daf31fd44 --- /dev/null +++ b/core/common/src/main/kotlin/mihon/core/common/utils/Set.kt @@ -0,0 +1,5 @@ +package mihon.core.common.utils + +fun Set.mutate(action: (MutableSet) -> Unit): Set { + return toMutableSet().apply(action) +} diff --git a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt index c2b9d648e..767f3bfc3 100644 --- a/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt +++ b/data/src/main/java/tachiyomi/data/manga/MangaMapper.kt @@ -92,7 +92,7 @@ object MangaMapper { chapterFetchedAt: Long, lastRead: Long, bookmarkCount: Double, - category: Long, + categories: String, ): LibraryManga = LibraryManga( manga = mapManga( id, @@ -121,7 +121,7 @@ object MangaMapper { isSyncing, notes, ), - category = category, + categories = categories.split(",").map { it.toLong() }, totalChapters = totalCount, readCount = readCount.toLong(), bookmarkCount = bookmarkCount.toLong(), diff --git a/data/src/main/sqldelight/tachiyomi/migrations/6.sqm b/data/src/main/sqldelight/tachiyomi/migrations/6.sqm new file mode 100644 index 000000000..fd82b7302 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/6.sqm @@ -0,0 +1,39 @@ +DROP VIEW IF EXISTS libraryView; + +CREATE VIEW libraryView AS +SELECT + M.*, + coalesce(C.total, 0) AS totalCount, + coalesce(C.readCount, 0) AS readCount, + coalesce(C.latestUpload, 0) AS latestUpload, + coalesce(C.fetchedAt, 0) AS chapterFetchedAt, + coalesce(C.lastRead, 0) AS lastRead, + coalesce(C.bookmarkCount, 0) AS bookmarkCount, + coalesce(MC.categories, '0') AS categories +FROM mangas M +LEFT JOIN ( + SELECT + chapters.manga_id, + count(*) AS total, + sum(read) AS readCount, + coalesce(max(chapters.date_upload), 0) AS latestUpload, + coalesce(max(history.last_read), 0) AS lastRead, + coalesce(max(chapters.date_fetch), 0) AS fetchedAt, + sum(chapters.bookmark) AS bookmarkCount + FROM chapters + LEFT JOIN excluded_scanlators + ON chapters.manga_id = excluded_scanlators.manga_id + AND chapters.scanlator = excluded_scanlators.scanlator + LEFT JOIN history + ON chapters._id = history.chapter_id + WHERE excluded_scanlators.scanlator IS NULL + GROUP BY chapters.manga_id +) AS C +ON M._id = C.manga_id +LEFT JOIN ( + SELECT manga_id, group_concat(category_id) AS categories + FROM mangas_categories + GROUP BY manga_id +) AS MC +ON MC.manga_id = M._id +WHERE M.favorite = 1; diff --git a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq index 0a5d28543..8ce19ae1d 100644 --- a/data/src/main/sqldelight/tachiyomi/view/libraryView.sq +++ b/data/src/main/sqldelight/tachiyomi/view/libraryView.sq @@ -7,9 +7,9 @@ SELECT coalesce(C.fetchedAt, 0) AS chapterFetchedAt, coalesce(C.lastRead, 0) AS lastRead, coalesce(C.bookmarkCount, 0) AS bookmarkCount, - coalesce(MC.category_id, 0) AS category + coalesce(MC.categories, '0') AS categories FROM mangas M -LEFT JOIN( +LEFT JOIN ( SELECT chapters.manga_id, count(*) AS total, @@ -28,7 +28,11 @@ LEFT JOIN( GROUP BY chapters.manga_id ) AS C ON M._id = C.manga_id -LEFT JOIN mangas_categories AS MC +LEFT JOIN ( + SELECT manga_id, group_concat(category_id) AS categories + FROM mangas_categories + GROUP BY manga_id +) AS MC ON MC.manga_id = M._id WHERE M.favorite = 1; diff --git a/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt b/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt index 65e06c195..2fda140aa 100644 --- a/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt +++ b/domain/src/main/java/tachiyomi/domain/library/model/LibraryManga.kt @@ -4,7 +4,7 @@ import tachiyomi.domain.manga.model.Manga data class LibraryManga( val manga: Manga, - val category: Long, + val categories: List, val totalChapters: Long, val readCount: Long, val bookmarkCount: Long,