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)
This commit is contained in:
Cuong M. Tran 2024-05-17 11:27:18 +07:00 committed by Cuong Tran
parent d90a4da895
commit bdd8a652c2
No known key found for this signature in database
GPG key ID: 733AA7624B9315C2
32 changed files with 1034 additions and 191 deletions

View file

@ -84,6 +84,6 @@ class SourcePreferences(
// SY <--
// KMK -->
fun relatedMangas() = preferenceStore.getBoolean("related_mangas", false)
fun relatedMangas() = preferenceStore.getBoolean("related_mangas", true)
// KMK <--
}

View file

@ -58,10 +58,11 @@ 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 -> {}
}
}

View file

@ -48,10 +48,11 @@ 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 -> {}
}
}

View file

@ -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<RelatedManga>?,
getMangaState: @Composable (Manga) -> State<Manga>,
columns: GridCells,
displayMode: LibraryDisplayMode,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
onKeywordClick: (String) -> Unit,
onKeywordLongClick: (String) -> Unit,
selection: List<Manga>,
) {
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,
)
}
}

View file

@ -131,10 +131,11 @@ fun SourceFeedScreen(
onSelectAll = {
items.forEach {
it.results?.forEach { manga ->
if (!bulkFavoriteState.selection.contains(manga))
if (!bulkFavoriteState.selection.contains(manga)) {
bulkFavoriteScreenModel.select(manga)
}
}
}
},
)
} else {

View file

@ -79,7 +79,7 @@ fun BrowseSourceComfortableGrid(
}
@Composable
private fun BrowseSourceComfortableGridItem(
internal fun BrowseSourceComfortableGridItem(
manga: Manga,
// SY -->
metadata: RaisedSearchMetadata?,

View file

@ -79,7 +79,7 @@ fun BrowseSourceCompactGrid(
}
@Composable
private fun BrowseSourceCompactGridItem(
internal fun BrowseSourceCompactGridItem(
manga: Manga,
// SY -->
metadata: RaisedSearchMetadata?,

View file

@ -72,7 +72,7 @@ fun BrowseSourceList(
}
@Composable
private fun BrowseSourceListItem(
internal fun BrowseSourceListItem(
manga: Manga,
// SY -->
metadata: RaisedSearchMetadata?,

View file

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

View file

@ -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<RelatedManga>,
getManga: @Composable (Manga) -> State<Manga>,
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
onKeywordClick: (String) -> Unit,
onKeywordLongClick: (String) -> Unit,
selection: List<Manga>,
) {
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,
)
}
}
}
}
}

View file

@ -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<RelatedManga>,
getManga: @Composable (Manga) -> State<Manga>,
columns: GridCells,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
onKeywordClick: (String) -> Unit,
onKeywordLongClick: (String) -> Unit,
selection: List<Manga>,
) {
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,
)
}
}
}
}
}

View file

@ -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<RelatedManga>,
getManga: @Composable (Manga) -> State<Manga>,
contentPadding: PaddingValues,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
onKeywordClick: (String) -> Unit,
onKeywordLongClick: (String) -> Unit,
selection: List<Manga>,
) {
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,
)
}
}
}
}
}

View file

@ -73,10 +73,11 @@ fun TabbedScreen(
onSelectAll = {
feedState.items?.forEach {
it.results?.forEach { manga ->
if (!bulkFavoriteState.selection.contains(manga))
if (!bulkFavoriteState.selection.contains(manga)) {
bulkFavoriteScreenModel.select(manga)
}
}
}
},
)
} else {

View file

@ -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<Manga>),
getMangaState: @Composable (Manga) -> State<Manga>,
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<Manga>),
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<SourcePreferences>().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",
Column {
RelatedMangaTitle(
title = stringResource(SYMR.strings.pref_source_related_mangas),
subtitle = null,
onClick = onRelatedMangasScreenClick,
onLongClick = null,
onClick = { /* Should show a page with grid/list of all the related manga */ },
) {
)
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<Manga>),
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<SourcePreferences>().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",
Column {
RelatedMangaTitle(
title = stringResource(SYMR.strings.pref_source_related_mangas),
subtitle = null,
onClick = onRelatedMangasScreenClick,
onLongClick = null,
onClick = { /* Should show a page with grid/list of all the related manga */ },
) {
)
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<Manga>,
getMangaState: @Composable ((Manga) -> State<Manga>),
onClickManga: (Manga) -> Unit,
onLongClickManga: (Manga) -> Unit,
) {
GlobalSearchCardRow(
titles = mangas,
getManga = getMangaState,
onClick = onClickManga,
onLongClick = onLongClickManga,
selection = emptyList(),
)
}
// KMK <--
@Composable
private fun SharedMangaBottomActionMenu(
selected: List<ChapterList.Item>,

View file

@ -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<RelatedManga>?,
getMangaState: @Composable (Manga) -> State<Manga>,
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<RelatedManga>,
getManga: @Composable (Manga) -> State<Manga>,
onMangaClick: (Manga) -> Unit,
onMangaLongClick: (Manga) -> Unit,
) {
val mangas = relatedMangas.filterIsInstance<RelatedManga.Success>().map { it.mangaList }.flatten()
val loading = relatedMangas.filterIsInstance<RelatedManga.Loading>().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,
)
}
}

View file

@ -71,6 +71,7 @@ class DownloadCache(
) {
private val scope = CoroutineScope(Dispatchers.IO)
// KMK -->
private val downloadPreferences: DownloadPreferences = Injekt.get()
// KMK <--

View file

@ -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<Manga> {
return produceState(initialValue = initialManga) {
getManga.subscribe(initialManga.url, initialManga.source)
.filterNotNull()
.collectLatest { manga ->
value = manga
}
}
}
}

View file

@ -77,9 +77,10 @@ data class SourceSearchScreen(
onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite,
onSelectAll = {
state.mangaDisplayingList.forEach { manga ->
if (!bulkFavoriteState.selection.contains(manga))
if (!bulkFavoriteState.selection.contains(manga)) {
bulkFavoriteScreenModel.select(manga)
}
}
},
)
} else {

View file

@ -173,9 +173,10 @@ data class BrowseSourceScreen(
onChangeCategoryClicked = bulkFavoriteScreenModel::addFavorite,
onSelectAll = {
state.mangaDisplayingList.forEach { manga ->
if (!bulkFavoriteState.selection.contains(manga))
if (!bulkFavoriteState.selection.contains(manga)) {
bulkFavoriteScreenModel.select(manga)
}
}
},
)
} else {

View file

@ -474,7 +474,7 @@ open class BrowseSourceScreenModel(
val filterable: Boolean = true,
// SY <--
// KMK -->
val mangaDisplayingList: MutableSet<Manga> = emptySet<Manga>().toMutableSet()
val mangaDisplayingList: MutableSet<Manga> = mutableSetOf()
// KMK <--
) {
val isUserQuery get() = listing is Listing.Search && !listing.query.isNullOrEmpty()

View file

@ -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 = {

View file

@ -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<SourcePreferences>().relatedMangas().get()
try {
withIOContext {
if (state.source !is MergedSource) {
val relatedMangas = if (Injekt.get<SourcePreferences>().relatedMangas().get()) {
val map = mutableMapOf<Long, Manga>()
val list = state.source.getRelatedMangaList(state.manga.toSManga())
.map {
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))
}
list.forEach {
map[it.id] = it
}
map
} else {
null
updateSuccessState { successState ->
val relatedMangaCollection =
successState.relatedMangaCollection
?.toMutableStateList()
?.apply { add(relatedManga) }
?: listOf(relatedManga)
successState.copy(relatedMangaCollection = relatedMangaCollection)
}
}
}
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<Long, Manga>? = null,
val isFetchingRelatedMangas: Boolean = true,
/**
* a list of <keyword, related mangas>
*/
val relatedMangaCollection: List<RelatedManga>? = 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<Manga>,
) : RelatedManga {
val isEmpty: Boolean
get() = mangaList.isEmpty()
companion object {
suspend fun fromPair(
pair: Pair<String, List<SManga>>,
toManga: suspend (mangaList: List<SManga>) -> List<Manga>,
) = Success(pair.first, toManga(pair.second))
}
}
fun isVisible(): Boolean {
return this is Loading || (this is Success && !this.isEmpty)
}
companion object {
fun List<RelatedManga>.sorted(manga: Manga): List<RelatedManga> {
val success = filterIsInstance<Success>()
val loading = filterIsInstance<Loading>()
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<RelatedManga>.removeDuplicates(manga: Manga): List<RelatedManga> {
val mangaUrls = HashSet<String>().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<RelatedManga>.isLoading(isFetchingRelatedMangas: Boolean): List<RelatedManga> {
return if (isFetchingRelatedMangas) {
this + listOf(Loading)
} else {
this
}
}
}
}
// KMK <--

View file

@ -75,9 +75,10 @@ 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)
}
}
},
)
} else {

View file

@ -71,9 +71,10 @@ 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)
}
}
},
)
} else {

View file

@ -70,9 +70,10 @@ 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)
}
}
},
)
} else {
@ -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,

View file

@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<changelog bulletedList="true">
<changelogversion versionName="Current" changeDate="May 9,2024">
<changelogversion versionName="Latest (r8855)" changeDate="May 17, 2024">
<changelogtext>A dedicated screen for related entries, fetching directly from sources</changelogtext>
<changelogtext>Faster loading of related entries</changelogtext>
<changelogtext>Enable fetching related entries from site's entry page (things like "More like this", "Recommendations" etc.)</changelogtext>
<changelogtext>Improve &amp; fix bugs of algorithm looking for related entries</changelogtext>
</changelogversion>
<changelogversion versionName="r8852" changeDate="May 9,2024">
<changelogtext>Beta build switch to 'preview' repo</changelogtext>
</changelogversion>
<changelogversion versionName="1.10.5" changeDate="May 9,2024">

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<changelog bulletedList="true">
<changelogversion versionName="1.10.6" changeDate="2024">
<changelogtext>A dedicated screen for related entries, fetching directly from sources</changelogtext>
<changelogtext>Faster loading of related entries</changelogtext>
<changelogtext>Enable fetching related entries from site's entry page (things like "More like this", "Recommendations" etc.)</changelogtext>
<changelogtext>Improve &amp; fix bugs of algorithm looking for related entries</changelogtext>
</changelogversion>
<changelogversion versionName="1.10.5" changeDate="May 9,2024">
<changelogtext>Show related mangas for all sources</changelogtext>
<changelogtext>Bulk select multiple entries to favorite/category</changelogtext>

View file

@ -22,8 +22,10 @@ class StubSource(
throw SourceNotInstalledException()
// KMK -->
override suspend fun getRelatedMangaList(manga: SManga): List<SManga> =
throw SourceNotInstalledException()
override suspend fun getRelatedMangaList(
manga: SManga,
pushResults: suspend (relatedManga: Pair<String, List<SManga>>, completed: Boolean) -> Unit,
) = throw SourceNotInstalledException()
// KMK <--
override fun toString(): String =

View file

@ -202,8 +202,8 @@
<string name="pref_source_navigation_summery">Replace latest button with a custom browse view that includes both latest and browse</string>
<string name="pref_local_source_hidden_folders">Local source hidden folders</string>
<string name="pref_local_source_hidden_folders_summery">Allow local source to read hidden folders</string>
<string name="pref_source_related_mangas">Related mangas</string>
<string name="pref_source_related_mangas_summary">Show related mangas provided by extensions</string>
<string name="pref_source_related_mangas">Related titles</string>
<string name="pref_source_related_mangas_summary">Show related titles while viewing entry</string>
<!-- Backup settings -->
<string name="custom_entry_info">Custom entry info</string>

View file

@ -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 <keyword, related mangas>
* @throws UnsupportedOperationException if a source doesn't support related mangas.
*/
override suspend fun getRelatedMangaList(
manga: SManga,
pushResults: suspend (relatedManga: Pair<String, List<SManga>>, 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<SManga> =
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<String> {
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 <keyword, related mangas>
* @since komikku/extensions-lib 1.6
*/
private suspend fun getRelatedMangaListBySearch(
manga: SManga,
pushResults: suspend (relatedManga: Pair<String, List<SManga>>, completed: Boolean) -> Unit,
) {
val words = HashSet<String>()
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"),

View file

@ -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 <keyword, related mangas>
*/
suspend fun getRelatedMangaList(manga: SManga): List<SManga> =
throw IllegalStateException("Not used")
suspend fun getRelatedMangaList(
manga: SManga,
pushResults: suspend (relatedManga: Pair<String, List<SManga>>, completed: Boolean) -> Unit,
): Unit = throw IllegalStateException("Not used")
// KMK <--
/**

View file

@ -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<SManga> {
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<SManga> {
return client.newCall(relatedMangaListRequest(manga))
override suspend fun fetchRelatedMangaList(manga: SManga): List<SManga> = coroutineScope {
async {
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<SManga> {
fun String.stripKeyword(): List<String> {
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 }
}
val scope = CoroutineScope(Dispatchers.IO)
val words = HashSet<String>()
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()
}
/**