Optimize and cleanup library code (#2329)

This commit is contained in:
AntsyLich
2025-08-02 09:04:23 +06:00
committed by GitHub
parent 1365b28106
commit e62cd0e816
18 changed files with 370 additions and 338 deletions

View File

@@ -23,6 +23,9 @@ ij_kotlin_name_count_to_use_star_import_for_members = 2147483647
ktlint_code_style = intellij_idea ktlint_code_style = intellij_idea
ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_standard_class-signature = disabled ktlint_standard_class-signature = disabled
ktlint_standard_comment-wrapping = disabled
ktlint_standard_discouraged-comment-location = disabled ktlint_standard_discouraged-comment-location = disabled
ktlint_standard_function-expression-body = disabled ktlint_standard_function-expression-body = disabled
ktlint_standard_function-signature = disabled ktlint_standard_function-signature = disabled
ktlint_standard_type-argument-comment = disabled
ktlint_standard_type-parameter-comment = disabled

View File

@@ -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 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)) - 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)) - 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 ### 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)) - 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))

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
@@ -15,7 +14,7 @@ internal fun LibraryComfortableGrid(
items: List<LibraryItem>, items: List<LibraryItem>,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: Set<Long>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?, onClickContinueReading: ((LibraryManga) -> Unit)?,
@@ -35,7 +34,7 @@ internal fun LibraryComfortableGrid(
) { libraryItem -> ) { libraryItem ->
val manga = libraryItem.libraryManga.manga val manga = libraryItem.libraryManga.manga
MangaComfortableGridItem( MangaComfortableGridItem(
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, isSelected = manga.id in selection,
title = manga.title, title = manga.title,
coverData = MangaCover( coverData = MangaCover(
mangaId = manga.id, mangaId = manga.id,

View File

@@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
@@ -16,7 +15,7 @@ internal fun LibraryCompactGrid(
showTitle: Boolean, showTitle: Boolean,
columns: Int, columns: Int,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: Set<Long>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?, onClickContinueReading: ((LibraryManga) -> Unit)?,
@@ -36,7 +35,7 @@ internal fun LibraryCompactGrid(
) { libraryItem -> ) { libraryItem ->
val manga = libraryItem.libraryManga.manga val manga = libraryItem.libraryManga.manga
MangaCompactGridItem( MangaCompactGridItem(
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, isSelected = manga.id in selection,
title = manga.title.takeIf { showTitle }, title = manga.title.takeIf { showTitle },
coverData = MangaCover( coverData = MangaCover(
mangaId = manga.id, mangaId = manga.id,

View File

@@ -29,22 +29,22 @@ import kotlin.time.Duration.Companion.seconds
fun LibraryContent( fun LibraryContent(
categories: List<Category>, categories: List<Category>,
searchQuery: String?, searchQuery: String?,
selection: List<LibraryManga>, selection: Set<Long>,
contentPadding: PaddingValues, contentPadding: PaddingValues,
currentPage: () -> Int, currentPage: () -> Int,
hasActiveFilters: Boolean, hasActiveFilters: Boolean,
showPageTabs: Boolean, showPageTabs: Boolean,
onChangeCurrentPage: (Int) -> Unit, onChangeCurrentPage: (Int) -> Unit,
onMangaClicked: (Long) -> Unit, onClickManga: (Long) -> Unit,
onContinueReadingClicked: ((LibraryManga) -> Unit)?, onContinueReadingClicked: ((LibraryManga) -> Unit)?,
onToggleSelection: (LibraryManga) -> Unit, onToggleSelection: (Category, LibraryManga) -> Unit,
onToggleRangeSelection: (LibraryManga) -> Unit, onToggleRangeSelection: (Category, LibraryManga) -> Unit,
onRefresh: (Category?) -> Boolean, onRefresh: () -> Boolean,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
getNumberOfMangaForCategory: (Category) -> Int?, getItemCountForCategory: (Category) -> Int?,
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>, getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: (Int) -> List<LibraryItem>, getItemsForCategory: (Category) -> List<LibraryItem>,
) { ) {
Column( Column(
modifier = Modifier.padding( modifier = Modifier.padding(
@@ -59,7 +59,7 @@ fun LibraryContent(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) } var isRefreshing by remember(pagerState.currentPage) { mutableStateOf(false) }
if (showPageTabs && categories.size > 1) { if (showPageTabs && categories.isNotEmpty()) {
LaunchedEffect(categories) { LaunchedEffect(categories) {
if (categories.size <= pagerState.currentPage) { if (categories.size <= pagerState.currentPage) {
pagerState.scrollToPage(categories.size - 1) pagerState.scrollToPage(categories.size - 1)
@@ -68,23 +68,20 @@ fun LibraryContent(
LibraryTabs( LibraryTabs(
categories = categories, categories = categories,
pagerState = pagerState, pagerState = pagerState,
getNumberOfMangaForCategory = getNumberOfMangaForCategory, getItemCountForCategory = getItemCountForCategory,
) { scope.launch { pagerState.animateScrollToPage(it) } } onTabItemClick = {
} scope.launch {
pagerState.animateScrollToPage(it)
val notSelectionMode = selection.isEmpty()
val onClickManga = { manga: LibraryManga ->
if (notSelectionMode) {
onMangaClicked(manga.manga.id)
} else {
onToggleSelection(manga)
} }
},
)
} }
PullRefresh( PullRefresh(
refreshing = isRefreshing, refreshing = isRefreshing,
enabled = selection.isEmpty(),
onRefresh = { onRefresh = {
val started = onRefresh(categories[currentPage()]) val started = onRefresh()
if (!started) return@PullRefresh if (!started) return@PullRefresh
scope.launch { scope.launch {
// Fake refresh status but hide it after a second as it's a long running task // Fake refresh status but hide it after a second as it's a long running task
@@ -93,19 +90,25 @@ fun LibraryContent(
isRefreshing = false isRefreshing = false
} }
}, },
enabled = notSelectionMode,
) { ) {
LibraryPager( LibraryPager(
state = pagerState, state = pagerState,
contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()), contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()),
hasActiveFilters = hasActiveFilters, hasActiveFilters = hasActiveFilters,
selectedManga = selection, selection = selection,
searchQuery = searchQuery, searchQuery = searchQuery,
onGlobalSearchClicked = onGlobalSearchClicked, onGlobalSearchClicked = onGlobalSearchClicked,
getCategoryForPage = { page -> categories[page] },
getDisplayMode = getDisplayMode, getDisplayMode = getDisplayMode,
getColumnsForOrientation = getColumnsForOrientation, getColumnsForOrientation = getColumnsForOrientation,
getLibraryForPage = getLibraryForPage, getItemsForCategory = getItemsForCategory,
onClickManga = onClickManga, onClickManga = { category, manga ->
if (selection.isNotEmpty()) {
onToggleSelection(category, manga)
} else {
onClickManga(manga.manga.id)
}
},
onLongClickManga = onToggleRangeSelection, onLongClickManga = onToggleRangeSelection,
onClickContinueReading = onContinueReadingClicked, onClickContinueReading = onContinueReadingClicked,
) )

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastAny
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.domain.manga.model.MangaCover import tachiyomi.domain.manga.model.MangaCover
@@ -18,7 +17,7 @@ import tachiyomi.presentation.core.util.plus
internal fun LibraryList( internal fun LibraryList(
items: List<LibraryItem>, items: List<LibraryItem>,
contentPadding: PaddingValues, contentPadding: PaddingValues,
selection: List<LibraryManga>, selection: Set<Long>,
onClick: (LibraryManga) -> Unit, onClick: (LibraryManga) -> Unit,
onLongClick: (LibraryManga) -> Unit, onLongClick: (LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?, onClickContinueReading: ((LibraryManga) -> Unit)?,
@@ -45,7 +44,7 @@ internal fun LibraryList(
) { libraryItem -> ) { libraryItem ->
val manga = libraryItem.libraryManga.manga val manga = libraryItem.libraryManga.manga
MangaListItem( MangaListItem(
isSelected = selection.fastAny { it.id == libraryItem.libraryManga.id }, isSelected = manga.id in selection,
title = manga.title, title = manga.title,
coverData = MangaCover( coverData = MangaCover(
mangaId = manga.id, mangaId = manga.id,

View File

@@ -20,6 +20,7 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.core.preference.PreferenceMutableState
import eu.kanade.tachiyomi.ui.library.LibraryItem import eu.kanade.tachiyomi.ui.library.LibraryItem
import tachiyomi.domain.category.model.Category
import tachiyomi.domain.library.model.LibraryDisplayMode import tachiyomi.domain.library.model.LibraryDisplayMode
import tachiyomi.domain.library.model.LibraryManga import tachiyomi.domain.library.model.LibraryManga
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
@@ -31,14 +32,15 @@ fun LibraryPager(
state: PagerState, state: PagerState,
contentPadding: PaddingValues, contentPadding: PaddingValues,
hasActiveFilters: Boolean, hasActiveFilters: Boolean,
selectedManga: List<LibraryManga>, selection: Set<Long>,
searchQuery: String?, searchQuery: String?,
onGlobalSearchClicked: () -> Unit, onGlobalSearchClicked: () -> Unit,
getCategoryForPage: (Int) -> Category,
getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>, getDisplayMode: (Int) -> PreferenceMutableState<LibraryDisplayMode>,
getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>, getColumnsForOrientation: (Boolean) -> PreferenceMutableState<Int>,
getLibraryForPage: (Int) -> List<LibraryItem>, getItemsForCategory: (Category) -> List<LibraryItem>,
onClickManga: (LibraryManga) -> Unit, onClickManga: (Category, LibraryManga) -> Unit,
onLongClickManga: (LibraryManga) -> Unit, onLongClickManga: (Category, LibraryManga) -> Unit,
onClickContinueReading: ((LibraryManga) -> Unit)?, onClickContinueReading: ((LibraryManga) -> Unit)?,
) { ) {
HorizontalPager( HorizontalPager(
@@ -50,9 +52,10 @@ fun LibraryPager(
// To make sure only one offscreen page is being composed // To make sure only one offscreen page is being composed
return@HorizontalPager return@HorizontalPager
} }
val library = getLibraryForPage(page) val category = getCategoryForPage(page)
val items = getItemsForCategory(category)
if (library.isEmpty()) { if (items.isEmpty()) {
LibraryPagerEmptyScreen( LibraryPagerEmptyScreen(
searchQuery = searchQuery, searchQuery = searchQuery,
hasActiveFilters = hasActiveFilters, hasActiveFilters = hasActiveFilters,
@@ -72,12 +75,15 @@ fun LibraryPager(
remember { mutableIntStateOf(0) } remember { mutableIntStateOf(0) }
} }
val onClickManga: (LibraryManga) -> Unit = { onClickManga(category, it) }
val onLongClickManga: (LibraryManga) -> Unit = { onLongClickManga(category, it) }
when (displayMode) { when (displayMode) {
LibraryDisplayMode.List -> { LibraryDisplayMode.List -> {
LibraryList( LibraryList(
items = library, items = items,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selection,
onClick = onClickManga, onClick = onClickManga,
onLongClick = onLongClickManga, onLongClick = onLongClickManga,
onClickContinueReading = onClickContinueReading, onClickContinueReading = onClickContinueReading,
@@ -87,11 +93,11 @@ fun LibraryPager(
} }
LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> {
LibraryCompactGrid( LibraryCompactGrid(
items = library, items = items,
showTitle = displayMode is LibraryDisplayMode.CompactGrid, showTitle = displayMode is LibraryDisplayMode.CompactGrid,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selection,
onClick = onClickManga, onClick = onClickManga,
onLongClick = onLongClickManga, onLongClick = onLongClickManga,
onClickContinueReading = onClickContinueReading, onClickContinueReading = onClickContinueReading,
@@ -101,10 +107,10 @@ fun LibraryPager(
} }
LibraryDisplayMode.ComfortableGrid -> { LibraryDisplayMode.ComfortableGrid -> {
LibraryComfortableGrid( LibraryComfortableGrid(
items = library, items = items,
columns = columns, columns = columns,
contentPadding = contentPadding, contentPadding = contentPadding,
selection = selectedManga, selection = selection,
onClick = onClickManga, onClick = onClickManga,
onLongClick = onLongClickManga, onLongClick = onLongClickManga,
onClickContinueReading = onClickContinueReading, onClickContinueReading = onClickContinueReading,

View File

@@ -18,13 +18,11 @@ import tachiyomi.presentation.core.components.material.TabText
internal fun LibraryTabs( internal fun LibraryTabs(
categories: List<Category>, categories: List<Category>,
pagerState: PagerState, pagerState: PagerState,
getNumberOfMangaForCategory: (Category) -> Int?, getItemCountForCategory: (Category) -> Int?,
onTabItemClick: (Int) -> Unit, onTabItemClick: (Int) -> Unit,
) { ) {
val currentPageIndex = pagerState.currentPage.coerceAtMost(categories.lastIndex) val currentPageIndex = pagerState.currentPage.coerceAtMost(categories.lastIndex)
Column( Column(modifier = Modifier.zIndex(2f)) {
modifier = Modifier.zIndex(1f),
) {
PrimaryScrollableTabRow( PrimaryScrollableTabRow(
selectedTabIndex = currentPageIndex, selectedTabIndex = currentPageIndex,
edgePadding = 0.dp, edgePadding = 0.dp,
@@ -39,7 +37,7 @@ internal fun LibraryTabs(
text = { text = {
TabText( TabText(
text = category.visualName, text = category.visualName,
badgeCount = getNumberOfMangaForCategory(category), badgeCount = getItemCountForCategory(category),
) )
}, },
unselectedContentColor = MaterialTheme.colorScheme.onSurface, unselectedContentColor = MaterialTheme.colorScheme.onSurface,

View File

@@ -158,25 +158,16 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet
val libraryManga = getLibraryManga.await() val libraryManga = getLibraryManga.await()
val listToUpdate = if (categoryId != -1L) { val listToUpdate = if (categoryId != -1L) {
libraryManga.filter { it.category == categoryId } libraryManga.filter { categoryId in it.categories }
} else { } else {
val categoriesToUpdate = libraryPreferences.updateCategories().get().map { it.toLong() } val includedCategories = libraryPreferences.updateCategories().get().map { it.toLong() }
val includedManga = if (categoriesToUpdate.isNotEmpty()) { val excludedCategories = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() }
libraryManga.filter { it.category in categoriesToUpdate }
} else {
libraryManga
}
val categoriesToExclude = libraryPreferences.updateCategoriesExclude().get().map { it.toLong() } libraryManga.filter {
val excludedMangaIds = if (categoriesToExclude.isNotEmpty()) { val included = includedCategories.isEmpty() || it.categories.intersect(includedCategories).isNotEmpty()
libraryManga.filter { it.category in categoriesToExclude }.map { it.manga.id } val excluded = it.categories.intersect(excludedCategories).isNotEmpty()
} else { included && !excluded
emptyList()
} }
includedManga
.filterNot { it.manga.id in excludedMangaIds }
.distinctBy { it.manga.id }
} }
val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get() val restrictions = libraryPreferences.autoUpdateMangaRestrictions().get()

View File

@@ -14,6 +14,8 @@ data class LibraryItem(
val sourceLanguage: String = "", val sourceLanguage: String = "",
private val sourceManager: SourceManager = Injekt.get(), private val sourceManager: SourceManager = Injekt.get(),
) { ) {
val id: Long = libraryManga.id
/** /**
* Checks if a query matches the manga * Checks if a query matches the manga
* *
@@ -23,8 +25,7 @@ data class LibraryItem(
fun matches(constraint: String): Boolean { fun matches(constraint: String): Boolean {
val sourceName by lazy { sourceManager.getOrStub(libraryManga.manga.source).getNameForMangaInfo() } val sourceName by lazy { sourceManager.getOrStub(libraryManga.manga.source).getNameForMangaInfo() }
if (constraint.startsWith("id:", true)) { if (constraint.startsWith("id:", true)) {
val id = constraint.substringAfter("id:").toLongOrNull() return id == constraint.substringAfter("id:").toLongOrNull()
return libraryManga.id == id
} }
return libraryManga.manga.title.contains(constraint, true) || return libraryManga.manga.title.contains(constraint, true) ||
(libraryManga.manga.author?.contains(constraint, true) ?: false) || (libraryManga.manga.author?.contains(constraint, true) ?: false) ||

View File

@@ -4,16 +4,13 @@ import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastAny
import androidx.compose.ui.util.fastDistinctBy
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.compose.ui.util.fastMapNotNull
import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.core.preference.PreferenceMutableState
import eu.kanade.core.preference.asState import eu.kanade.core.preference.asState
import eu.kanade.core.util.fastFilterNot import eu.kanade.core.util.fastFilterNot
import eu.kanade.core.util.fastPartition
import eu.kanade.domain.base.BasePreferences import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.chapter.interactor.SetReadStatus import eu.kanade.domain.chapter.interactor.SetReadStatus
import eu.kanade.domain.manga.interactor.UpdateManga 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.chapter.getNextUnread
import eu.kanade.tachiyomi.util.removeCovers import eu.kanade.tachiyomi.util.removeCovers
import kotlinx.collections.immutable.ImmutableList 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.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
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.first 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.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import mihon.core.common.utils.mutate
import tachiyomi.core.common.preference.CheckboxState import tachiyomi.core.common.preference.CheckboxState
import tachiyomi.core.common.preference.TriState import tachiyomi.core.common.preference.TriState
import tachiyomi.core.common.util.lang.compareToWithCollator import tachiyomi.core.common.util.lang.compareToWithCollator
import tachiyomi.core.common.util.lang.launchIO import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.launchNonCancellable 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.GetCategories
import tachiyomi.domain.category.interactor.SetMangaCategories import tachiyomi.domain.category.interactor.SetMangaCategories
import tachiyomi.domain.category.model.Category import tachiyomi.domain.category.model.Category
@@ -74,11 +68,6 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import kotlin.random.Random import kotlin.random.Random
/**
* Typealias for the library manga, using the category as keys, and list of manga as values.
*/
typealias LibraryMap = Map<Category, List<LibraryItem>>
class LibraryScreenModel( class LibraryScreenModel(
private val getLibraryManga: GetLibraryManga = Injekt.get(), private val getLibraryManga: GetLibraryManga = Injekt.get(),
private val getCategories: GetCategories = Injekt.get(), private val getCategories: GetCategories = Injekt.get(),
@@ -98,32 +87,52 @@ class LibraryScreenModel(
) : StateScreenModel<LibraryScreenModel.State>(State()) { ) : StateScreenModel<LibraryScreenModel.State>(State()) {
var activeCategoryIndex: Int by libraryPreferences.lastUsedCategory().asState(screenModelScope) var activeCategoryIndex: Int by libraryPreferences.lastUsedCategory().asState(screenModelScope)
val activeCategory: Category get() = state.value.displayedCategories[activeCategoryIndex]
init { init {
screenModelScope.launchIO { screenModelScope.launchIO {
combine( combine(
state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS), state.map { it.searchQuery }.distinctUntilChanged().debounce(SEARCH_DEBOUNCE_MILLIS),
getLibraryFlow(), getCategories.subscribe(),
getTracksPerManga.subscribe(), getFavoritesFlow(),
getTrackingFilterFlow(), combine(getTracksPerManga.subscribe(), getTrackingFiltersFlow(), ::Pair),
downloadCache.changes, getLibraryItemPreferencesFlow(),
) { searchQuery, library, tracks, trackingFilter, _ -> ) { searchQuery, categories, favorites, (tracksMap, trackingFilters), itemPreferences ->
library val filteredFavorites = favorites
.applyFilters(tracks, trackingFilter) .applyFilters(tracksMap, trackingFilters, itemPreferences)
.applySort(tracks, trackingFilter.keys) .let { if (searchQuery == null) it else it.filter { m -> m.matches(searchQuery) } }
.mapValues { (_, value) ->
if (searchQuery != null) { LibraryData(
value.filter { it.matches(searchQuery) } isInitialized = true,
} else { categories = categories,
value 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 { .collectLatest {
mutableState.update { state -> mutableState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
library = it, groupedFavorites = it,
) )
} }
} }
@@ -147,9 +156,8 @@ class LibraryScreenModel(
combine( combine(
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
getTrackingFilterFlow(), getTrackingFiltersFlow(),
) { prefs, trackFilter -> ) { prefs, trackFilters ->
(
listOf( listOf(
prefs.filterDownloaded, prefs.filterDownloaded,
prefs.filterUnread, prefs.filterUnread,
@@ -157,8 +165,9 @@ class LibraryScreenModel(
prefs.filterBookmarked, prefs.filterBookmarked,
prefs.filterCompleted, prefs.filterCompleted,
prefs.filterIntervalCustom, prefs.filterIntervalCustom,
) + trackFilter.values *trackFilters.values.toTypedArray(),
).any { it != TriState.DISABLED } )
.any { it != TriState.DISABLED }
} }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { .onEach {
@@ -169,19 +178,19 @@ class LibraryScreenModel(
.launchIn(screenModelScope) .launchIn(screenModelScope)
} }
private suspend fun LibraryMap.applyFilters( private fun List<LibraryItem>.applyFilters(
trackMap: Map<Long, List<Track>>, trackMap: Map<Long, List<Track>>,
trackingFilter: Map<Long, TriState>, trackingFilter: Map<Long, TriState>,
): LibraryMap { preferences: ItemPreferences,
val prefs = getLibraryItemPreferencesFlow().first() ): List<LibraryItem> {
val downloadedOnly = prefs.globalFilterDownloaded val downloadedOnly = preferences.globalFilterDownloaded
val skipOutsideReleasePeriod = prefs.skipOutsideReleasePeriod val skipOutsideReleasePeriod = preferences.skipOutsideReleasePeriod
val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else prefs.filterDownloaded val filterDownloaded = if (downloadedOnly) TriState.ENABLED_IS else preferences.filterDownloaded
val filterUnread = prefs.filterUnread val filterUnread = preferences.filterUnread
val filterStarted = prefs.filterStarted val filterStarted = preferences.filterStarted
val filterBookmarked = prefs.filterBookmarked val filterBookmarked = preferences.filterBookmarked
val filterCompleted = prefs.filterCompleted val filterCompleted = preferences.filterCompleted
val filterIntervalCustom = prefs.filterIntervalCustom val filterIntervalCustom = preferences.filterIntervalCustom
val isNotLoggedInAnyTrack = trackingFilter.isEmpty() val isNotLoggedInAnyTrack = trackingFilter.isEmpty()
@@ -225,7 +234,7 @@ class LibraryScreenModel(
if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true if (isNotLoggedInAnyTrack || trackFiltersIsIgnored) return@tracking true
val mangaTracks = trackMap val mangaTracks = trackMap
.mapValues { entry -> entry.value.map { it.trackerId } }[item.libraryManga.id] .mapValues { entry -> entry.value.map { it.trackerId } }[item.id]
.orEmpty() .orEmpty()
val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks } val isExcluded = excludedTracks.isNotEmpty() && mangaTracks.fastAny { it in excludedTracks }
@@ -234,7 +243,7 @@ class LibraryScreenModel(
!isExcluded && isIncluded !isExcluded && isIncluded
} }
val filterFn: (LibraryItem) -> Boolean = { return fastFilter {
filterFnDownloaded(it) && filterFnDownloaded(it) &&
filterFnUnread(it) && filterFnUnread(it) &&
filterFnStarted(it) && filterFnStarted(it) &&
@@ -243,13 +252,31 @@ class LibraryScreenModel(
filterFnIntervalCustom(it) && filterFnIntervalCustom(it) &&
filterFnTracking(it) filterFnTracking(it)
} }
return mapValues { (_, value) -> value.fastFilter(filterFn) }
} }
private fun LibraryMap.applySort(trackMap: Map<Long, List<Track>>, loggedInTrackerIds: Set<Long>): LibraryMap { private fun List<LibraryItem>.applyGrouping(
val sortAlphabetically: (LibraryItem, LibraryItem) -> Int = { i1, i2 -> categories: List<Category>,
i1.libraryManga.manga.title.lowercase().compareToWithCollator(i2.libraryManga.manga.title.lowercase()) ): Map<Category, List</* LibraryItem */ Long>> {
val groupCache = mutableMapOf</* Category */ Long, MutableList</* LibraryItem */ Long>>()
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<Category, List</* LibraryItem */ Long>>.applySort(
favoritesById: Map<Long, LibraryItem>,
trackMap: Map<Long, List<Track>>,
loggedInTrackerIds: Set<Long>,
): Map<Category, List</* LibraryItem */ Long>> {
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 val defaultTrackerScoreSortValue = -1.0
@@ -266,39 +293,39 @@ class LibraryScreenModel(
} }
} }
fun LibrarySort.comparator(): Comparator<LibraryItem> = Comparator { i1, i2 -> fun LibrarySort.comparator(): Comparator<LibraryItem> = Comparator { manga1, manga2 ->
when (this.type) { when (this.type) {
LibrarySort.Type.Alphabetical -> { LibrarySort.Type.Alphabetical -> {
sortAlphabetically(i1, i2) sortAlphabetically(manga1, manga2)
} }
LibrarySort.Type.LastRead -> { LibrarySort.Type.LastRead -> {
i1.libraryManga.lastRead.compareTo(i2.libraryManga.lastRead) manga1.libraryManga.lastRead.compareTo(manga2.libraryManga.lastRead)
} }
LibrarySort.Type.LastUpdate -> { 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 { LibrarySort.Type.UnreadCount -> when {
// Ensure unread content comes first // Ensure unread content comes first
i1.libraryManga.unreadCount == i2.libraryManga.unreadCount -> 0 manga1.libraryManga.unreadCount == manga2.libraryManga.unreadCount -> 0
i1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1 manga1.libraryManga.unreadCount == 0L -> if (this.isAscending) 1 else -1
i2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1 manga2.libraryManga.unreadCount == 0L -> if (this.isAscending) -1 else 1
else -> i1.libraryManga.unreadCount.compareTo(i2.libraryManga.unreadCount) else -> manga1.libraryManga.unreadCount.compareTo(manga2.libraryManga.unreadCount)
} }
LibrarySort.Type.TotalChapters -> { LibrarySort.Type.TotalChapters -> {
i1.libraryManga.totalChapters.compareTo(i2.libraryManga.totalChapters) manga1.libraryManga.totalChapters.compareTo(manga2.libraryManga.totalChapters)
} }
LibrarySort.Type.LatestChapter -> { LibrarySort.Type.LatestChapter -> {
i1.libraryManga.latestUpload.compareTo(i2.libraryManga.latestUpload) manga1.libraryManga.latestUpload.compareTo(manga2.libraryManga.latestUpload)
} }
LibrarySort.Type.ChapterFetchDate -> { LibrarySort.Type.ChapterFetchDate -> {
i1.libraryManga.chapterFetchedAt.compareTo(i2.libraryManga.chapterFetchedAt) manga1.libraryManga.chapterFetchedAt.compareTo(manga2.libraryManga.chapterFetchedAt)
} }
LibrarySort.Type.DateAdded -> { LibrarySort.Type.DateAdded -> {
i1.libraryManga.manga.dateAdded.compareTo(i2.libraryManga.manga.dateAdded) manga1.libraryManga.manga.dateAdded.compareTo(manga2.libraryManga.manga.dateAdded)
} }
LibrarySort.Type.TrackerMean -> { LibrarySort.Type.TrackerMean -> {
val item1Score = trackerScores[i1.libraryManga.id] ?: defaultTrackerScoreSortValue val item1Score = trackerScores[manga1.id] ?: defaultTrackerScoreSortValue
val item2Score = trackerScores[i2.libraryManga.id] ?: defaultTrackerScoreSortValue val item2Score = trackerScores[manga2.id] ?: defaultTrackerScoreSortValue
item1Score.compareTo(item2Score) item1Score.compareTo(item2Score)
} }
LibrarySort.Type.Random -> { LibrarySort.Type.Random -> {
@@ -312,11 +339,13 @@ class LibraryScreenModel(
return@mapValues value.shuffled(Random(libraryPreferences.randomSortSeed().get())) return@mapValues value.shuffled(Random(libraryPreferences.randomSortSeed().get()))
} }
val manga = value.mapNotNull { favoritesById[it] }
val comparator = key.sort.comparator() val comparator = key.sort.comparator()
.let { if (key.sort.isAscending) it else it.reversed() } .let { if (key.sort.isAscending) it else it.reversed() }
.thenComparator(sortAlphabetically) .thenComparator(sortAlphabetically)
value.sortedWith(comparator) manga.sortedWith(comparator).map { it.id }
} }
} }
@@ -353,45 +382,37 @@ class LibraryScreenModel(
} }
} }
/** private fun getFavoritesFlow(): Flow<List<LibraryItem>> {
* Get the categories and all its manga from the database. return combine(
*/
private fun getLibraryFlow(): Flow<LibraryMap> {
val libraryMangasFlow = combine(
getLibraryManga.subscribe(), getLibraryManga.subscribe(),
getLibraryItemPreferencesFlow(), getLibraryItemPreferencesFlow(),
downloadCache.changes, downloadCache.changes,
) { libraryMangaList, prefs, _ -> ) { libraryManga, preferences, _ ->
libraryMangaList libraryManga.map { manga ->
.map { libraryManga ->
// Display mode based on user preference: take it from global library setting or category
LibraryItem( LibraryItem(
libraryManga, libraryManga = manga,
downloadCount = if (prefs.downloadBadge) { downloadCount = if (preferences.downloadBadge) {
downloadManager.getDownloadCount(libraryManga.manga).toLong() downloadManager.getDownloadCount(manga.manga).toLong()
} else { } else {
0 0
}, },
unreadCount = if (prefs.unreadBadge) libraryManga.unreadCount else 0, unreadCount = if (preferences.unreadBadge) {
isLocal = if (prefs.localBadge) libraryManga.manga.isLocal() else false, manga.unreadCount
sourceLanguage = if (prefs.languageBadge) { } else {
sourceManager.getOrStub(libraryManga.manga.source).lang 0
},
isLocal = if (preferences.localBadge) {
manga.manga.isLocal()
} else {
false
},
sourceLanguage = if (preferences.languageBadge) {
sourceManager.getOrStub(manga.manga.source).lang
} else { } 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
}
displayCategories.associateWith { libraryManga[it.id].orEmpty() }
} }
} }
@@ -400,17 +421,15 @@ class LibraryScreenModel(
* *
* @return map of track id with the filter value * @return map of track id with the filter value
*/ */
private fun getTrackingFilterFlow(): Flow<Map<Long, TriState>> { private fun getTrackingFiltersFlow(): Flow<Map<Long, TriState>> {
return trackerManager.loggedInTrackersFlow().flatMapLatest { loggedInTrackers -> return trackerManager.loggedInTrackersFlow().flatMapLatest { loggedInTrackers ->
if (loggedInTrackers.isEmpty()) return@flatMapLatest flowOf(emptyMap()) if (loggedInTrackers.isEmpty()) {
flowOf(emptyMap())
val prefFlows = loggedInTrackers.map { tracker -> } else {
libraryPreferences.filterTracking(tracker.id.toInt()).changes() val filterFlows = loggedInTrackers.map { tracker ->
libraryPreferences.filterTracking(tracker.id.toInt()).changes().map { tracker.id to it }
} }
combine(prefFlows) { combine(filterFlows) { it.toMap() }
loggedInTrackers
.mapIndexed { index, tracker -> tracker.id to it[index] }
.toMap()
} }
} }
} }
@@ -443,26 +462,19 @@ class LibraryScreenModel(
return mangaCategories.flatten().distinct().subtract(common) return mangaCategories.flatten().distinct().subtract(common)
} }
fun runDownloadActionSelection(action: DownloadAction) { /**
val selection = state.value.selection * Queues the amount specified of unread chapters from the list of selected manga
val mangas = selection.map { it.manga }.toList() */
when (action) { fun performDownloadAction(action: DownloadAction) {
DownloadAction.NEXT_1_CHAPTER -> downloadUnreadChapters(mangas, 1) val mangas = state.value.selectedManga
DownloadAction.NEXT_5_CHAPTERS -> downloadUnreadChapters(mangas, 5) val amount = when (action) {
DownloadAction.NEXT_10_CHAPTERS -> downloadUnreadChapters(mangas, 10) DownloadAction.NEXT_1_CHAPTER -> 1
DownloadAction.NEXT_25_CHAPTERS -> downloadUnreadChapters(mangas, 25) DownloadAction.NEXT_5_CHAPTERS -> 5
DownloadAction.UNREAD_CHAPTERS -> downloadUnreadChapters(mangas, null) DownloadAction.NEXT_10_CHAPTERS -> 10
DownloadAction.NEXT_25_CHAPTERS -> 25
DownloadAction.UNREAD_CHAPTERS -> null
} }
clearSelection() 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<Manga>, amount: Int?) {
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
mangas.forEach { manga -> mangas.forEach { manga ->
val chapters = getNextChapters.await(manga.id) val chapters = getNextChapters.await(manga.id)
@@ -486,11 +498,10 @@ class LibraryScreenModel(
* Marks mangas' chapters read status. * Marks mangas' chapters read status.
*/ */
fun markReadSelection(read: Boolean) { fun markReadSelection(read: Boolean) {
val mangas = state.value.selection.toList()
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
mangas.forEach { manga -> state.value.selectedManga.forEach { manga ->
setReadStatus.await( setReadStatus.await(
manga = manga.manga, manga = manga,
read = read, read = read,
) )
} }
@@ -501,16 +512,14 @@ class LibraryScreenModel(
/** /**
* Remove the selected manga. * 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 deleteFromLibrary whether to delete manga from library.
* @param deleteChapters whether to delete downloaded chapters. * @param deleteChapters whether to delete downloaded chapters.
*/ */
fun removeMangas(mangaList: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) { fun removeMangas(mangas: List<Manga>, deleteFromLibrary: Boolean, deleteChapters: Boolean) {
screenModelScope.launchNonCancellable { screenModelScope.launchNonCancellable {
val mangaToDelete = mangaList.distinctBy { it.id }
if (deleteFromLibrary) { if (deleteFromLibrary) {
val toDelete = mangaToDelete.map { val toDelete = mangas.map {
it.removeCovers(coverCache) it.removeCovers(coverCache)
MangaUpdate( MangaUpdate(
favorite = false, favorite = false,
@@ -521,7 +530,7 @@ class LibraryScreenModel(
} }
if (deleteChapters) { if (deleteChapters) {
mangaToDelete.forEach { manga -> mangas.forEach { manga ->
val source = sourceManager.get(manga.source) as? HttpSource val source = sourceManager.get(manga.source) as? HttpSource
if (source != null) { if (source != null) {
downloadManager.deleteManga(manga, source) downloadManager.deleteManga(manga, source)
@@ -556,38 +565,32 @@ class LibraryScreenModel(
return libraryPreferences.displayMode().asState(screenModelScope) return libraryPreferences.displayMode().asState(screenModelScope)
} }
fun getColumnsPreferenceForCurrentOrientation(isLandscape: Boolean): PreferenceMutableState<Int> { fun getColumnsForOrientation(isLandscape: Boolean): PreferenceMutableState<Int> {
return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns()) return (if (isLandscape) libraryPreferences.landscapeColumns() else libraryPreferences.portraitColumns())
.asState(screenModelScope) .asState(screenModelScope)
} }
suspend fun getRandomLibraryItemForCurrentCategory(): LibraryItem? { fun getRandomLibraryItemForCurrentCategory(): LibraryItem? {
if (state.value.categories.isEmpty()) return null return state.value.getItemsForCategoryId(activeCategory.id).randomOrNull()
return withIOContext {
state.value
.getLibraryItemsByCategoryId(state.value.categories[activeCategoryIndex].id)
?.randomOrNull()
}
} }
fun showSettingsDialog() { fun showSettingsDialog() {
mutableState.update { it.copy(dialog = Dialog.SettingsSheet) } mutableState.update { it.copy(dialog = Dialog.SettingsSheet) }
} }
private var lastSelectionCategory: Long? = null
fun clearSelection() { 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 -> mutableState.update { state ->
val newSelection = state.selection.mutate { list -> val newSelection = state.selection.mutate { set ->
if (list.fastAny { it.id == manga.id }) { if (!set.remove(manga.id)) set.add(manga.id)
list.removeAll { it.id == manga.id }
} else {
list.add(manga)
}
} }
lastSelectionCategory = category.id.takeIf { newSelection.isNotEmpty() }
state.copy(selection = newSelection) 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 * Selects all mangas between and including the given manga and the last pressed manga from the
* same category as the given manga * same category as the given manga
*/ */
fun toggleRangeSelection(manga: LibraryManga) { fun toggleRangeSelection(category: Category, manga: LibraryManga) {
mutableState.update { state -> mutableState.update { state ->
val newSelection = state.selection.mutate { list -> val newSelection = state.selection.mutate { list ->
val lastSelected = list.lastOrNull() val lastSelected = list.lastOrNull()
if (lastSelected?.category != manga.category) { if (lastSelectionCategory != category.id) {
list.add(manga) list.add(manga.id)
return@mutate return@mutate
} }
val items = state.getLibraryItemsByCategoryId(manga.category) val items = state.getItemsForCategoryId(category.id).fastMap { it.id }
?.fastMap { it.libraryManga }.orEmpty()
val lastMangaIndex = items.indexOf(lastSelected) 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 { val selectionRange = when {
lastMangaIndex < curMangaIndex -> IntRange(lastMangaIndex, curMangaIndex) lastMangaIndex < curMangaIndex -> lastMangaIndex..curMangaIndex
curMangaIndex < lastMangaIndex -> IntRange(curMangaIndex, lastMangaIndex) curMangaIndex < lastMangaIndex -> curMangaIndex..lastMangaIndex
// We shouldn't reach this point // We shouldn't reach this point
else -> return@mutate else -> return@mutate
} }
val newSelections = selectionRange.mapNotNull { index -> selectionRange.mapNotNull { items[it] }.let(list::addAll)
items[index].takeUnless { it.id in selectedIds }
} }
list.addAll(newSelections) 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) state.copy(selection = newSelection)
} }
} }
fun selectAll(index: Int) { fun invertSelection() {
lastSelectionCategory = null
mutableState.update { state -> mutableState.update { state ->
val newSelection = state.selection.mutate { list -> val newSelection = state.selection.mutate { list ->
val categoryId = state.categories.getOrNull(index)?.id ?: -1 val itemIds = state.getItemsForCategoryId(activeCategory.id).fastMap { it.id }
val selectedIds = list.fastMap { it.id } val (toRemove, toAdd) = itemIds.partition { it in list }
state.getLibraryItemsByCategoryId(categoryId) list.removeAll(toRemove)
?.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 }
list.addAll(toAdd) list.addAll(toAdd)
} }
state.copy(selection = newSelection) state.copy(selection = newSelection)
@@ -663,10 +655,10 @@ class LibraryScreenModel(
fun openChangeCategoryDialog() { fun openChangeCategoryDialog() {
screenModelScope.launchIO { screenModelScope.launchIO {
// Create a copy of selected manga // 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. // 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. // Get indexes of the common categories to preselect.
val common = getCommonCategories(mangaList) val common = getCommonCategories(mangaList)
@@ -686,8 +678,7 @@ class LibraryScreenModel(
} }
fun openDeleteMangaDialog() { fun openDeleteMangaDialog() {
val mangaList = state.value.selection.map { it.manga } mutableState.update { it.copy(dialog = Dialog.DeleteManga(state.value.selectedManga)) }
mutableState.update { it.copy(dialog = Dialog.DeleteManga(mangaList)) }
} }
fun closeDialog() { fun closeDialog() {
@@ -720,41 +711,50 @@ class LibraryScreenModel(
val filterIntervalCustom: TriState, val filterIntervalCustom: TriState,
) )
@Immutable
data class LibraryData(
val isInitialized: Boolean = false,
val categories: List<Category> = emptyList(),
val favorites: List<LibraryItem> = emptyList(),
val tracksMap: Map</* Manga */ Long, List<Track>> = emptyMap(),
val loggedInTrackerIds: Set<Long> = emptySet(),
) {
val favoritesById by lazy { favorites.associateBy { it.id } }
}
@Immutable @Immutable
data class State( data class State(
val isInitialized: Boolean = false,
val isLoading: Boolean = true, val isLoading: Boolean = true,
val library: LibraryMap = emptyMap(),
val searchQuery: String? = null, val searchQuery: String? = null,
val selection: PersistentList<LibraryManga> = persistentListOf(), val selection: Set</* Manga */ Long> = setOf(),
val hasActiveFilters: Boolean = false, val hasActiveFilters: Boolean = false,
val showCategoryTabs: Boolean = false, val showCategoryTabs: Boolean = false,
val showMangaCount: Boolean = false, val showMangaCount: Boolean = false,
val showMangaContinueButton: Boolean = false, val showMangaContinueButton: Boolean = false,
val dialog: Dialog? = null, val dialog: Dialog? = null,
val libraryData: LibraryData = LibraryData(),
private val groupedFavorites: Map<Category, List</* LibraryItem */ Long>> = emptyMap(),
) { ) {
private val libraryCount by lazy { val isLibraryEmpty = libraryData.favorites.isEmpty()
library.values
.flatten()
.fastDistinctBy { it.libraryManga.manga.id }
.size
}
val isLibraryEmpty by lazy { libraryCount == 0 }
val selectionMode = selection.isNotEmpty() 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<LibraryItem>? { val displayedCategories = groupedFavorites.keys.toList()
return library.firstNotNullOfOrNull { (k, v) -> v.takeIf { k.id == categoryId } }
fun getItemsForCategoryId(categoryId: Long): List<LibraryItem> {
val category = displayedCategories.find { it.id == categoryId } ?: return emptyList()
return getItemsForCategory(category)
} }
fun getLibraryItemsByPage(page: Int): List<LibraryItem> { fun getItemsForCategory(category: Category): List<LibraryItem> {
return library.values.toTypedArray().getOrNull(page).orEmpty() return groupedFavorites[category].orEmpty().mapNotNull { libraryData.favoritesById[it] }
} }
fun getMangaCountForCategory(category: Category): Int? { fun getItemCountForCategory(category: Category): Int? {
return if (showMangaCount || !searchQuery.isNullOrEmpty()) library[category]?.size else null return if (showMangaCount || !searchQuery.isNullOrEmpty()) groupedFavorites[category]?.size else null
} }
fun getToolbarTitle( fun getToolbarTitle(
@@ -762,18 +762,17 @@ class LibraryScreenModel(
defaultCategoryTitle: String, defaultCategoryTitle: String,
page: Int, page: Int,
): LibraryToolbarTitle { ): LibraryToolbarTitle {
val category = categories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle) val category = displayedCategories.getOrNull(page) ?: return LibraryToolbarTitle(defaultTitle)
val categoryName = category.let { val categoryName = category.let {
if (it.isSystemCategory) defaultCategoryTitle else it.name if (it.isSystemCategory) defaultCategoryTitle else it.name
} }
val title = if (showCategoryTabs) defaultTitle else categoryName val title = if (showCategoryTabs) defaultTitle else categoryName
val count = when { val count = when {
!showMangaCount -> null !showMangaCount -> null
!showCategoryTabs -> getMangaCountForCategory(category) !showCategoryTabs -> getItemCountForCategory(category)
// Whole library count // Whole library count
else -> libraryCount else -> libraryData.favorites.size
} }
return LibraryToolbarTitle(title, count) return LibraryToolbarTitle(title, count)
} }
} }

View File

@@ -112,16 +112,15 @@ data object LibraryTab : Tab {
defaultCategoryTitle = stringResource(MR.strings.label_default), defaultCategoryTitle = stringResource(MR.strings.label_default),
page = screenModel.activeCategoryIndex, page = screenModel.activeCategoryIndex,
) )
val tabVisible = state.showCategoryTabs && state.categories.size > 1
LibraryToolbar( LibraryToolbar(
hasActiveFilters = state.hasActiveFilters, hasActiveFilters = state.hasActiveFilters,
selectedCount = state.selection.size, selectedCount = state.selection.size,
title = title, title = title,
onClickUnselectAll = screenModel::clearSelection, onClickUnselectAll = screenModel::clearSelection,
onClickSelectAll = { screenModel.selectAll(screenModel.activeCategoryIndex) }, onClickSelectAll = screenModel::selectAll,
onClickInvertSelection = { screenModel.invertSelection(screenModel.activeCategoryIndex) }, onClickInvertSelection = screenModel::invertSelection,
onClickFilter = screenModel::showSettingsDialog, onClickFilter = screenModel::showSettingsDialog,
onClickRefresh = { onClickRefresh(state.categories[screenModel.activeCategoryIndex]) }, onClickRefresh = { onClickRefresh(screenModel.activeCategory) },
onClickGlobalUpdate = { onClickRefresh(null) }, onClickGlobalUpdate = { onClickRefresh(null) },
onClickOpenRandomManga = { onClickOpenRandomManga = {
scope.launch { scope.launch {
@@ -137,7 +136,8 @@ data object LibraryTab : Tab {
}, },
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
onSearchQueryChange = screenModel::search, 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 = { bottomBar = {
@@ -146,15 +146,17 @@ data object LibraryTab : Tab {
onChangeCategoryClicked = screenModel::openChangeCategoryDialog, onChangeCategoryClicked = screenModel::openChangeCategoryDialog,
onMarkAsReadClicked = { screenModel.markReadSelection(true) }, onMarkAsReadClicked = { screenModel.markReadSelection(true) },
onMarkAsUnreadClicked = { screenModel.markReadSelection(false) }, onMarkAsUnreadClicked = { screenModel.markReadSelection(false) },
onDownloadClicked = screenModel::runDownloadActionSelection onDownloadClicked = screenModel::performDownloadAction
.takeIf { state.selection.fastAll { !it.manga.isLocal() } }, .takeIf { state.selectedManga.fastAll { !it.isLocal() } },
onDeleteClicked = screenModel::openDeleteMangaDialog, onDeleteClicked = screenModel::openDeleteMangaDialog,
) )
}, },
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
) { contentPadding -> ) { contentPadding ->
when { when {
state.isLoading -> LoadingScreen(Modifier.padding(contentPadding)) state.isLoading -> {
LoadingScreen(Modifier.padding(contentPadding))
}
state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> { state.searchQuery.isNullOrEmpty() && !state.hasActiveFilters && state.isLibraryEmpty -> {
val handler = LocalUriHandler.current val handler = LocalUriHandler.current
EmptyScreen( EmptyScreen(
@@ -171,7 +173,7 @@ data object LibraryTab : Tab {
} }
else -> { else -> {
LibraryContent( LibraryContent(
categories = state.categories, categories = state.displayedCategories,
searchQuery = state.searchQuery, searchQuery = state.searchQuery,
selection = state.selection, selection = state.selection,
contentPadding = contentPadding, contentPadding = contentPadding,
@@ -179,7 +181,7 @@ data object LibraryTab : Tab {
hasActiveFilters = state.hasActiveFilters, hasActiveFilters = state.hasActiveFilters,
showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(), showPageTabs = state.showCategoryTabs || !state.searchQuery.isNullOrEmpty(),
onChangeCurrentPage = { screenModel.activeCategoryIndex = it }, onChangeCurrentPage = { screenModel.activeCategoryIndex = it },
onMangaClicked = { navigator.push(MangaScreen(it)) }, onClickManga = { navigator.push(MangaScreen(it)) },
onContinueReadingClicked = { it: LibraryManga -> onContinueReadingClicked = { it: LibraryManga ->
scope.launchIO { scope.launchIO {
val chapter = screenModel.getNextUnreadChapter(it.manga) val chapter = screenModel.getNextUnreadChapter(it.manga)
@@ -194,18 +196,19 @@ data object LibraryTab : Tab {
Unit Unit
}.takeIf { state.showMangaContinueButton }, }.takeIf { state.showMangaContinueButton },
onToggleSelection = screenModel::toggleSelection, onToggleSelection = screenModel::toggleSelection,
onToggleRangeSelection = { onToggleRangeSelection = { category, manga ->
screenModel.toggleRangeSelection(it) screenModel.toggleRangeSelection(category, manga)
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
}, },
onRefresh = onClickRefresh, onRefresh = { onClickRefresh(screenModel.activeCategory) },
onGlobalSearchClicked = { onGlobalSearchClicked = {
navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: "")) navigator.push(GlobalSearchScreen(screenModel.state.value.searchQuery ?: ""))
}, },
getNumberOfMangaForCategory = { state.getMangaCountForCategory(it) }, getItemCountForCategory = { state.getItemCountForCategory(it) },
getDisplayMode = { screenModel.getDisplayMode() }, getDisplayMode = { screenModel.getDisplayMode() },
getColumnsForOrientation = { screenModel.getColumnsPreferenceForCurrentOrientation(it) }, getColumnsForOrientation = { screenModel.getColumnsForOrientation(it) },
) { state.getLibraryItemsByPage(it) } getItemsForCategory = { state.getItemsForCategory(it) },
)
} }
} }
} }
@@ -213,15 +216,10 @@ data object LibraryTab : Tab {
val onDismissRequest = screenModel::closeDialog val onDismissRequest = screenModel::closeDialog
when (val dialog = state.dialog) { when (val dialog = state.dialog) {
is LibraryScreenModel.Dialog.SettingsSheet -> run { is LibraryScreenModel.Dialog.SettingsSheet -> run {
val category = state.categories.getOrNull(screenModel.activeCategoryIndex)
if (category == null) {
onDismissRequest()
return@run
}
LibrarySettingsDialog( LibrarySettingsDialog(
onDismissRequest = onDismissRequest, onDismissRequest = onDismissRequest,
screenModel = settingsScreenModel, screenModel = settingsScreenModel,
category = category, category = screenModel.activeCategory,
) )
} }
is LibraryScreenModel.Dialog.ChangeCategory -> { is LibraryScreenModel.Dialog.ChangeCategory -> {

View File

@@ -2,11 +2,9 @@ package eu.kanade.tachiyomi.ui.stats
import androidx.compose.ui.util.fastDistinctBy import androidx.compose.ui.util.fastDistinctBy
import androidx.compose.ui.util.fastFilter 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.StateScreenModel
import cafe.adriel.voyager.core.model.screenModelScope import cafe.adriel.voyager.core.model.screenModelScope
import eu.kanade.core.util.fastCountNot 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.StatsScreenState
import eu.kanade.presentation.more.stats.data.StatsData import eu.kanade.presentation.more.stats.data.StatsData
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
@@ -88,25 +86,14 @@ class StatsScreenModel(
private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): Int { private fun getGlobalUpdateItemCount(libraryManga: List<LibraryManga>): Int {
val includedCategories = preferences.updateCategories().get().map { it.toLong() } 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 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() val updateRestrictions = preferences.autoUpdateMangaRestrictions().get()
return includedManga
.fastFilterNot { it.manga.id in excludedMangaIds } return libraryManga.filter {
.fastDistinctBy { it.manga.id } val included = includedCategories.isEmpty() || it.categories.intersect(includedCategories).isNotEmpty()
val excluded = it.categories.intersect(excludedCategories).isNotEmpty()
included && !excluded
}
.fastCountNot { .fastCountNot {
(MANGA_NON_COMPLETED in updateRestrictions && it.manga.status.toInt() == SManga.COMPLETED) || (MANGA_NON_COMPLETED in updateRestrictions && it.manga.status.toInt() == SManga.COMPLETED) ||
(MANGA_HAS_UNREAD in updateRestrictions && it.unreadCount != 0L) || (MANGA_HAS_UNREAD in updateRestrictions && it.unreadCount != 0L) ||

View File

@@ -0,0 +1,5 @@
package mihon.core.common.utils
fun <T> Set<T>.mutate(action: (MutableSet<T>) -> Unit): Set<T> {
return toMutableSet().apply(action)
}

View File

@@ -92,7 +92,7 @@ object MangaMapper {
chapterFetchedAt: Long, chapterFetchedAt: Long,
lastRead: Long, lastRead: Long,
bookmarkCount: Double, bookmarkCount: Double,
category: Long, categories: String,
): LibraryManga = LibraryManga( ): LibraryManga = LibraryManga(
manga = mapManga( manga = mapManga(
id, id,
@@ -121,7 +121,7 @@ object MangaMapper {
isSyncing, isSyncing,
notes, notes,
), ),
category = category, categories = categories.split(",").map { it.toLong() },
totalChapters = totalCount, totalChapters = totalCount,
readCount = readCount.toLong(), readCount = readCount.toLong(),
bookmarkCount = bookmarkCount.toLong(), bookmarkCount = bookmarkCount.toLong(),

View File

@@ -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;

View File

@@ -7,7 +7,7 @@ SELECT
coalesce(C.fetchedAt, 0) AS chapterFetchedAt, coalesce(C.fetchedAt, 0) AS chapterFetchedAt,
coalesce(C.lastRead, 0) AS lastRead, coalesce(C.lastRead, 0) AS lastRead,
coalesce(C.bookmarkCount, 0) AS bookmarkCount, coalesce(C.bookmarkCount, 0) AS bookmarkCount,
coalesce(MC.category_id, 0) AS category coalesce(MC.categories, '0') AS categories
FROM mangas M FROM mangas M
LEFT JOIN ( LEFT JOIN (
SELECT SELECT
@@ -28,7 +28,11 @@ LEFT JOIN(
GROUP BY chapters.manga_id GROUP BY chapters.manga_id
) AS C ) AS C
ON M._id = C.manga_id 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 ON MC.manga_id = M._id
WHERE M.favorite = 1; WHERE M.favorite = 1;

View File

@@ -4,7 +4,7 @@ import tachiyomi.domain.manga.model.Manga
data class LibraryManga( data class LibraryManga(
val manga: Manga, val manga: Manga,
val category: Long, val categories: List<Long>,
val totalChapters: Long, val totalChapters: Long,
val readCount: Long, val readCount: Long,
val bookmarkCount: Long, val bookmarkCount: Long,