From bdd8a652c2836af0a0302c3927f58497f018cbc0 Mon Sep 17 00:00:00 2001 From: "Cuong M. Tran" Date: Fri, 17 May 2024 11:27:18 +0700 Subject: [PATCH] A dedicated screen for related entries, fetching directly from sources * Faster loading of related entries * Enable fetching related entries from site's entry page (things like "More like this", "Recommendations" etc.) * Improve & fix bugs of algorithm looking for related entries (cherry picked from commit 54d91c3f71557becd4fc22ec8500e05f79dc48d8) --- .../source/service/SourcePreferences.kt | 2 +- .../presentation/browse/GlobalSearchScreen.kt | 3 +- .../browse/MigrateSearchScreen.kt | 3 +- .../browse/RelatedMangasScreen.kt | 176 ++++++++++++++++++ .../presentation/browse/SourceFeedScreen.kt | 3 +- .../components/BrowseSourceComfortableGrid.kt | 2 +- .../components/BrowseSourceCompactGrid.kt | 2 +- .../browse/components/BrowseSourceList.kt | 2 +- .../browse/components/GlobalSearchCardRow.kt | 4 +- .../RelatedMangasComfortableGrid.kt | 80 ++++++++ .../components/RelatedMangasCompactGrid.kt | 80 ++++++++ .../browse/components/RelatedMangasList.kt | 75 ++++++++ .../presentation/components/TabbedScreen.kt | 3 +- .../kanade/presentation/manga/MangaScreen.kt | 87 +++++---- .../manga/components/RelatedMangas.kt | 101 ++++++++++ .../tachiyomi/data/download/DownloadCache.kt | 1 + .../ui/browse/RelatedMangasScreen.kt | 160 ++++++++++++++++ .../migration/search/SourceSearchScreen.kt | 3 +- .../source/browse/BrowseSourceScreen.kt | 3 +- .../source/browse/BrowseSourceScreenModel.kt | 2 +- .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 14 ++ .../tachiyomi/ui/manga/MangaScreenModel.kt | 135 +++++++++++--- .../exh/md/follows/MangaDexFollowsScreen.kt | 3 +- .../exh/md/similar/MangaDexSimilarScreen.kt | 3 +- .../main/java/exh/recs/RecommendsScreen.kt | 7 +- app/src/main/res/raw/changelog_debug.xml | 8 +- app/src/main/res/raw/changelog_release.xml | 6 + .../domain/source/model/StubSource.kt | 6 +- .../commonMain/resources/MR/base/strings.xml | 4 +- .../tachiyomi/source/CatalogueSource.kt | 131 +++++++++++++ .../eu/kanade/tachiyomi/source/Source.kt | 8 +- .../tachiyomi/source/online/HttpSource.kt | 108 ++--------- 32 files changed, 1034 insertions(+), 191 deletions(-) create mode 100644 app/src/main/java/eu/kanade/presentation/browse/RelatedMangasScreen.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasComfortableGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasCompactGrid.kt create mode 100644 app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasList.kt create mode 100644 app/src/main/java/eu/kanade/presentation/manga/components/RelatedMangas.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/browse/RelatedMangasScreen.kt diff --git a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt index 9c2413d96..8ba39ed14 100644 --- a/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt +++ b/app/src/main/java/eu/kanade/domain/source/service/SourcePreferences.kt @@ -84,6 +84,6 @@ class SourcePreferences( // SY <-- // KMK --> - fun relatedMangas() = preferenceStore.getBoolean("related_mangas", false) + fun relatedMangas() = preferenceStore.getBoolean("related_mangas", true) // KMK <-- } diff --git a/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt index 7f254b6fd..e17e6d15a 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/GlobalSearchScreen.kt @@ -58,8 +58,9 @@ fun GlobalSearchScreen( when (result) { is SearchItemResult.Success -> { result.result.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } } else -> {} diff --git a/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt index 440075d99..3624c73ad 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/MigrateSearchScreen.kt @@ -48,8 +48,9 @@ fun MigrateSearchScreen( when (result) { is SearchItemResult.Success -> { result.result.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } } else -> {} diff --git a/app/src/main/java/eu/kanade/presentation/browse/RelatedMangasScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/RelatedMangasScreen.kt new file mode 100644 index 000000000..902671e5d --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/RelatedMangasScreen.kt @@ -0,0 +1,176 @@ +package eu.kanade.presentation.browse + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForward +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.browse.components.RelatedMangasComfortableGrid +import eu.kanade.presentation.browse.components.RelatedMangasCompactGrid +import eu.kanade.presentation.browse.components.RelatedMangasList +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import exh.util.capitalize +import tachiyomi.domain.library.model.LibraryDisplayMode +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.screens.EmptyScreen +import tachiyomi.presentation.core.screens.LoadingScreen + +@Composable +fun RelatedMangasContent( + relatedMangas: List?, + getMangaState: @Composable (Manga) -> State, + columns: GridCells, + displayMode: LibraryDisplayMode, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, + onKeywordClick: (String) -> Unit, + onKeywordLongClick: (String) -> Unit, + selection: List, +) { + if (relatedMangas == null) { + LoadingScreen( + modifier = Modifier.padding(contentPadding), + ) + return + } + + if (relatedMangas.isEmpty()) { + EmptyScreen( + modifier = Modifier.padding(contentPadding), + message = stringResource(MR.strings.no_results_found), + ) + return + } + + when (displayMode) { + LibraryDisplayMode.ComfortableGrid -> { + RelatedMangasComfortableGrid( + relatedMangas = relatedMangas, + getManga = getMangaState, + columns = columns, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + onKeywordClick = onKeywordClick, + onKeywordLongClick = onKeywordLongClick, + selection = selection, + ) + } + LibraryDisplayMode.List -> { + RelatedMangasList( + relatedMangas = relatedMangas, + getManga = getMangaState, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + onKeywordClick = onKeywordClick, + onKeywordLongClick = onKeywordLongClick, + selection = selection, + ) + } + LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { + RelatedMangasCompactGrid( + relatedMangas = relatedMangas, + getManga = getMangaState, + columns = columns, + contentPadding = contentPadding, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + onKeywordClick = onKeywordClick, + onKeywordLongClick = onKeywordLongClick, + selection = selection, + ) + } + } +} + +@Composable +fun RelatedMangaTitle( + title: String, + subtitle: String?, + onClick: () -> Unit, + onLongClick: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .padding( + start = MaterialTheme.padding.medium, + end = MaterialTheme.padding.extraSmall, + ) + .fillMaxWidth() + .let { + if (onLongClick == null) { + it.clickable(onClick = onClick) + } else { + it.combinedClickable(onClick = onClick, onLongClick = onLongClick) + } + }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.padding( + top = MaterialTheme.padding.small, + bottom = MaterialTheme.padding.extraSmall, + ), + ) { + Text( + text = title.capitalize(), + style = MaterialTheme.typography.titleMedium, + ) + if (subtitle != null) { + Text(text = subtitle) + } + } + IconButton(onClick = onClick) { + Icon(imageVector = Icons.AutoMirrored.Outlined.ArrowForward, contentDescription = null) + } + } +} + +fun LazyGridScope.header( + content: @Composable LazyGridItemScope.() -> Unit, +) { + item(span = { GridItemSpan(this.maxLineSpan) }, content = content) +} + +@Composable +fun RelatedMangasLoadingItem() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(MaterialTheme.padding.medium), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier + .size(24.dp), + strokeWidth = 2.dp, + ) + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt index 621d645f5..b86c50e0b 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/SourceFeedScreen.kt @@ -131,8 +131,9 @@ fun SourceFeedScreen( onSelectAll = { items.forEach { it.results?.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } } }, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt index 27c7d81b2..995684017 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceComfortableGrid.kt @@ -79,7 +79,7 @@ fun BrowseSourceComfortableGrid( } @Composable -private fun BrowseSourceComfortableGridItem( +internal fun BrowseSourceComfortableGridItem( manga: Manga, // SY --> metadata: RaisedSearchMetadata?, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt index 8998dbd6b..85b691951 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceCompactGrid.kt @@ -79,7 +79,7 @@ fun BrowseSourceCompactGrid( } @Composable -private fun BrowseSourceCompactGridItem( +internal fun BrowseSourceCompactGridItem( manga: Manga, // SY --> metadata: RaisedSearchMetadata?, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index 012c39e63..228ab1203 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -72,7 +72,7 @@ fun BrowseSourceList( } @Composable -private fun BrowseSourceListItem( +internal fun BrowseSourceListItem( manga: Manga, // SY --> metadata: RaisedSearchMetadata?, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt index e23f50604..4f868ff34 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/GlobalSearchCardRow.kt @@ -60,7 +60,7 @@ fun GlobalSearchCardRow( } @Composable -private fun MangaItem( +internal fun MangaItem( title: String, cover: MangaCover, isFavorite: Boolean, @@ -92,7 +92,7 @@ private fun MangaItem( } @Composable -private fun EmptyResultItem() { +internal fun EmptyResultItem() { Text( text = stringResource(MR.strings.no_results_found), modifier = Modifier diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasComfortableGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasComfortableGrid.kt new file mode 100644 index 000000000..daad7929b --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasComfortableGrid.kt @@ -0,0 +1,80 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import eu.kanade.presentation.browse.RelatedMangaTitle +import eu.kanade.presentation.browse.RelatedMangasLoadingItem +import eu.kanade.presentation.browse.header +import eu.kanade.presentation.library.components.CommonMangaItemDefaults +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.plus + +@Composable +fun RelatedMangasComfortableGrid( + relatedMangas: List, + getManga: @Composable (Manga) -> State, + columns: GridCells, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, + onKeywordClick: (String) -> Unit, + onKeywordLongClick: (String) -> Unit, + selection: List, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = contentPadding + PaddingValues(MaterialTheme.padding.small), + verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer), + horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer), + ) { + relatedMangas.forEach { + val isLoading = it is RelatedManga.Loading + if (isLoading) { + header { + RelatedMangaTitle( + title = stringResource(MR.strings.loading), + subtitle = null, + onClick = {}, + onLongClick = null, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + header { RelatedMangasLoadingItem() } + } else { + val relatedManga = it as RelatedManga.Success + header { + RelatedMangaTitle( + title = relatedManga.keyword, + subtitle = null, + onClick = { onKeywordClick(relatedManga.keyword) }, + onLongClick = { onKeywordLongClick(relatedManga.keyword) }, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + items(count = relatedManga.mangaList.size) { index -> + val manga by getManga(relatedManga.mangaList[index]) + BrowseSourceComfortableGridItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + isSelected = selection.fastAny { selected -> selected.id == manga.id }, + metadata = null, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasCompactGrid.kt b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasCompactGrid.kt new file mode 100644 index 000000000..4affe921a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasCompactGrid.kt @@ -0,0 +1,80 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import eu.kanade.presentation.browse.RelatedMangaTitle +import eu.kanade.presentation.browse.RelatedMangasLoadingItem +import eu.kanade.presentation.browse.header +import eu.kanade.presentation.library.components.CommonMangaItemDefaults +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.plus + +@Composable +fun RelatedMangasCompactGrid( + relatedMangas: List, + getManga: @Composable (Manga) -> State, + columns: GridCells, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, + onKeywordClick: (String) -> Unit, + onKeywordLongClick: (String) -> Unit, + selection: List, +) { + LazyVerticalGrid( + columns = columns, + contentPadding = contentPadding + PaddingValues(MaterialTheme.padding.small), + verticalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridVerticalSpacer), + horizontalArrangement = Arrangement.spacedBy(CommonMangaItemDefaults.GridHorizontalSpacer), + ) { + relatedMangas.forEach { + val isLoading = it is RelatedManga.Loading + if (isLoading) { + header { + RelatedMangaTitle( + title = stringResource(MR.strings.loading), + subtitle = null, + onClick = {}, + onLongClick = null, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + header { RelatedMangasLoadingItem() } + } else { + val relatedManga = it as RelatedManga.Success + header { + RelatedMangaTitle( + title = relatedManga.keyword, + subtitle = null, + onClick = { onKeywordClick(relatedManga.keyword) }, + onLongClick = { onKeywordLongClick(relatedManga.keyword) }, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + items(count = relatedManga.mangaList.size) { index -> + val manga by getManga(relatedManga.mangaList[index]) + BrowseSourceCompactGridItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + isSelected = selection.fastAny { selected -> selected.id == manga.id }, + metadata = null, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasList.kt new file mode 100644 index 000000000..c984b3bc1 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/browse/components/RelatedMangasList.kt @@ -0,0 +1,75 @@ +package eu.kanade.presentation.browse.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.util.fastAny +import eu.kanade.presentation.browse.RelatedMangaTitle +import eu.kanade.presentation.browse.RelatedMangasLoadingItem +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.MR +import tachiyomi.presentation.core.components.material.padding +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun RelatedMangasList( + relatedMangas: List, + getManga: @Composable (Manga) -> State, + contentPadding: PaddingValues, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, + onKeywordClick: (String) -> Unit, + onKeywordLongClick: (String) -> Unit, + selection: List, +) { + LazyColumn( + modifier = Modifier.padding( + top = contentPadding.calculateTopPadding(), + ), + contentPadding = PaddingValues(MaterialTheme.padding.small), + ) { + relatedMangas.forEach { + val isLoading = it is RelatedManga.Loading + if (isLoading) { + stickyHeader { + RelatedMangaTitle( + title = stringResource(MR.strings.loading), + subtitle = null, + onClick = {}, + onLongClick = null, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + item { RelatedMangasLoadingItem() } + } else { + val relatedManga = it as RelatedManga.Success + stickyHeader { + RelatedMangaTitle( + title = relatedManga.keyword, + subtitle = null, + onClick = { onKeywordClick(relatedManga.keyword) }, + onLongClick = { onKeywordLongClick(relatedManga.keyword) }, + modifier = Modifier.background(MaterialTheme.colorScheme.background), + ) + } + items(count = relatedManga.mangaList.size) { index -> + val manga by getManga(relatedManga.mangaList[index]) + BrowseSourceListItem( + manga = manga, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + isSelected = selection.fastAny { selected -> selected.id == manga.id }, + metadata = null, + ) + } + } + } + } +} diff --git a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt index adddec7b7..78d5fee08 100644 --- a/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/components/TabbedScreen.kt @@ -73,8 +73,9 @@ fun TabbedScreen( onSelectAll = { feedState.items?.forEach { it.results?.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } } }, diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index dcd5f261b..e5a458991 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -50,8 +50,8 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap -import eu.kanade.presentation.browse.components.GlobalSearchCardRow -import eu.kanade.presentation.browse.components.GlobalSearchResultItem +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.browse.RelatedMangaTitle import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.manga.components.ChapterDownloadAction import eu.kanade.presentation.manga.components.ChapterHeader @@ -65,6 +65,7 @@ import eu.kanade.presentation.manga.components.MangaToolbar import eu.kanade.presentation.manga.components.MissingChapterCountListItem import eu.kanade.presentation.manga.components.PagePreviewItems import eu.kanade.presentation.manga.components.PagePreviews +import eu.kanade.presentation.manga.components.RelatedMangas import eu.kanade.presentation.manga.components.SearchMetadataChips import eu.kanade.presentation.util.formatChapterNumber import eu.kanade.tachiyomi.data.download.model.Download @@ -99,6 +100,7 @@ import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR +import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.TwoPanelBox import tachiyomi.presentation.core.components.VerticalFastScroller import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton @@ -107,6 +109,8 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.shouldExpandFAB import tachiyomi.source.local.isLocal +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime @@ -171,7 +175,8 @@ fun MangaScreen( onInvertSelection: () -> Unit, // KMK --> - getMangaState: @Composable ((Manga) -> State), + getMangaState: @Composable (Manga) -> State, + onRelatedMangasScreenClick: () -> Unit, onRelatedMangaClick: (Manga) -> Unit, onRelatedMangaLongClick: (Manga) -> Unit, onSourceClick: () -> Unit, @@ -231,6 +236,7 @@ fun MangaScreen( onInvertSelection = onInvertSelection, // KMK --> getMangaState = getMangaState, + onRelatedMangasScreenClick = onRelatedMangasScreenClick, onRelatedMangaClick = onRelatedMangaClick, onRelatedMangaLongClick = onRelatedMangaLongClick, onSourceClick = onSourceClick, @@ -283,6 +289,7 @@ fun MangaScreen( onInvertSelection = onInvertSelection, // KMK --> getMangaState = getMangaState, + onRelatedMangasScreenClick = onRelatedMangasScreenClick, onRelatedMangaClick = onRelatedMangaClick, onRelatedMangaLongClick = onRelatedMangaLongClick, onSourceClick = onSourceClick, @@ -352,6 +359,7 @@ private fun MangaScreenSmallImpl( // KMK --> getMangaState: @Composable ((Manga) -> State), + onRelatedMangasScreenClick: () -> Unit, onRelatedMangaClick: (Manga) -> Unit, onRelatedMangaLongClick: (Manga) -> Unit, onSourceClick: () -> Unit, @@ -372,6 +380,9 @@ private fun MangaScreenSmallImpl( mutableStateOf(Dp.Hairline) } // SY <-- + // KMK --> + val relatedMangasEnabled = Injekt.get().relatedMangas().get() + // KMK <-- val internalOnBackPressed = { if (isAnySelected) { @@ -566,22 +577,23 @@ private fun MangaScreenSmallImpl( } // KMK --> - if (state.relatedMangas?.isNotEmpty() == true) { + if (state.source !is StubSource && relatedMangasEnabled) { item( key = MangaScreenItem.RELATED_TITLES, contentType = MangaScreenItem.RELATED_TITLES, ) { - GlobalSearchResultItem( - title = "Related titles", - subtitle = null, - onLongClick = null, - onClick = { /* Should show a page with grid/list of all the related manga */ }, - ) { + Column { + RelatedMangaTitle( + title = stringResource(SYMR.strings.pref_source_related_mangas), + subtitle = null, + onClick = onRelatedMangasScreenClick, + onLongClick = null, + ) RelatedMangas( - mangas = state.relatedMangas.map { it.value }, - getMangaState = { getMangaState(it) }, - onClickManga = onRelatedMangaClick, - onLongClickManga = onRelatedMangaLongClick, + relatedMangas = state.relatedMangasSorted, + getMangaState = getMangaState, + onMangaClick = onRelatedMangaClick, + onMangaLongClick = onRelatedMangaLongClick ) } } @@ -651,7 +663,7 @@ private fun MangaScreenSmallImpl( } @Composable -fun MangaScreenLargeImpl( +private fun MangaScreenLargeImpl( state: MangaScreenModel.State.Success, snackbarHostState: SnackbarHostState, nextUpdate: Instant?, @@ -711,6 +723,7 @@ fun MangaScreenLargeImpl( // KMK --> getMangaState: @Composable ((Manga) -> State), + onRelatedMangasScreenClick: () -> Unit, onRelatedMangaClick: (Manga) -> Unit, onRelatedMangaLongClick: (Manga) -> Unit, onSourceClick: () -> Unit, @@ -730,6 +743,9 @@ fun MangaScreenLargeImpl( // SY --> val metadataDescription = metadataDescription(state.source) // SY <-- + // KMK --> + val relatedMangasEnabled = Injekt.get().relatedMangas().get() + // KMK <-- val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() var topBarHeight by remember { mutableIntStateOf(0) } @@ -928,22 +944,23 @@ fun MangaScreenLargeImpl( ), ) { // KMK --> - if (state.relatedMangas?.isNotEmpty() == true) { + if (state.source !is StubSource && relatedMangasEnabled) { item( key = MangaScreenItem.RELATED_TITLES, contentType = MangaScreenItem.RELATED_TITLES, ) { - GlobalSearchResultItem( - title = "Related titles", - subtitle = null, - onLongClick = null, - onClick = { /* Should show a page with grid/list of all the related manga */ }, - ) { + Column { + RelatedMangaTitle( + title = stringResource(SYMR.strings.pref_source_related_mangas), + subtitle = null, + onClick = onRelatedMangasScreenClick, + onLongClick = null, + ) RelatedMangas( - mangas = state.relatedMangas.map { it.value }, - getMangaState = { getMangaState(it) }, - onClickManga = onRelatedMangaClick, - onLongClickManga = onRelatedMangaLongClick, + relatedMangas = state.relatedMangasSorted, + getMangaState = getMangaState, + onMangaClick = onRelatedMangaClick, + onMangaLongClick = onRelatedMangaLongClick ) } } @@ -987,24 +1004,6 @@ fun MangaScreenLargeImpl( } } -// KMK --> -@Composable -fun RelatedMangas( - mangas: List, - getMangaState: @Composable ((Manga) -> State), - onClickManga: (Manga) -> Unit, - onLongClickManga: (Manga) -> Unit, -) { - GlobalSearchCardRow( - titles = mangas, - getManga = getMangaState, - onClick = onClickManga, - onLongClick = onLongClickManga, - selection = emptyList(), - ) -} -// KMK <-- - @Composable private fun SharedMangaBottomActionMenu( selected: List, diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/RelatedMangas.kt b/app/src/main/java/eu/kanade/presentation/manga/components/RelatedMangas.kt new file mode 100644 index 000000000..acad1d7ad --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/manga/components/RelatedMangas.kt @@ -0,0 +1,101 @@ +package eu.kanade.presentation.manga.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import eu.kanade.presentation.browse.components.EmptyResultItem +import eu.kanade.presentation.browse.components.GlobalSearchLoadingResultItem +import eu.kanade.presentation.browse.components.MangaItem +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.manga.model.asMangaCover +import tachiyomi.presentation.core.components.material.padding + +@Composable +fun RelatedMangas( + relatedMangas: List?, + getMangaState: @Composable (Manga) -> State, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + when { + relatedMangas == null -> { + GlobalSearchLoadingResultItem() + } + + relatedMangas.isNotEmpty() -> { + RelatedMangaCardRow( + relatedMangas = relatedMangas, + getManga = { getMangaState(it) }, + onMangaClick = onMangaClick, + onMangaLongClick = onMangaLongClick, + ) + } + + else -> { EmptyResultItem() } + } +} + +@Composable +fun RelatedMangaCardRow( + relatedMangas: List, + getManga: @Composable (Manga) -> State, + onMangaClick: (Manga) -> Unit, + onMangaLongClick: (Manga) -> Unit, +) { + val mangas = relatedMangas.filterIsInstance().map { it.mangaList }.flatten() + val loading = relatedMangas.filterIsInstance().firstOrNull() + + LazyRow( + contentPadding = PaddingValues(MaterialTheme.padding.small), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), + ) { + items(mangas) { + val manga by getManga(it) + MangaItem( + title = manga.title, + cover = manga.asMangaCover(), + isFavorite = manga.favorite, + onClick = { onMangaClick(manga) }, + onLongClick = { onMangaLongClick(manga) }, + isSelected = false, + ) + } + if (loading != null) { + item { + RelatedMangaLoadingItem() + } + } + } +} + +@Composable +fun RelatedMangaLoadingItem() { + Box( + modifier = Modifier + .width(96.dp) + .aspectRatio(MangaCover.Book.ratio) + .padding(vertical = MaterialTheme.padding.medium), + ) { + CircularProgressIndicator( + modifier = Modifier + .size(16.dp) + .align(Alignment.Center), + strokeWidth = 2.dp, + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt index ef8bec2b7..98fbca5ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadCache.kt @@ -71,6 +71,7 @@ class DownloadCache( ) { private val scope = CoroutineScope(Dispatchers.IO) + // KMK --> private val downloadPreferences: DownloadPreferences = Injekt.get() // KMK <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/RelatedMangasScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/RelatedMangasScreen.kt new file mode 100644 index 000000000..1e4e85916 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/RelatedMangasScreen.kt @@ -0,0 +1,160 @@ +package eu.kanade.tachiyomi.ui.browse + +import android.content.res.Configuration +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.core.preference.asState +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.browse.RelatedMangasContent +import eu.kanade.presentation.browse.components.BrowseSourceSimpleToolbar +import eu.kanade.presentation.components.SelectionToolbar +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.ui.manga.MangaScreen +import eu.kanade.tachiyomi.ui.manga.MangaScreenModel +import eu.kanade.tachiyomi.ui.manga.RelatedManga +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.presentation.core.components.material.Scaffold +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class RelatedMangasScreen( + val mangaScreenModel: MangaScreenModel, + val onKeywordClick: (String) -> Unit, + val onKeywordLongClick: (String) -> Unit, + private val getManga: GetManga = Injekt.get(), + private val sourcePreferences: SourcePreferences = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), +) : Screen() { + @Composable + override fun Content() { + val scope = rememberCoroutineScope() + var displayMode by sourcePreferences.sourceDisplayMode().asState(scope) + + val screenState by mangaScreenModel.state.collectAsState() + val successState = screenState as? MangaScreenModel.State.Success ?: return + + val bulkFavoriteScreenModel = rememberScreenModel { BulkFavoriteScreenModel() } + val bulkFavoriteState by bulkFavoriteScreenModel.state.collectAsState() + + val navigator = LocalNavigator.currentOrThrow + val haptic = LocalHapticFeedback.current + + BackHandler(enabled = bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.toggleSelectionMode() + } + + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + topBar = { scrollBehavior -> + if (bulkFavoriteState.selectionMode) { + SelectionToolbar( + selectedCount = bulkFavoriteState.selection.size, + onClickClearSelection = bulkFavoriteScreenModel::toggleSelectionMode, + onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, + onSelectAll = { + successState.relatedMangasSorted?.forEach { + val relatedManga = it as RelatedManga.Success + relatedManga.mangaList.forEach { manga -> + if (!bulkFavoriteState.selection.contains(manga)) { + bulkFavoriteScreenModel.select(manga) + } + } + } + }, + ) + } else { + BrowseSourceSimpleToolbar( + navigateUp = navigator::pop, + title = successState.manga.title, + displayMode = displayMode, + onDisplayModeChange = { displayMode = it }, + scrollBehavior = scrollBehavior, + toggleSelectionMode = bulkFavoriteScreenModel::toggleSelectionMode, + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + ) { paddingValues -> + RelatedMangasContent( + relatedMangas = successState.relatedMangasSorted, + getMangaState = { manga -> getMangaState(initialManga = manga) }, + columns = getColumnsPreference(LocalConfiguration.current.orientation), + displayMode = displayMode, + contentPadding = paddingValues, + onMangaClick = { manga -> + if (bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.toggleSelection(manga) + } else { + navigator.push(MangaScreen(manga.id, true)) + } + }, + onMangaLongClick = { manga -> + if (!bulkFavoriteState.selectionMode) { + bulkFavoriteScreenModel.addRemoveManga(manga, haptic) + } else { + navigator.push(MangaScreen(manga.id, true)) + } + }, + onKeywordClick = onKeywordClick, + onKeywordLongClick = onKeywordLongClick, + selection = bulkFavoriteState.selection, + ) + } + + when (bulkFavoriteState.dialog) { + is BulkFavoriteScreenModel.Dialog.AddDuplicateManga -> + AddDuplicateMangaDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.RemoveManga -> + RemoveMangaDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.ChangeMangaCategory -> + ChangeMangaCategoryDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.ChangeMangasCategory -> + ChangeMangasCategoryDialog(bulkFavoriteScreenModel) + is BulkFavoriteScreenModel.Dialog.AllowDuplicate -> + AllowDuplicateDialog(bulkFavoriteScreenModel) + else -> {} + } + } + + private fun getColumnsPreference(orientation: Int): GridCells { + val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE + val columns = if (isLandscape) { + libraryPreferences.landscapeColumns() + } else { + libraryPreferences.portraitColumns() + }.get() + return if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) + } + + @Composable + fun getMangaState(initialManga: Manga): State { + return produceState(initialValue = initialManga) { + getManga.subscribe(initialManga.url, initialManga.source) + .filterNotNull() + .collectLatest { manga -> + value = manga + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt index 61ddfdf3d..ee03c868a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/SourceSearchScreen.kt @@ -77,8 +77,9 @@ data class SourceSearchScreen( onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, onSelectAll = { state.mangaDisplayingList.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt index ef3007363..45f75cc16 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -173,8 +173,9 @@ data class BrowseSourceScreen( onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, onSelectAll = { state.mangaDisplayingList.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } }, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index 94820b727..c61081ad2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -474,7 +474,7 @@ open class BrowseSourceScreenModel( val filterable: Boolean = true, // SY <-- // KMK --> - val mangaDisplayingList: MutableSet = emptySet().toMutableSet() + val mangaDisplayingList: MutableSet = mutableSetOf() // KMK <-- ) { val isUserQuery get() = listing is Listing.Search && !listing.query.isNullOrEmpty() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt index 180062b1d..041b3412f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreen.kt @@ -47,6 +47,7 @@ import eu.kanade.tachiyomi.ui.browse.AllowDuplicateDialog import eu.kanade.tachiyomi.ui.browse.BulkFavoriteScreenModel import eu.kanade.tachiyomi.ui.browse.ChangeMangaCategoryDialog import eu.kanade.tachiyomi.ui.browse.ChangeMangasCategoryDialog +import eu.kanade.tachiyomi.ui.browse.RelatedMangasScreen import eu.kanade.tachiyomi.ui.browse.RemoveMangaDialog import eu.kanade.tachiyomi.ui.browse.extension.ExtensionsScreen import eu.kanade.tachiyomi.ui.browse.migration.advanced.design.PreMigrationScreen @@ -243,6 +244,19 @@ class MangaScreen( onInvertSelection = screenModel::invertSelection, // KMK --> getMangaState = { screenModel.getManga(initialManga = it) }, + onRelatedMangasScreenClick = { + navigator.push( + RelatedMangasScreen( + mangaScreenModel = screenModel, + onKeywordClick = { query -> + navigator.push(BrowseSourceScreen(successState.source.id, query)) + }, + onKeywordLongClick = { query -> + navigator.push(GlobalSearchScreen(query)) + }, + ) + ) + }, onRelatedMangaClick = { navigator.push(MangaScreen(it.id, true)) }, onRelatedMangaLongClick = { bulkFavoriteScreenModel.addRemoveManga(it, haptic) }, onSourceClick = { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt index c427f939e..b7f2efe4c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaScreenModel.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState +import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.util.fastAny import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope @@ -43,8 +44,12 @@ import eu.kanade.tachiyomi.network.HttpException import eu.kanade.tachiyomi.source.PagePreviewSource import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.getNameForMangaInfo +import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.MetadataSource import eu.kanade.tachiyomi.source.online.all.MergedSource +import eu.kanade.tachiyomi.ui.manga.RelatedManga.Companion.isLoading +import eu.kanade.tachiyomi.ui.manga.RelatedManga.Companion.removeDuplicates +import eu.kanade.tachiyomi.ui.manga.RelatedManga.Companion.sorted import eu.kanade.tachiyomi.ui.manga.track.TrackItem import eu.kanade.tachiyomi.ui.reader.setting.ReaderPreferences import eu.kanade.tachiyomi.util.chapter.getNextUnread @@ -128,6 +133,7 @@ import tachiyomi.domain.manga.model.MergeMangaSettingsUpdate import tachiyomi.domain.manga.model.MergedMangaReference import tachiyomi.domain.manga.model.applyFilter import tachiyomi.domain.manga.repository.MangaRepository +import tachiyomi.domain.source.model.StubSource import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.track.interactor.GetTracks import tachiyomi.domain.track.interactor.InsertTrack @@ -435,12 +441,17 @@ class MangaScreenModel( // Fetch info-chapters when needed if (screenModelScope.isActive) { + // KMK --> + launch { + launch { + fetchRelatedMangasFromSource() + }.join() + updateSuccessState { it.copy(isFetchingRelatedMangas = false) } + } + // KMK <-- val fetchFromSourceTasks = listOf( async { if (needRefreshInfo) fetchMangaFromSource() }, async { if (needRefreshChapter) fetchChaptersFromSource() }, - // KMK --> - async { fetchRelatedMangasFromSource() }, - // KMK <-- ) fetchFromSourceTasks.awaitAll() } @@ -456,9 +467,6 @@ class MangaScreenModel( val fetchFromSourceTasks = listOf( async { fetchMangaFromSource(manualFetch) }, async { fetchChaptersFromSource(manualFetch) }, - // KMK --> - async { fetchRelatedMangasFromSource() }, - // KMK <-- ) fetchFromSourceTasks.awaitAll() updateSuccessState { it.copy(isRefreshingData = false) } @@ -1101,25 +1109,29 @@ class MangaScreenModel( */ private suspend fun fetchRelatedMangasFromSource() { val state = successState ?: return + val relatedMangasEnabled = Injekt.get().relatedMangas().get() try { withIOContext { - if (state.source !is MergedSource) { - val relatedMangas = if (Injekt.get().relatedMangas().get()) { - val map = mutableMapOf() - val list = state.source.getRelatedMangaList(state.manga.toSManga()) - .map { - networkToLocalManga.await(it.toDomainManga(state.source.id)) + if (state.source !is StubSource) { + if (relatedMangasEnabled) { + state.source.getRelatedMangaList(state.manga.toSManga()) { pair, _ -> + /* Push found related mangas into collection */ + val relatedManga = RelatedManga.Success.fromPair(pair) { mangaList -> + mangaList.map { + networkToLocalManga.await(it.toDomainManga(state.source.id)) + } + } + + updateSuccessState { successState -> + val relatedMangaCollection = + successState.relatedMangaCollection + ?.toMutableStateList() + ?.apply { add(relatedManga) } + ?: listOf(relatedManga) + successState.copy(relatedMangaCollection = relatedMangaCollection) } - list.forEach { - map[it.id] = it } - map - } else { - null } - updateSuccessState { it.copy(relatedMangas = relatedMangas) } - } else { - throw UnsupportedOperationException("Fetching related titles for merged entry is not supported") } } } catch (e: Throwable) { @@ -1129,8 +1141,6 @@ class MangaScreenModel( screenModelScope.launch { snackbarHostState.showSnackbar(message = message) } - val newManga = mangaRepository.getMangaById(mangaId) - updateSuccessState { it.copy(manga = newManga) } } } // KMK <-- @@ -1714,9 +1724,22 @@ class MangaScreenModel( val previewsRowCount: Int, // SY <-- // KMK --> - val relatedMangas: MutableMap? = null, + val isFetchingRelatedMangas: Boolean = true, + /** + * a list of + */ + val relatedMangaCollection: List? = null, // KMK <-- ) : State { + // KMK ->> + val relatedMangasSorted = relatedMangaCollection + ?.sorted(manga) + ?.removeDuplicates(manga) + ?.filter { it.isVisible() } + ?.isLoading(isFetchingRelatedMangas) + ?: if (!isFetchingRelatedMangas) emptyList() else null + // KMK <-- + val processedChapters by lazy { chapters.applyFilters(manga).toList() } @@ -1820,3 +1843,69 @@ sealed interface PagePreviewState { data class Error(val error: Throwable) : PagePreviewState } // SY <-- + +// KMK --> +sealed interface RelatedManga { + data object Loading : RelatedManga + + data class Success( + val keyword: String, + val mangaList: List, + ) : RelatedManga { + val isEmpty: Boolean + get() = mangaList.isEmpty() + + companion object { + suspend fun fromPair( + pair: Pair>, + toManga: suspend (mangaList: List) -> List, + ) = Success(pair.first, toManga(pair.second)) + } + } + + fun isVisible(): Boolean { + return this is Loading || (this is Success && !this.isEmpty) + } + + companion object { + fun List.sorted(manga: Manga): List { + val success = filterIsInstance() + val loading = filterIsInstance() + val title = manga.title.lowercase() + val ogTitle = manga.ogTitle.lowercase() + return success.filter { it.keyword.isEmpty() } + + success.filter { it.keyword.lowercase() == title } + + success.filter { it.keyword.lowercase() == ogTitle && ogTitle != title } + + success.filter { it.keyword.isNotEmpty() && it.keyword.lowercase() !in listOf(title, ogTitle) } + .sortedByDescending { it.keyword.length } + .sortedBy { it.mangaList.size } + + loading + } + + fun List.removeDuplicates(manga: Manga): List { + val mangaUrls = HashSet().apply { add(manga.url) } + + return map { relatedManga -> + if (relatedManga is Success) { + Success( + relatedManga.keyword, + relatedManga.mangaList + .filterNot { mangaUrls.contains(it.url) } + .onEach { mangaUrls.add(it.url) }, + ) + } else { + relatedManga + } + } + } + + fun List.isLoading(isFetchingRelatedMangas: Boolean): List { + return if (isFetchingRelatedMangas) { + this + listOf(Loading) + } else { + this + } + } + } +} +// KMK <-- diff --git a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt index 63b864318..a1a063cf7 100644 --- a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt +++ b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt @@ -75,8 +75,9 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() { onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, onSelectAll = { state.mangaDisplayingList.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } }, ) diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt index 9a03d6a0d..192faaa7d 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarScreen.kt @@ -71,8 +71,9 @@ class MangaDexSimilarScreen(val mangaId: Long, val sourceId: Long) : Screen() { onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, onSelectAll = { state.mangaDisplayingList.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } }, ) diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index 11144cf62..d826fcae0 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -70,8 +70,9 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite, onSelectAll = { state.mangaDisplayingList.forEach { manga -> - if (!bulkFavoriteState.selection.contains(manga)) + if (!bulkFavoriteState.selection.contains(manga)) { bulkFavoriteScreenModel.select(manga) + } } }, ) @@ -119,10 +120,8 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { // KMK --> if (!bulkFavoriteState.selectionMode) { bulkFavoriteScreenModel.addRemoveManga(manga, haptic) - } else { - // KMK <-- - onMangaClick(manga) } + // KMK <-- }, // KMK --> selection = bulkFavoriteState.selection, diff --git a/app/src/main/res/raw/changelog_debug.xml b/app/src/main/res/raw/changelog_debug.xml index 087a69aa5..3906b1f16 100644 --- a/app/src/main/res/raw/changelog_debug.xml +++ b/app/src/main/res/raw/changelog_debug.xml @@ -1,6 +1,12 @@ - + + A dedicated screen for related entries, fetching directly from sources + Faster loading of related entries + Enable fetching related entries from site's entry page (things like "More like this", "Recommendations" etc.) + Improve & fix bugs of algorithm looking for related entries + + Beta build switch to 'preview' repo diff --git a/app/src/main/res/raw/changelog_release.xml b/app/src/main/res/raw/changelog_release.xml index 81dbc9814..a7837e830 100644 --- a/app/src/main/res/raw/changelog_release.xml +++ b/app/src/main/res/raw/changelog_release.xml @@ -1,5 +1,11 @@ + + A dedicated screen for related entries, fetching directly from sources + Faster loading of related entries + Enable fetching related entries from site's entry page (things like "More like this", "Recommendations" etc.) + Improve & fix bugs of algorithm looking for related entries + Show related mangas for all sources Bulk select multiple entries to favorite/category diff --git a/domain/src/main/java/tachiyomi/domain/source/model/StubSource.kt b/domain/src/main/java/tachiyomi/domain/source/model/StubSource.kt index f7e4cb243..91f0bf78d 100644 --- a/domain/src/main/java/tachiyomi/domain/source/model/StubSource.kt +++ b/domain/src/main/java/tachiyomi/domain/source/model/StubSource.kt @@ -22,8 +22,10 @@ class StubSource( throw SourceNotInstalledException() // KMK --> - override suspend fun getRelatedMangaList(manga: SManga): List = - throw SourceNotInstalledException() + override suspend fun getRelatedMangaList( + manga: SManga, + pushResults: suspend (relatedManga: Pair>, completed: Boolean) -> Unit, + ) = throw SourceNotInstalledException() // KMK <-- override fun toString(): String = diff --git a/i18n-sy/src/commonMain/resources/MR/base/strings.xml b/i18n-sy/src/commonMain/resources/MR/base/strings.xml index 4fcb230d6..543f32022 100644 --- a/i18n-sy/src/commonMain/resources/MR/base/strings.xml +++ b/i18n-sy/src/commonMain/resources/MR/base/strings.xml @@ -202,8 +202,8 @@ Replace latest button with a custom browse view that includes both latest and browse Local source hidden folders Allow local source to read hidden folders - Related mangas - Show related mangas provided by extensions + Related titles + Show related titles while viewing entry Custom entry info diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt index 9be673541..bc6f0ed68 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/CatalogueSource.kt @@ -2,8 +2,13 @@ package eu.kanade.tachiyomi.source import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.MangasPage +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import logcat.LogPriority import rx.Observable import tachiyomi.core.common.util.lang.awaitSingle +import tachiyomi.core.common.util.system.logcat interface CatalogueSource : Source { @@ -57,6 +62,132 @@ interface CatalogueSource : Source { */ fun getFilterList(): FilterList + // KMK --> + /** + * Whether parsing related mangas in manga page or extension provide custom related mangas request. + * @default false + * @since komikku/extensions-lib 1.6 + */ + val supportsRelatedMangas: Boolean get() = false + + /** + * Extensions doesn't want to use App's [getRelatedMangaListBySearch]. + * @default false + * @since komikku/extensions-lib 1.6 + */ + val disableRelatedMangasBySearch: Boolean get() = false + + /** + * Disable showing any related titles. + * @default false + * @since komikku/extensions-lib 1.6 + */ + val disableRelatedMangas: Boolean get() = false + + /** + * Get all the available related mangas for a manga. + * Normally it's not needed to override this method. + * + * @since komikku/extensions-lib 1.6 + * @param manga the current manga to get related mangas. + * @return a list of + * @throws UnsupportedOperationException if a source doesn't support related mangas. + */ + override suspend fun getRelatedMangaList( + manga: SManga, + pushResults: suspend (relatedManga: Pair>, completed: Boolean) -> Unit, + ) { + if (!disableRelatedMangas) { + if (supportsRelatedMangas) { + coroutineScope { + launch { + runCatching { fetchRelatedMangaList(manga) } + .onSuccess { if (it.isNotEmpty()) pushResults(Pair("", it), false) } + .onFailure { + logcat(LogPriority.ERROR, it) { "## fetchRelatedMangaList: $it" } + } + } + } + } + + if (!disableRelatedMangasBySearch) { + getRelatedMangaListBySearch(manga, pushResults) + } + } + } + + /** + * Fetch related mangas for a manga from source/site. + * + * @since komikku/extensions-lib 1.6 + * @param manga the current manga to get related mangas. + * @return the related mangas for the current manga. + * @throws UnsupportedOperationException if a source doesn't support related mangas. + */ + suspend fun fetchRelatedMangaList(manga: SManga): List = + throw IllegalStateException("Not used") + + /** + * Slit & strip manga's title into separate searchable keywords. + * Used for searching related mangas. + * + * @since komikku/extensions-lib 1.6 + * @return List of keywords. + */ + fun String.stripKeywordForRelatedMangas(): List { + val regexWhitespace = Regex("\\s+") + val regexSpecialCharacters = + Regex("([!~#$%^&*+_|/\\\\,?:;'“”‘’\"<>(){}\\[\\]。・~:—!?、―«»《》〘〙【】「」|]|\\s-|-\\s|\\s\\.|\\.\\s)") + val regexNumberOnly = Regex("^\\d+$") + + return replace(regexSpecialCharacters, " ") + .split(regexWhitespace) + .map { + // remove number only + it.replace(regexNumberOnly, "") + .lowercase() + } + // exclude single character + .filter { it.length > 1 } + } + + /** + * Fetch related mangas by searching for each keywords from manga's title. + * + * @return a list of + * @since komikku/extensions-lib 1.6 + */ + private suspend fun getRelatedMangaListBySearch( + manga: SManga, + pushResults: suspend (relatedManga: Pair>, completed: Boolean) -> Unit, + ) { + val words = HashSet() + words.add(manga.title) + if (manga.title.lowercase() != manga.originalTitle.lowercase()) words.add(manga.originalTitle) + manga.title.stripKeywordForRelatedMangas() + .filterNot { word -> words.any { it.lowercase() == word } } + .onEach { words.add(it) } + manga.originalTitle.stripKeywordForRelatedMangas() + .filterNot { word -> words.any { it.lowercase() == word } } + .onEach { words.add(it) } + if (words.isEmpty()) return + + coroutineScope { + words.map { keyword -> + launch { + runCatching { + getSearchManga(1, keyword, FilterList()).mangas + } + .onSuccess { if (it.isNotEmpty()) pushResults(Pair(keyword, it), false) } + .onFailure { e -> + logcat(LogPriority.ERROR, e) { "## getRelatedMangaListBySearch: $e" } + } + } + } + } + } + // KMK <-- + @Deprecated( "Use the non-RxJava API instead", ReplaceWith("getPopularManga"), diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/Source.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/Source.kt index fc6a31ded..be1977a4e 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/Source.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/Source.kt @@ -54,10 +54,12 @@ interface Source { * * @since komikku/extensions-lib 1.6 * @param manga the current manga to get related mangas. - * @return the related mangas for the current manga. + * @return a list of */ - suspend fun getRelatedMangaList(manga: SManga): List = - throw IllegalStateException("Not used") + suspend fun getRelatedMangaList( + manga: SManga, + pushResults: suspend (relatedManga: Pair>, completed: Boolean) -> Unit, + ): Unit = throw IllegalStateException("Not used") // KMK <-- /** diff --git a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt index 4bc8834c2..6394979db 100644 --- a/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt +++ b/source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/source/online/HttpSource.kt @@ -16,18 +16,14 @@ import eu.kanade.tachiyomi.source.model.SManga import exh.log.maybeInjectEHLogger import exh.pref.DelegateSourcePreferences import exh.source.DelegatedHttpSource -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import logcat.LogPriority +import kotlinx.coroutines.coroutineScope import okhttp3.Headers import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response import rx.Observable import tachiyomi.core.common.util.lang.awaitSingle -import tachiyomi.core.common.util.system.logcat import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import java.net.URI @@ -282,45 +278,11 @@ abstract class HttpSource : CatalogueSource { // KMK --> /** - * Whether parsing related mangas in manga page or extension provide custom related mangas request (true) - * or using search keyword to search for related mangas (false - default) - * @default false + * Whether parsing related mangas in manga page or extension provide custom related mangas request. + * @default true * @since komikku/extensions-lib 1.6 */ - protected open val supportsRelatedMangas: Boolean get() = false - - /** - * Extensions provide custom [relatedMangaListRequest] and [relatedMangaListParse] - * while also want to use App's [getRelatedMangaListBySearch] together. - * @default false - * @since komikku/extensions-lib 1.6 - */ - protected open val supportsRelatedMangasAndSearch: Boolean get() = false - - /** - * Disable showing any related titles - * @default false - * @since komikku/extensions-lib 1.6 - */ - protected open val disableRelatedMangas: Boolean get() = false - - /** - * Get all the available related mangas for a manga. - * Normally it's not needed to override this method. - * - * @since komikku/extensions-lib 1.6 - * @param manga the current manga to get related mangas. - * @return the related mangas for the current manga. - * @throws UnsupportedOperationException if a source doesn't support related mangas. - */ - override suspend fun getRelatedMangaList(manga: SManga): List { - return when { - supportsRelatedMangasAndSearch -> fetchRelatedMangaList(manga) + getRelatedMangaListBySearch(manga) - supportsRelatedMangas -> fetchRelatedMangaList(manga) - disableRelatedMangas -> emptyList() - else -> getRelatedMangaListBySearch(manga) - } - } + override val supportsRelatedMangas: Boolean get() = true /** * Fetch related mangas for a manga from source/site. @@ -331,62 +293,14 @@ abstract class HttpSource : CatalogueSource { * @return the related mangas for the current manga. * @throws UnsupportedOperationException if a source doesn't support related mangas. */ - protected open suspend fun fetchRelatedMangaList(manga: SManga): List { - return client.newCall(relatedMangaListRequest(manga)) - .execute() - .let { response -> - relatedMangaListParse(response) - } - } - - /** - * Fetch related mangas by searching for each keywords from manga's title - * - * @since komikku/extensions-lib 1.6 - */ - protected open suspend fun getRelatedMangaListBySearch(manga: SManga): List { - fun String.stripKeyword(): List { - val regexWhitespace = Regex("\\s+") - val regexSpecialCharacters = - Regex("([!~#$%^&*+_|/\\\\,?:;'“”‘’\"<>(){}\\[\\]。・~:—!?、―«»《》〘〙【】「」|]|\\s-|-\\s|\\s\\.|\\.\\s)") - val regexNumberOnly = Regex("^\\d+$") - - return replace(regexSpecialCharacters, " ") - .split(regexWhitespace) - .map { - // remove number only - it.replace(regexNumberOnly, "") - .lowercase() + override suspend fun fetchRelatedMangaList(manga: SManga): List = coroutineScope { + async { + client.newCall(relatedMangaListRequest(manga)) + .execute() + .let { response -> + relatedMangaListParse(response) } - // exclude single character - .filter { it.length > 1 } - } - val scope = CoroutineScope(Dispatchers.IO) - val words = HashSet() - words += manga.title.stripKeyword() - manga.originalTitle.let { title -> - words += title.stripKeyword() - } - if (words.isEmpty()) { - return emptyList() - } - - return words.map { keyword -> - // Make multiple search in parallel - scope.async { - try { - getSearchManga(0, keyword, FilterList()) - .mangas.filter { - it.url != manga.url && - it.title.lowercase().contains(keyword) - } - } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { "getRelatedMangaListBySearch: $e" } - emptyList() - } - } - }.awaitAll() - .minBy { if (it.isEmpty()) Int.MAX_VALUE else it.size } + }.await() } /**