From 11c35fd5d08d913b8ed65e1266b5cdc28940cd45 Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sat, 25 Jan 2025 02:58:11 +0100 Subject: [PATCH 01/21] refactor: use NoResultsException --- app/src/main/java/exh/recs/sources/AniListPagingSource.kt | 3 ++- app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt index 66994cfd44ac..911498be4ed4 100644 --- a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt @@ -18,6 +18,7 @@ import kotlinx.serialization.json.put import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR @@ -84,7 +85,7 @@ class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecomm .jsonObject["Page"]!! .jsonObject["media"]!! .jsonArray - .ifEmpty { throw Exception("'$queryParam' not found") } + .ifEmpty { throw NoResultsException() } .filter() return media.flatMap { it.jsonObject["recommendations"]!!.jsonObject["edges"]!!.jsonArray }.map { diff --git a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt index bf0bdef5eded..ddfe41842fae 100644 --- a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt @@ -20,6 +20,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import tachiyomi.core.common.util.system.logcat +import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR @@ -89,7 +90,7 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : return getRecsById( data["results"]!! .jsonArray - .ifEmpty { throw Exception("'$search' not found") } + .ifEmpty { throw NoResultsException() } .first() .jsonObject["record"]!! .jsonObject["series_id"]!! From a2e9711eca43e3d6ca579720944291c551ba3db1 Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sat, 25 Jan 2025 09:57:48 +0100 Subject: [PATCH 02/21] refactor: cleanup RecommendationPagingSources --- .../md/similar/MangaDexSimilarPagingSource.kt | 2 +- .../exh/recs/sources/AniListPagingSource.kt | 4 ++-- .../exh/recs/sources/ComickPagingSource.kt | 2 +- .../recs/sources/MangaUpdatesPagingSource.kt | 9 ++++---- .../recs/sources/MyAnimeListPagingSource.kt | 5 ++--- .../sources/RecommendationPagingSource.kt | 21 +++++++++++-------- .../data/source/SourcePagingSource.kt | 8 +++---- 7 files changed, 26 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt index 7458ecfc2e21..b96255d0bdcb 100644 --- a/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt +++ b/app/src/main/java/exh/md/similar/MangaDexSimilarPagingSource.kt @@ -19,7 +19,7 @@ import tachiyomi.i18n.sy.SYMR class MangaDexSimilarPagingSource( manga: Manga, private val mangaDex: MangaDex, -) : RecommendationPagingSource(mangaDex, manga) { +) : RecommendationPagingSource(manga, mangaDex) { override val name: String get() = "MangaDex" diff --git a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt index 911498be4ed4..8002f0245b66 100644 --- a/app/src/main/java/exh/recs/sources/AniListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/AniListPagingSource.kt @@ -22,8 +22,8 @@ import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -class AniListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://graphql.anilist.co/", source, manga, +class AniListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://graphql.anilist.co/", manga ) { override val name: String get() = "AniList" diff --git a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt index 84e9dd960e67..29f060a3f293 100644 --- a/app/src/main/java/exh/recs/sources/ComickPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/ComickPagingSource.kt @@ -28,7 +28,7 @@ fun CatalogueSource.isComickSource() = name == "Comick" class ComickPagingSource( manga: Manga, private val comickSource: CatalogueSource, -) : RecommendationPagingSource(comickSource, manga) { +) : RecommendationPagingSource(manga, comickSource) { override val name: String get() = "Comick" diff --git a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt index ddfe41842fae..686956a272ff 100644 --- a/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MangaUpdatesPagingSource.kt @@ -5,7 +5,6 @@ import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement @@ -24,8 +23,8 @@ import tachiyomi.data.source.NoResultsException import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://api.mangaupdates.com/v1/", source, manga, +abstract class MangaUpdatesPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://api.mangaupdates.com/v1/", manga, ) { override val name: String get() = "MangaUpdates" @@ -99,14 +98,14 @@ abstract class MangaUpdatesPagingSource(manga: Manga, source: CatalogueSource) : } } -class MangaUpdatesCommunityPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { +class MangaUpdatesCommunityPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) { override val category: StringResource get() = SYMR.strings.community_recommendations override val recommendationJsonObjectName: String get() = "recommendations" } -class MangaUpdatesSimilarPagingSource(manga: Manga, source: CatalogueSource) : MangaUpdatesPagingSource(manga, source) { +class MangaUpdatesSimilarPagingSource(manga: Manga) : MangaUpdatesPagingSource(manga) { override val category: StringResource get() = SYMR.strings.similar_titles override val recommendationJsonObjectName: String diff --git a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt index a4cb8a8e022d..3a997b21a584 100644 --- a/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/MyAnimeListPagingSource.kt @@ -4,7 +4,6 @@ import dev.icerock.moko.resources.StringResource import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.awaitSuccess import eu.kanade.tachiyomi.network.parseAs -import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.SManga import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -17,8 +16,8 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.sy.SYMR -class MyAnimeListPagingSource(manga: Manga, source: CatalogueSource) : TrackerRecommendationPagingSource( - "https://api.jikan.moe/v4/", source, manga, +class MyAnimeListPagingSource(manga: Manga) : TrackerRecommendationPagingSource( + "https://api.jikan.moe/v4/", manga, ) { override val name: String get() = "MyAnimeList" diff --git a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt index 52bdeb98a634..8235fea9c9cd 100644 --- a/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt +++ b/app/src/main/java/exh/recs/sources/RecommendationPagingSource.kt @@ -18,6 +18,7 @@ import tachiyomi.data.source.NoResultsException import tachiyomi.data.source.SourcePagingSource import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.track.interactor.GetTracks +import tachiyomi.i18n.sy.SYMR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get import uy.kohesive.injekt.injectLazy @@ -26,14 +27,14 @@ import uy.kohesive.injekt.injectLazy * General class for recommendation sources. */ abstract class RecommendationPagingSource( - source: CatalogueSource, protected val manga: Manga, + source: CatalogueSource? = null, ) : SourcePagingSource(source) { // Display name abstract val name: String // Localized category name - abstract val category: StringResource + open val category: StringResource = SYMR.strings.similar_titles /** * Recommendation sources that display results from a source extension, @@ -46,10 +47,10 @@ abstract class RecommendationPagingSource( companion object { fun createSources(manga: Manga, source: CatalogueSource): List { return buildList { - add(AniListPagingSource(manga, source)) - add(MangaUpdatesCommunityPagingSource(manga, source)) - add(MangaUpdatesSimilarPagingSource(manga, source)) - add(MyAnimeListPagingSource(manga, source)) + add(AniListPagingSource(manga)) + add(MangaUpdatesCommunityPagingSource(manga)) + add(MangaUpdatesSimilarPagingSource(manga)) + add(MyAnimeListPagingSource(manga)) // Only include MangaDex if the delegate sources are enabled and the source is MD-based if (source.isMdBasedSource() && Injekt.get().delegateSources().get()) { @@ -70,9 +71,8 @@ abstract class RecommendationPagingSource( */ abstract class TrackerRecommendationPagingSource( protected val endpoint: String, - source: CatalogueSource, manga: Manga, -) : RecommendationPagingSource(source, manga) { +) : RecommendationPagingSource(manga) { private val getTracks: GetTracks by injectLazy() protected val trackerManager: TrackerManager by injectLazy() @@ -105,7 +105,10 @@ abstract class TrackerRecommendationPagingSource( results.ifEmpty { throw NoResultsException() } } catch (e: Exception) { - logcat(LogPriority.ERROR, e) { name } + // 'No results' should not be logged as it happens frequently and is expected + if(e !is NoResultsException) { + logcat(LogPriority.ERROR, e) { name } + } throw e } diff --git a/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt b/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt index 3a27c625c667..4a1a1004c81b 100644 --- a/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt +++ b/data/src/main/java/tachiyomi/data/source/SourcePagingSource.kt @@ -13,24 +13,24 @@ import tachiyomi.domain.source.repository.SourcePagingSourceType class SourceSearchPagingSource(source: CatalogueSource, val query: String, val filters: FilterList) : SourcePagingSource(source) { override suspend fun requestNextPage(currentPage: Int): MangasPage { - return source.getSearchManga(currentPage, query, filters) + return source!!.getSearchManga(currentPage, query, filters) } } class SourcePopularPagingSource(source: CatalogueSource) : SourcePagingSource(source) { override suspend fun requestNextPage(currentPage: Int): MangasPage { - return source.getPopularManga(currentPage) + return source!!.getPopularManga(currentPage) } } class SourceLatestPagingSource(source: CatalogueSource) : SourcePagingSource(source) { override suspend fun requestNextPage(currentPage: Int): MangasPage { - return source.getLatestUpdates(currentPage) + return source!!.getLatestUpdates(currentPage) } } abstract class SourcePagingSource( - protected open val source: CatalogueSource, + protected open val source: CatalogueSource?, ) : SourcePagingSourceType() { abstract suspend fun requestNextPage(currentPage: Int): MangasPage From 9d1f4c2691406acbd089e0954a75b1e5a086381e Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sat, 25 Jan 2025 09:59:18 +0100 Subject: [PATCH 03/21] refactor: turn wake/wifi lock functions into reusable extensions --- .../java/exh/favorites/FavoritesSyncHelper.kt | 27 +++---------------- .../main/java/exh/util/ContextExtensions.kt | 23 ++++++++++++++++ 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt index 39238d2b5691..ed2cb9249951 100644 --- a/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt +++ b/app/src/main/java/exh/favorites/FavoritesSyncHelper.kt @@ -2,13 +2,11 @@ package exh.favorites import android.content.Context import android.net.wifi.WifiManager -import android.os.Build import android.os.PowerManager import eu.kanade.domain.manga.interactor.UpdateManga import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.source.online.all.EHentai -import eu.kanade.tachiyomi.util.system.powerManager import eu.kanade.tachiyomi.util.system.toast import exh.GalleryAddEvent import exh.GalleryAdder @@ -18,8 +16,9 @@ import exh.log.xLog import exh.source.EH_SOURCE_ID import exh.source.EXH_SOURCE_ID import exh.source.isEhBasedManga +import exh.util.createPartialWakeLock +import exh.util.createWifiLock import exh.util.ignore -import exh.util.wifiManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -130,27 +129,9 @@ class FavoritesSyncHelper(val context: Context) { try { // Take wake + wifi locks ignore { wakeLock?.release() } - wakeLock = ignore { - context.powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "teh:ExhFavoritesSyncWakelock", - ) - } + wakeLock = ignore { context.createPartialWakeLock("teh:ExhFavoritesSyncWakelock") } ignore { wifiLock?.release() } - wifiLock = ignore { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - context.wifiManager.createWifiLock( - WifiManager.WIFI_MODE_FULL_LOW_LATENCY, - "teh:ExhFavoritesSyncWifi", - ) - } else { - @Suppress("DEPRECATION") - context.wifiManager.createWifiLock( - WifiManager.WIFI_MODE_FULL_HIGH_PERF, - "teh:ExhFavoritesSyncWifi", - ) - } - } + wifiLock = ignore { context.createWifiLock("teh:ExhFavoritesSyncWifi") } // Do not update galleries while syncing favorites EHentaiUpdateWorker.cancelBackground(context) diff --git a/app/src/main/java/exh/util/ContextExtensions.kt b/app/src/main/java/exh/util/ContextExtensions.kt index 123e35eb1cce..59da5d1bdc10 100644 --- a/app/src/main/java/exh/util/ContextExtensions.kt +++ b/app/src/main/java/exh/util/ContextExtensions.kt @@ -5,7 +5,10 @@ import android.content.ClipboardManager import android.content.Context import android.content.res.Configuration import android.net.wifi.WifiManager +import android.os.Build +import android.os.PowerManager import androidx.core.content.getSystemService +import eu.kanade.tachiyomi.util.system.powerManager /** * Property to get the wifi manager from the context. @@ -24,3 +27,23 @@ val Context.isInNightMode: Boolean val currentNightMode = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK return currentNightMode == Configuration.UI_MODE_NIGHT_YES } + +fun Context.createPartialWakeLock(tag: String): PowerManager.WakeLock = + powerManager.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + tag, + ) + +fun Context.createWifiLock(tag: String): WifiManager.WifiLock = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_LOW_LATENCY, + tag, + ) + } else { + @Suppress("DEPRECATION") + wifiManager.createWifiLock( + WifiManager.WIFI_MODE_FULL_HIGH_PERF, + tag, + ) + } From e2306d483d1800960531146664f2de77fdf35452 Mon Sep 17 00:00:00 2001 From: Tim Schneeberger Date: Sat, 25 Jan 2025 11:15:41 +0100 Subject: [PATCH 04/21] feat: implement batch recommendation (initial version) --- .../manga/components/MangaBottomActionMenu.kt | 11 +- .../ui/library/LibraryScreenModel.kt | 14 ++ .../kanade/tachiyomi/ui/library/LibraryTab.kt | 38 +++++ .../kanade/tachiyomi/ui/manga/MangaScreen.kt | 4 +- .../java/exh/recs/BrowseRecommendsScreen.kt | 18 ++- .../exh/recs/BrowseRecommendsScreenModel.kt | 23 +-- .../main/java/exh/recs/RecommendsScreen.kt | 38 ++++- .../java/exh/recs/RecommendsScreenModel.kt | 27 +++- .../RecommendationSearchBottomSheetDialog.kt | 56 +++++++ .../recs/batch/RecommendationSearchHelper.kt | 150 ++++++++++++++++++ .../RecommendationSearchProgressDialog.kt | 118 ++++++++++++++ .../main/java/exh/recs/batch/SearchFlags.kt | 20 +++ .../exh/recs/components/RecommendsScreen.kt | 7 +- .../recs/sources/StaticResultPagingSource.kt | 16 ++ .../recommendation_search_bottom_sheet.xml | 116 ++++++++++++++ .../tachiyomi/domain/UnsortedPreferences.kt | 2 + .../moko-resources/base/strings.xml | 34 ++++ 17 files changed, 654 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt create mode 100644 app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt create mode 100644 app/src/main/java/exh/recs/batch/SearchFlags.kt create mode 100644 app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt create mode 100644 app/src/main/res/layout/recommendation_search_bottom_sheet.xml diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt index 4ff79362336b..423f6a40de92 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaBottomActionMenu.kt @@ -28,7 +28,6 @@ import androidx.compose.material.icons.outlined.BookmarkRemove import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Download -import androidx.compose.material.icons.outlined.Label import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.SwapCalls @@ -237,6 +236,7 @@ fun LibraryBottomActionMenu( // SY --> onClickCleanTitles: (() -> Unit)?, onClickMigrate: (() -> Unit)?, + onClickCollectRecommendations: (() -> Unit)?, onClickAddToMangaDex: (() -> Unit)?, onClickResetInfo: (() -> Unit)?, // SY <-- @@ -267,7 +267,8 @@ fun LibraryBottomActionMenu( } } // SY --> - val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || onClickResetInfo != null + val showOverflow = onClickCleanTitles != null || onClickAddToMangaDex != null || + onClickResetInfo != null || onClickCollectRecommendations != null val configuration = LocalConfiguration.current val moveMarkPrev = remember { !configuration.isTabletUi() } var overFlowOpen by remember { mutableStateOf(false) } @@ -358,6 +359,12 @@ fun LibraryBottomActionMenu( onClick = onClickMigrate, ) } + if (onClickCollectRecommendations != null) { + DropdownMenuItem( + text = { Text(stringResource(SYMR.strings.az_recommends)) }, + onClick = onClickCollectRecommendations, + ) + } if (onClickAddToMangaDex != null) { DropdownMenuItem( text = { Text(stringResource(SYMR.strings.mangadex_add_to_follows)) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt index ecf813882ad1..c8786b0ebc35 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -42,6 +42,7 @@ import exh.md.utils.FollowStatus import exh.md.utils.MdUtil import exh.metadata.sql.models.SearchTag import exh.metadata.sql.models.SearchTitle +import exh.recs.batch.RecommendationSearchHelper import exh.search.Namespace import exh.search.QueryComponent import exh.search.SearchEngine @@ -160,6 +161,7 @@ class LibraryScreenModel( // SY --> val favoritesSync = FavoritesSyncHelper(preferences.context) + val recommendationSearch = RecommendationSearchHelper(preferences.context) // SY <-- init { @@ -898,6 +900,10 @@ class LibraryScreenModel( } // SY --> + fun showRecommendationSearchDialog() { + val mangaList = state.value.selection.map { it.manga } + mutableState.update { it.copy(dialog = Dialog.RecommendationSearchSheet(mangaList)) } + } fun getCategoryName( context: Context, @@ -1222,8 +1228,12 @@ class LibraryScreenModel( val initialSelection: ImmutableList>, ) : Dialog data class DeleteManga(val manga: List) : Dialog + + // SY --> data object SyncFavoritesWarning : Dialog data object SyncFavoritesConfirm : Dialog + data class RecommendationSearchSheet(val manga: List) : Dialog + // SY <-- } // SY --> @@ -1316,6 +1326,10 @@ class LibraryScreenModel( }.toSortedMap(compareBy { it.order }) } + fun runRecommendationSearch(selection: List) { + recommendationSearch.runSearch(screenModelScope, selection) + } + fun runSync() { favoritesSync.runSync(screenModelScope) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt index 647b7caa2217..42687f6801c2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryTab.kt @@ -51,6 +51,10 @@ import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.reader.ReaderActivity import eu.kanade.tachiyomi.util.system.toast import exh.favorites.FavoritesSyncStatus +import exh.recs.RecommendsScreen +import exh.recs.batch.RecommendationSearchBottomSheetDialog +import exh.recs.batch.RecommendationSearchProgressDialog +import exh.recs.batch.SearchStatus import exh.source.MERGED_SOURCE_ID import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.channels.Channel @@ -205,6 +209,7 @@ data object LibraryTab : Tab { context.toast(SYMR.strings.no_valid_entry) } }, + onClickCollectRecommendations = screenModel::showRecommendationSearchDialog.takeIf { state.selection.size > 1 }, onClickAddToMangaDex = screenModel::syncMangaToDex.takeIf { state.showAddToMangadex }, onClickResetInfo = screenModel::resetInfo.takeIf { state.showResetInfo }, // SY <-- @@ -310,6 +315,7 @@ data object LibraryTab : Tab { }, ) } + // SY --> LibraryScreenModel.Dialog.SyncFavoritesWarning -> { SyncFavoritesWarningDialog( onDismissRequest = onDismissRequest, @@ -328,6 +334,17 @@ data object LibraryTab : Tab { }, ) } + is LibraryScreenModel.Dialog.RecommendationSearchSheet -> { + RecommendationSearchBottomSheetDialog( + onDismissRequest = onDismissRequest, + onSearchRequest = { + onDismissRequest() + screenModel.clearSelection() + screenModel.runRecommendationSearch(dialog.manga) + } + ) + } + // SY <-- null -> {} } @@ -337,6 +354,11 @@ data object LibraryTab : Tab { setStatusIdle = { screenModel.favoritesSync.status.value = FavoritesSyncStatus.Idle }, openManga = { navigator.push(MangaScreen(it)) }, ) + + RecommendationSearchProgressDialog( + status = screenModel.recommendationSearch.status.collectAsState().value, + setStatusIdle = { screenModel.recommendationSearch.status.value = SearchStatus.Idle }, + ) // SY <-- BackHandler(enabled = state.selectionMode || state.searchQuery != null) { @@ -356,6 +378,22 @@ data object LibraryTab : Tab { } } + // SY --> + val recSearchState by screenModel.recommendationSearch.status.collectAsState() + LaunchedEffect(recSearchState) { + when (val current = recSearchState) { + is SearchStatus.Finished -> { + RecommendsScreen.Args.MergedSourceMangas(current.results) + .let(::RecommendsScreen) + .let(navigator::push) + + screenModel.recommendationSearch.status.value = SearchStatus.Idle + } + else -> {} + } + } + // SY <-- + LaunchedEffect(Unit) { launch { queryEvent.receiveAsFlow().collect(screenModel::search) } launch { requestSettingsSheetEvent.receiveAsFlow().collectLatest { screenModel.showSettingsDialog() } } 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 25c347f35945..78bf597c3f66 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 @@ -548,7 +548,9 @@ class MangaScreen( // AZ --> private fun openRecommends(navigator: Navigator, source: Source?, manga: Manga) { source ?: return - navigator.push(RecommendsScreen(manga.id, source.id)) + RecommendsScreen.Args.SingleSourceManga(manga.id, source.id) + .let(::RecommendsScreen) + .let(navigator::push) } // AZ <-- } diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt index a22a549e3c74..e47bf6b4f00e 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreen.kt @@ -15,6 +15,7 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import exh.recs.batch.SearchResults import exh.ui.ifSourcesLoaded import mihon.presentation.core.util.collectAsLazyPagingItems import tachiyomi.domain.manga.model.Manga @@ -22,12 +23,19 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.screens.LoadingScreen class BrowseRecommendsScreen( - private val mangaId: Long, - private val sourceId: Long, - private val recommendationSourceName: String, + private val args: Args, private val isExternalSource: Boolean, ) : Screen() { + sealed interface Args { + data class SingleSourceManga( + val mangaId: Long, + val sourceId: Long, + val recommendationSourceName: String, + ) : Args + data class MergedSourceMangas(val results: SearchResults) : Args + } + @Composable override fun Content() { if (!ifSourcesLoaded()) { @@ -38,9 +46,7 @@ class BrowseRecommendsScreen( val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { - BrowseRecommendsScreenModel(mangaId, sourceId, recommendationSourceName) - } + val screenModel = rememberScreenModel { BrowseRecommendsScreenModel(args) } val snackbarHostState = remember { SnackbarHostState() } val onClickItem = { manga: Manga -> diff --git a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt index b0b5074de213..be57f7203c01 100644 --- a/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/BrowseRecommendsScreenModel.kt @@ -4,6 +4,7 @@ import eu.kanade.tachiyomi.source.CatalogueSource import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.StaticResultPagingSource import kotlinx.coroutines.flow.update import kotlinx.coroutines.runBlocking import tachiyomi.domain.manga.interactor.GetManga @@ -11,17 +12,21 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get class BrowseRecommendsScreenModel( - val mangaId: Long, - sourceId: Long, - private val recommendationSourceName: String, + private val args: BrowseRecommendsScreen.Args, private val getManga: GetManga = Injekt.get(), -) : BrowseSourceScreenModel(sourceId, null) { - - val manga = runBlocking { getManga.await(mangaId) }!! - +) : BrowseSourceScreenModel( + if(args is BrowseRecommendsScreen.Args.SingleSourceManga) args.sourceId else -1, + null +) { val recommendationSource: RecommendationPagingSource - get() = RecommendationPagingSource.createSources(manga, source as CatalogueSource).first { - it::class.qualifiedName == recommendationSourceName + get() = when (args) { + is BrowseRecommendsScreen.Args.MergedSourceMangas -> StaticResultPagingSource(args.results) + is BrowseRecommendsScreen.Args.SingleSourceManga -> RecommendationPagingSource.createSources( + runBlocking { getManga.await(args.mangaId)!! }, + source as CatalogueSource + ).first { + it::class.qualifiedName == args.recommendationSourceName + } } override fun createSourcePagingSource(query: String, filters: FilterList) = recommendationSource diff --git a/app/src/main/java/exh/recs/RecommendsScreen.kt b/app/src/main/java/exh/recs/RecommendsScreen.kt index 526cdcab2751..8b04dbbe003f 100644 --- a/app/src/main/java/exh/recs/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/RecommendsScreen.kt @@ -11,12 +11,23 @@ import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.ui.browse.source.SourcesScreen import eu.kanade.tachiyomi.ui.manga.MangaScreen import eu.kanade.tachiyomi.ui.webview.WebViewActivity +import exh.recs.RecommendsScreen.Args.MergedSourceMangas +import exh.recs.RecommendsScreen.Args.SingleSourceManga +import exh.recs.batch.SearchResults import exh.recs.components.RecommendsScreen +import exh.recs.sources.StaticResultPagingSource import exh.ui.ifSourcesLoaded import tachiyomi.domain.manga.model.Manga +import tachiyomi.i18n.sy.SYMR +import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.screens.LoadingScreen -class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { +class RecommendsScreen(private val args: Args) : Screen() { + + sealed interface Args { + data class SingleSourceManga(val mangaId: Long, val sourceId: Long) : Args + data class MergedSourceMangas(val mergedResults: Map) : Args + } @Composable override fun Content() { @@ -28,9 +39,7 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { val context = LocalContext.current val navigator = LocalNavigator.currentOrThrow - val screenModel = rememberScreenModel { - RecommendsScreenModel(mangaId = mangaId, sourceId = sourceId) - } + val screenModel = rememberScreenModel { RecommendsScreenModel(args) } val state by screenModel.state.collectAsState() val onClickItem = { manga: Manga -> @@ -50,7 +59,11 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { } RecommendsScreen( - manga = state.manga, + title = if(args is SingleSourceManga) { + stringResource(SYMR.strings.similar, state.title.orEmpty()) + } else { + stringResource(SYMR.strings.rec_common_recommendations) + }, state = state, navigateUp = navigator::pop, getManga = @Composable { manga: Manga -> screenModel.getManga(manga) }, @@ -58,9 +71,18 @@ class RecommendsScreen(val mangaId: Long, val sourceId: Long) : Screen() { // Pass class name of paging source as screens need to be serializable navigator.push( BrowseRecommendsScreen( - mangaId, - sourceId, - pagingSource::class.qualifiedName!!, + when (args) { + is SingleSourceManga -> + BrowseRecommendsScreen.Args.SingleSourceManga( + args.mangaId, + args.sourceId, + pagingSource::class.qualifiedName!! + ) + is MergedSourceMangas -> + BrowseRecommendsScreen.Args.MergedSourceMangas( + (pagingSource as StaticResultPagingSource).results + ) + }, pagingSource.associatedSourceId == null, ), ) diff --git a/app/src/main/java/exh/recs/RecommendsScreenModel.kt b/app/src/main/java/exh/recs/RecommendsScreenModel.kt index c499e3a85b49..70a56bb0b093 100644 --- a/app/src/main/java/exh/recs/RecommendsScreenModel.kt +++ b/app/src/main/java/exh/recs/RecommendsScreenModel.kt @@ -8,6 +8,7 @@ import eu.kanade.domain.manga.model.toDomainManga import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.tachiyomi.source.CatalogueSource import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.StaticResultPagingSource import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.mutate import kotlinx.collections.immutable.persistentMapOf @@ -30,15 +31,12 @@ import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get open class RecommendsScreenModel( - val mangaId: Long, - val sourceId: Long, + private val args: RecommendsScreen.Args, sourceManager: SourceManager = Injekt.get(), private val getManga: GetManga = Injekt.get(), private val networkToLocalManga: NetworkToLocalManga = Injekt.get(), ) : StateScreenModel(State()) { - val source = sourceManager.getOrStub(sourceId) as CatalogueSource - private val coroutineDispatcher = Dispatchers.IO.limitedParallelism(5) private val sortComparator = { map: Map -> @@ -51,9 +49,22 @@ open class RecommendsScreenModel( init { ioCoroutineScope.launch { - val manga = getManga.await(mangaId)!! - mutableState.update { it.copy(manga = manga) } - val recommendationSources = RecommendationPagingSource.createSources(manga, source) + val recommendationSources = when (args) { + is RecommendsScreen.Args.SingleSourceManga -> { + val manga = getManga.await(args.mangaId)!! + mutableState.update { it.copy(title = manga.title) } + + RecommendationPagingSource.createSources( + manga, + sourceManager.getOrStub(args.sourceId) as CatalogueSource + ) + } + is RecommendsScreen.Args.MergedSourceMangas -> { + args.mergedResults + .map { it.value } + .map { StaticResultPagingSource(it) } + } + } updateItems( recommendationSources @@ -126,7 +137,7 @@ open class RecommendsScreenModel( @Immutable data class State( - val manga: Manga? = null, + val title: String? = null, val items: PersistentMap = persistentMapOf(), ) { val progress: Int = items.count { it.value !is RecommendationItemResult.Loading } diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt new file mode 100644 index 000000000000..dc7dbca831e2 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchBottomSheetDialog.kt @@ -0,0 +1,56 @@ +package exh.recs.batch + +import android.view.LayoutInflater +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import eu.kanade.presentation.components.AdaptiveSheet +import eu.kanade.tachiyomi.databinding.RecommendationSearchBottomSheetBinding +import tachiyomi.domain.UnsortedPreferences +import uy.kohesive.injekt.injectLazy + +@Composable +fun RecommendationSearchBottomSheetDialog( + onDismissRequest: () -> Unit, + onSearchRequest: () -> Unit +) { + val state = remember { RecommendationSearchBottomSheetDialogState(onSearchRequest) } + AdaptiveSheet(onDismissRequest = onDismissRequest) { + AndroidView( + factory = { factoryContext -> + val binding = RecommendationSearchBottomSheetBinding.inflate(LayoutInflater.from(factoryContext)) + state.initPreferences(binding) + binding.root + }, + modifier = Modifier.fillMaxWidth(), + ) + } +} + +class RecommendationSearchBottomSheetDialogState(private val onSearchRequest: () -> Unit) { + private val preferences: UnsortedPreferences by injectLazy() + + fun initPreferences(binding: RecommendationSearchBottomSheetBinding) { + val flags = preferences.recommendationSearchFlags().get() + + binding.recSources.isChecked = SearchFlags.hasIncludeSources(flags) + binding.recTrackers.isChecked = SearchFlags.hasIncludeTrackers(flags) + binding.recHideLibraryEntries.isChecked = SearchFlags.hasHideLibraryResults(flags) + + binding.recSources.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + binding.recTrackers.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + binding.recHideLibraryEntries.setOnCheckedChangeListener { _, _ -> setFlags(binding) } + + binding.recSearchBtn.setOnClickListener { _ -> onSearchRequest() } + } + + private fun setFlags(binding: RecommendationSearchBottomSheetBinding) { + var flags = 0 + if (binding.recSources.isChecked) flags = flags or SearchFlags.INCLUDE_SOURCES + if (binding.recTrackers.isChecked) flags = flags or SearchFlags.INCLUDE_TRACKERS + if (binding.recHideLibraryEntries.isChecked) flags = flags or SearchFlags.HIDE_LIBRARY_RESULTS + preferences.recommendationSearchFlags().set(flags) + } +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt new file mode 100644 index 000000000000..000e5b25980d --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchHelper.kt @@ -0,0 +1,150 @@ +package exh.recs.batch + +import android.content.Context +import android.net.wifi.WifiManager +import android.os.PowerManager +import dev.icerock.moko.resources.StringResource +import eu.kanade.domain.manga.model.toSManga +import eu.kanade.tachiyomi.source.CatalogueSource +import eu.kanade.tachiyomi.source.model.SManga +import exh.log.xLog +import exh.recs.sources.RecommendationPagingSource +import exh.recs.sources.TrackerRecommendationPagingSource +import exh.util.createPartialWakeLock +import exh.util.createWifiLock +import exh.util.ignore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import tachiyomi.data.source.NoResultsException +import tachiyomi.domain.UnsortedPreferences +import tachiyomi.domain.manga.interactor.GetLibraryManga +import tachiyomi.domain.manga.interactor.GetManga +import tachiyomi.domain.manga.model.Manga +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.injectLazy +import java.util.Collections +import kotlin.coroutines.coroutineContext + +typealias RecommendationMap = Map + +class RecommendationSearchHelper(val context: Context) { + private val getLibraryManga: GetLibraryManga by injectLazy() + private val getManga: GetManga by injectLazy() + private val sourceManager: SourceManager by injectLazy() + + private val prefs: UnsortedPreferences by injectLazy() + + private var wifiLock: WifiManager.WifiLock? = null + private var wakeLock: PowerManager.WakeLock? = null + + private val logger by lazy { xLog() } + + val status: MutableStateFlow = MutableStateFlow(SearchStatus.Idle) + + @Synchronized + fun runSearch(scope: CoroutineScope, mangaList: List) { + if (status.value !is SearchStatus.Idle) { + return + } + + status.value = SearchStatus.Initializing + + scope.launch(Dispatchers.IO) { beginSearch(mangaList) } + } + + private suspend fun beginSearch(mangaList: List) { + val libraryManga = getLibraryManga.await() + val flags = prefs.recommendationSearchFlags().get() + + try { + // Take wake + wifi locks + ignore { wakeLock?.release() } + wakeLock = ignore { context.createPartialWakeLock("tsy:RecommendationSearchWakelock") } + ignore { wifiLock?.release() } + wifiLock = ignore { context.createWifiLock("tsy:RecommendationSearchWifiLock") } + + // Map of results grouped by recommendation source + val resultsMap = Collections.synchronizedMap(mutableMapOf()) + + mangaList.forEachIndexed { index, sourceManga -> + status.value = SearchStatus.Processing(sourceManga.toSManga(), index + 1, mangaList.size) + + val jobs = RecommendationPagingSource.createSources( + sourceManga, + sourceManager.get(sourceManga.source) as CatalogueSource + ).mapNotNull { source -> + + if (source is TrackerRecommendationPagingSource && !SearchFlags.hasIncludeTrackers(flags)) { + return@mapNotNull null + } + + if (source.associatedSourceId != null && !SearchFlags.hasIncludeSources(flags)) { + return@mapNotNull null + } + + // Parallelize fetching recommendations from all sources in the current context + CoroutineScope(coroutineContext).async(Dispatchers.IO) { + val recSourceId = source::class.qualifiedName!! + + try { + val page = source.requestNextPage(1) + + // Add or update the result collection for the current source + resultsMap.getOrPut(recSourceId) { + SearchResults( + recSourceName = source.name, + recSourceCategory = source.category, + recAssociatedSourceId = source.associatedSourceId, + recommendations = mutableListOf() + ) + }.recommendations.addAll(page.mangas) + + } + catch (_: NoResultsException) {} + catch (e: Exception) { + logger.e("Error while fetching recommendations for $recSourceId", e) + } + } + } + + //TODO filter library manga + jobs.awaitAll() + } + + status.value = SearchStatus.Finished(resultsMap) + } catch (e: Exception) { + status.value = SearchStatus.Error(e.message.orEmpty()) + logger.e("Error during recommendation search", e) + return + } finally { + // Release wake + wifi locks + ignore { + wakeLock?.release() + wakeLock = null + } + ignore { + wifiLock?.release() + wifiLock = null + } + } + } +} + +data class SearchResults( + val recSourceName: String, + val recSourceCategory: StringResource, + val recAssociatedSourceId: Long?, + val recommendations: MutableList +) + +sealed interface SearchStatus { + data object Idle : SearchStatus + data object Initializing : SearchStatus + data class Processing(val manga: SManga, val current: Int, val total: Int) : SearchStatus + data class Error(val message: String) : SearchStatus + data class Finished(val results: RecommendationMap) : SearchStatus +} diff --git a/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt new file mode 100644 index 000000000000..0c7c0b3304bb --- /dev/null +++ b/app/src/main/java/exh/recs/batch/RecommendationSearchProgressDialog.kt @@ -0,0 +1,118 @@ +package exh.recs.batch + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties +import tachiyomi.core.common.i18n.stringResource +import tachiyomi.i18n.MR +import tachiyomi.i18n.sy.SYMR + +data class RecommendationSearchProgressProperties( + val title: String, + val text: String, + val positiveButtonText: String? = null, + val positiveButton: (() -> Unit)? = null, + val negativeButtonText: String? = null, + val negativeButton: (() -> Unit)? = null, +) + +@Composable +fun RecommendationSearchProgressDialog( + status: SearchStatus, + setStatusIdle: () -> Unit, +) { + val context = LocalContext.current + val currentView = LocalView.current + DisposableEffect(Unit) { + currentView.keepScreenOn = true + onDispose { + currentView.keepScreenOn = false + } + } + + val properties by produceState(initialValue = null, status) { + value = when (status) { + is SearchStatus.Idle -> null + is SearchStatus.Finished -> null + is SearchStatus.Initializing -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_collecting), + text = context.stringResource(SYMR.strings.rec_initializing), + negativeButtonText = context.stringResource(MR.strings.action_cancel), + negativeButton = setStatusIdle, + ) + } + is SearchStatus.Error -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_error_title), + text = context.stringResource(SYMR.strings.rec_error_string, status.message), + positiveButtonText = context.stringResource(MR.strings.action_ok), + positiveButton = setStatusIdle, + ) + } + is SearchStatus.Processing -> { + RecommendationSearchProgressProperties( + title = context.stringResource(SYMR.strings.rec_collecting), + text = context.stringResource(SYMR.strings.rec_processing_state, status.current, status.total) + "\n\n" + status.manga.title, + negativeButtonText = context.stringResource(MR.strings.action_cancel), + negativeButton = setStatusIdle, + ) + } + } + } + val dialog = properties + if (dialog != null) { + AlertDialog( + onDismissRequest = {}, + confirmButton = { + if (dialog.positiveButton != null && dialog.positiveButtonText != null) { + TextButton(onClick = dialog.positiveButton) { + Text(text = dialog.positiveButtonText) + } + } + }, + dismissButton = { + if (dialog.negativeButton != null && dialog.negativeButtonText != null) { + TextButton(onClick = dialog.negativeButton) { + Text(text = dialog.negativeButtonText) + } + } + }, + title = { + Text(text = dialog.title) + }, + text = { + Column( + Modifier.verticalScroll(rememberScrollState()), + ) { + Text(text = dialog.text) + if (status is SearchStatus.Processing) { + LinearProgressIndicator( + progress = { status.current.toFloat() / status.total }, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp) + ) + } + } + }, + properties = DialogProperties( + dismissOnClickOutside = false, + dismissOnBackPress = false, + ), + ) + } +} diff --git a/app/src/main/java/exh/recs/batch/SearchFlags.kt b/app/src/main/java/exh/recs/batch/SearchFlags.kt new file mode 100644 index 000000000000..589459cc4151 --- /dev/null +++ b/app/src/main/java/exh/recs/batch/SearchFlags.kt @@ -0,0 +1,20 @@ +package exh.recs.batch + +object SearchFlags { + + const val INCLUDE_SOURCES = 0b00001 + const val INCLUDE_TRACKERS = 0b00010 + const val HIDE_LIBRARY_RESULTS = 0b00100 + + fun hasIncludeSources(value: Int): Boolean { + return value and INCLUDE_SOURCES != 0 + } + + fun hasIncludeTrackers(value: Int): Boolean { + return value and INCLUDE_TRACKERS != 0 + } + + fun hasHideLibraryResults(value: Int): Boolean { + return value and HIDE_LIBRARY_RESULTS != 0 + } +} diff --git a/app/src/main/java/exh/recs/components/RecommendsScreen.kt b/app/src/main/java/exh/recs/components/RecommendsScreen.kt index c8fcf57499d6..3e0e62066890 100644 --- a/app/src/main/java/exh/recs/components/RecommendsScreen.kt +++ b/app/src/main/java/exh/recs/components/RecommendsScreen.kt @@ -17,13 +17,12 @@ import exh.recs.sources.RecommendationPagingSource import kotlinx.collections.immutable.ImmutableMap import nl.adaptivity.xmlutil.core.impl.multiplatform.name import tachiyomi.domain.manga.model.Manga -import tachiyomi.i18n.sy.SYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource @Composable fun RecommendsScreen( - manga: Manga?, + title: String, state: RecommendsScreenModel.State, navigateUp: () -> Unit, getManga: @Composable (Manga) -> State, @@ -34,7 +33,7 @@ fun RecommendsScreen( Scaffold( topBar = { scrollBehavior -> AppBar( - title = stringResource(SYMR.strings.similar, manga?.title.orEmpty()), + title = title, scrollBehavior = scrollBehavior, navigateUp = navigateUp, ) @@ -64,7 +63,7 @@ internal fun RecommendsContent( contentPadding = contentPadding, ) { items.forEach { (source, recResult) -> - item(key = source::class.name) { + item(key = "${source::class.name}-${source.name}-${source.category.resourceId}") { GlobalSearchResultItem( title = source.name, subtitle = stringResource(source.category), diff --git a/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt new file mode 100644 index 000000000000..2372528f414a --- /dev/null +++ b/app/src/main/java/exh/recs/sources/StaticResultPagingSource.kt @@ -0,0 +1,16 @@ +package exh.recs.sources + +import dev.icerock.moko.resources.StringResource +import eu.kanade.tachiyomi.source.model.MangasPage +import exh.recs.batch.SearchResults +import tachiyomi.domain.manga.model.Manga + +class StaticResultPagingSource( + val results: SearchResults +) : RecommendationPagingSource(Manga.create()) { + override val name: String get() = results.recSourceName + override val category: StringResource get() = results.recSourceCategory + override val associatedSourceId: Long? get() = results.recAssociatedSourceId + + override suspend fun requestNextPage(currentPage: Int) = MangasPage(results.recommendations, false) +} diff --git a/app/src/main/res/layout/recommendation_search_bottom_sheet.xml b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml new file mode 100644 index 000000000000..12c745b8df70 --- /dev/null +++ b/app/src/main/res/layout/recommendation_search_bottom_sheet.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +