From 7a81f4200471307b531402361fb199306e63450d Mon Sep 17 00:00:00 2001 From: secozzi Date: Fri, 12 Sep 2025 10:34:01 +0200 Subject: [PATCH 01/10] Implement ext lib 16-rc3 --- .../java/eu/kanade/domain/DomainModule.kt | 16 +- .../anime/interactor/SyncSeasonsWithSource.kt | 112 ++++ .../eu/kanade/domain/anime/model/Anime.kt | 29 + .../kanade/presentation/anime/AnimeScreen.kt | 614 ++++++++++++------ .../anime/EpisodeSettingsDialog.kt | 19 +- .../anime/SeasonSettingsDialog.kt | 332 ++++++++++ .../anime/components/AnimeInfoHeader.kt | 28 +- .../anime/components/AnimeSeasonListItem.kt | 131 ++++ .../{EpisodeHeader.kt => ItemHeader.kt} | 23 +- .../library/components/CommonAnimeItem.kt | 4 +- .../settings/screen/SettingsLibraryScreen.kt | 24 + .../screen/advanced/ClearDatabaseScreen.kt | 72 +- .../create/creators/AnimeBackupCreator.kt | 8 + .../data/backup/models/BackupAnime.kt | 18 + .../data/backup/restore/BackupRestorer.kt | 5 +- .../backup/restore/restorers/AnimeRestorer.kt | 40 +- .../data/library/LibraryUpdateJob.kt | 40 +- .../java/eu/kanade/tachiyomi/di/AppModule.kt | 8 + .../tachiyomi/source/AndroidSourceManager.kt | 3 + .../kanade/tachiyomi/ui/anime/AnimeScreen.kt | 60 +- .../tachiyomi/ui/anime/AnimeScreenModel.kt | 501 +++++++++++++- .../tachiyomi/ui/anime/AnimeSeasonItem.kt | 14 + .../ui/library/LibraryScreenModel.kt | 4 +- .../tachiyomi/ui/player/PlayerViewModel.kt | 10 +- .../ui/player/loader/EpisodeLoader.kt | 21 +- .../tachiyomi/ui/stats/StatsScreenModel.kt | 4 +- .../eu/kanade/tachiyomi/network/Requests.kt | 57 ++ .../java/tachiyomi/data/DatabaseAdapter.kt | 10 + .../java/tachiyomi/data/anime/AnimeMapper.kt | 137 +++- .../data/anime/AnimeRepositoryImpl.kt | 47 ++ .../data/source/SourceRepositoryImpl.kt | 32 +- .../main/sqldelight/tachiyomi/data/animes.sq | 53 +- .../sqldelight/tachiyomi/migrations/133.sqm | 177 +++++ .../tachiyomi/view/animedeletableView.sq | 14 + .../tachiyomi/view/animeseasonsView.sq | 71 ++ .../tachiyomi/view/episodestatsView.sq | 19 + .../tachiyomi/view/historystatsView.sq | 9 + .../sqldelight/tachiyomi/view/libraryView.sq | 97 ++- .../java/aniyomi/domain/anime/SeasonAnime.kt | 41 ++ .../aniyomi/domain/anime/SeasonDisplayMode.kt | 29 + .../java/mihon/domain/anime/model/SAnime.kt | 4 + ...s.kt => GetAnimeWithEpisodesAndSeasons.kt} | 18 +- .../anime/interactor/SetAnimeSeasonFlags.kt | 185 ++++++ .../tachiyomi/domain/anime/model/Anime.kt | 141 ++++ .../domain/anime/model/AnimeUpdate.kt | 15 + .../domain/anime/model/NoSeasonsException.kt | 5 + .../anime/repository/AnimeRepository.kt | 14 + .../domain/episode/service/MissingEpisodes.kt | 30 +- .../domain/library/model/LibraryAnime.kt | 4 +- .../library/service/LibraryPreferences.kt | 98 +++ .../interactor/GetAnimeSeasonsByParentId.kt | 21 + .../interactor/SetAnimeDefaultSeasonFlags.kt | 47 ++ .../season/interactor/ShouldUpdateDbSeason.kt | 13 + .../season/service/SeasonRecognition.kt | 139 ++++ .../domain/season/service/SeasonSorter.kt | 38 ++ .../GetSourcesWithNonLibraryAnime.kt | 17 +- .../domain/source/model/DeletableAnime.kt | 11 + .../domain/source/model/SourceWithCount.kt | 13 - .../domain/source/model/SourceWithIds.kt | 20 + .../domain/source/model/StubSource.kt | 4 + .../source/repository/SourceRepository.kt | 4 +- .../episode/service/MissingEpisodesTest.kt | 8 +- .../moko-resources/base/plurals.xml | 8 + .../moko-resources/base/strings.xml | 16 + .../presentation/core/util/LazyListState.kt | 15 + .../tachiyomi/animesource/AnimeSource.kt | 9 + .../tachiyomi/animesource/model/FetchType.kt | 21 + .../tachiyomi/animesource/model/Hoster.kt | 7 +- .../tachiyomi/animesource/model/SAnime.kt | 10 + .../tachiyomi/animesource/model/SAnimeImpl.kt | 4 + .../tachiyomi/animesource/model/Video.kt | 117 +--- .../animesource/online/AnimeHttpSource.kt | 2 +- .../source/local/LocalFetchTypeManager.kt | 23 + .../tachiyomi/source/local/LocalSource.kt | 60 +- .../source/local/LocalFetchTypeManager.kt | 7 + 75 files changed, 3569 insertions(+), 512 deletions(-) create mode 100644 app/src/main/java/eu/kanade/domain/anime/interactor/SyncSeasonsWithSource.kt create mode 100644 app/src/main/java/eu/kanade/presentation/anime/SeasonSettingsDialog.kt create mode 100644 app/src/main/java/eu/kanade/presentation/anime/components/AnimeSeasonListItem.kt rename app/src/main/java/eu/kanade/presentation/anime/components/{EpisodeHeader.kt => ItemHeader.kt} (75%) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeSeasonItem.kt create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/133.sqm create mode 100644 data/src/main/sqldelight/tachiyomi/view/animedeletableView.sq create mode 100644 data/src/main/sqldelight/tachiyomi/view/animeseasonsView.sq create mode 100644 data/src/main/sqldelight/tachiyomi/view/episodestatsView.sq create mode 100644 data/src/main/sqldelight/tachiyomi/view/historystatsView.sq create mode 100644 domain/src/main/java/aniyomi/domain/anime/SeasonAnime.kt create mode 100644 domain/src/main/java/aniyomi/domain/anime/SeasonDisplayMode.kt rename domain/src/main/java/tachiyomi/domain/anime/interactor/{GetAnimeWithEpisodes.kt => GetAnimeWithEpisodesAndSeasons.kt} (66%) create mode 100644 domain/src/main/java/tachiyomi/domain/anime/interactor/SetAnimeSeasonFlags.kt create mode 100644 domain/src/main/java/tachiyomi/domain/anime/model/NoSeasonsException.kt create mode 100644 domain/src/main/java/tachiyomi/domain/season/interactor/GetAnimeSeasonsByParentId.kt create mode 100644 domain/src/main/java/tachiyomi/domain/season/interactor/SetAnimeDefaultSeasonFlags.kt create mode 100644 domain/src/main/java/tachiyomi/domain/season/interactor/ShouldUpdateDbSeason.kt create mode 100644 domain/src/main/java/tachiyomi/domain/season/service/SeasonRecognition.kt create mode 100644 domain/src/main/java/tachiyomi/domain/season/service/SeasonSorter.kt create mode 100644 domain/src/main/java/tachiyomi/domain/source/model/DeletableAnime.kt delete mode 100644 domain/src/main/java/tachiyomi/domain/source/model/SourceWithCount.kt create mode 100644 domain/src/main/java/tachiyomi/domain/source/model/SourceWithIds.kt create mode 100644 source-api/src/commonMain/kotlin/eu/kanade/tachiyomi/animesource/model/FetchType.kt create mode 100644 source-local/src/androidMain/kotlin/tachiyomi/source/local/LocalFetchTypeManager.kt create mode 100644 source-local/src/commonMain/kotlin/tachiyomi/source/local/LocalFetchTypeManager.kt diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 979d62bccb..0bbd087404 100644 --- a/app/src/main/java/eu/kanade/domain/DomainModule.kt +++ b/app/src/main/java/eu/kanade/domain/DomainModule.kt @@ -4,6 +4,7 @@ import android.app.Application import eu.kanade.domain.anime.interactor.GetExcludedScanlators import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.anime.interactor.SetExcludedScanlators +import eu.kanade.domain.anime.interactor.SyncSeasonsWithSource import eu.kanade.domain.anime.interactor.UpdateAnime import eu.kanade.domain.download.interactor.DeleteDownload import eu.kanade.domain.episode.interactor.GetAvailableScanlators @@ -53,7 +54,7 @@ import tachiyomi.data.updates.UpdatesRepositoryImpl import tachiyomi.domain.anime.interactor.FetchInterval import tachiyomi.domain.anime.interactor.GetAnime import tachiyomi.domain.anime.interactor.GetAnimeByUrlAndSourceId -import tachiyomi.domain.anime.interactor.GetAnimeWithEpisodes +import tachiyomi.domain.anime.interactor.GetAnimeWithEpisodesAndSeasons import tachiyomi.domain.anime.interactor.GetCustomAnimeInfo import tachiyomi.domain.anime.interactor.GetDuplicateLibraryAnime import tachiyomi.domain.anime.interactor.GetFavorites @@ -61,6 +62,7 @@ import tachiyomi.domain.anime.interactor.GetLibraryAnime import tachiyomi.domain.anime.interactor.NetworkToLocalAnime import tachiyomi.domain.anime.interactor.ResetViewerFlags import tachiyomi.domain.anime.interactor.SetAnimeEpisodeFlags +import tachiyomi.domain.anime.interactor.SetAnimeSeasonFlags import tachiyomi.domain.anime.interactor.SetCustomAnimeInfo import tachiyomi.domain.anime.interactor.UpdateAnimeNotes import tachiyomi.domain.anime.repository.AnimeRepository @@ -99,6 +101,9 @@ import tachiyomi.domain.history.interactor.UpsertHistory import tachiyomi.domain.history.repository.HistoryRepository import tachiyomi.domain.release.interactor.GetApplicationRelease import tachiyomi.domain.release.service.ReleaseService +import tachiyomi.domain.season.interactor.GetAnimeSeasonsByParentId +import tachiyomi.domain.season.interactor.SetAnimeDefaultSeasonFlags +import tachiyomi.domain.season.interactor.ShouldUpdateDbSeason import tachiyomi.domain.source.interactor.GetRemoteAnime import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryAnime import tachiyomi.domain.source.repository.SourceRepository @@ -138,7 +143,6 @@ class DomainModule : InjektModule { addFactory { GetDuplicateLibraryAnime(get()) } addFactory { GetFavorites(get()) } addFactory { GetLibraryAnime(get()) } - addFactory { GetAnimeWithEpisodes(get(), get()) } addFactory { GetAnimeByUrlAndSourceId(get()) } addFactory { GetAnime(get()) } addFactory { GetNextEpisodes(get(), get(), get()) } @@ -159,6 +163,14 @@ class DomainModule : InjektModule { get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), ) } + // AY --> + addFactory { GetAnimeWithEpisodesAndSeasons(get(), get()) } + addFactory { GetAnimeSeasonsByParentId(get()) } + addFactory { SetAnimeSeasonFlags(get()) } + addFactory { SetAnimeDefaultSeasonFlags(get(), get(), get()) } + addFactory { ShouldUpdateDbSeason() } + addFactory { SyncSeasonsWithSource(get(), get(), get(), get(), get()) } + // <-- AY addSingletonFactory { ReleaseServiceImpl(get(), get()) } addFactory { GetApplicationRelease(get(), get()) } diff --git a/app/src/main/java/eu/kanade/domain/anime/interactor/SyncSeasonsWithSource.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/SyncSeasonsWithSource.kt new file mode 100644 index 0000000000..72ce87bc35 --- /dev/null +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/SyncSeasonsWithSource.kt @@ -0,0 +1,112 @@ +// AY --> +package eu.kanade.domain.anime.interactor + +import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.animesource.model.SAnime +import mihon.domain.anime.model.toDomainAnime +import tachiyomi.domain.anime.interactor.NetworkToLocalAnime +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.anime.model.NoSeasonsException +import tachiyomi.domain.anime.model.toAnimeUpdate +import tachiyomi.domain.anime.repository.AnimeRepository +import tachiyomi.domain.season.interactor.GetAnimeSeasonsByParentId +import tachiyomi.domain.season.interactor.ShouldUpdateDbSeason +import tachiyomi.domain.season.service.SeasonRecognition +import tachiyomi.source.local.isLocal +import java.time.ZonedDateTime + +class SyncSeasonsWithSource( + private val updateAnime: UpdateAnime, + private val animeRepository: AnimeRepository, + private val networkToLocalAnime: NetworkToLocalAnime, + private val shouldUpdateDbSeason: ShouldUpdateDbSeason, + private val getAnimeSeasonsByParentId: GetAnimeSeasonsByParentId, +) { + suspend fun await( + rawSourceSeasons: List, + anime: Anime, + source: AnimeSource, + manualFetch: Boolean = false, + fetchWindow: Pair = Pair(0, 0), + ): List { + if (rawSourceSeasons.isEmpty() && !source.isLocal()) { + throw NoSeasonsException() + } + + val now = ZonedDateTime.now() + + val sourceSeasons = rawSourceSeasons + .distinctBy { it.url } + .mapIndexed { i, sAnime -> + networkToLocalAnime.invoke(sAnime.toDomainAnime(source.id)) + .copy(parentId = anime.id, seasonSourceOrder = i.toLong()) + } + + val dbSeasons = getAnimeSeasonsByParentId.await(anime.id) + + val newSeasons = mutableListOf() + val updatedSeasons = mutableListOf() + val removedSeasons = dbSeasons.filterNot { dbSeasons -> + sourceSeasons.any { sourceSeason -> + dbSeasons.anime.url == sourceSeason.url + } + } + + for (sourceSeason in sourceSeasons) { + var season = sourceSeason + + // Recognize season number for the season + val seasonNumber = SeasonRecognition.parseSeasonNumber( + anime.title, + season.title, + season.seasonNumber, + ) + season = season.copy(seasonNumber = seasonNumber) + + val dbSeason = dbSeasons.find { it.anime.url == season.url }?.anime + if (dbSeason == null) { + newSeasons.add(season) + } else { + if (shouldUpdateDbSeason.await(dbSeason, season)) { + val toChangeSeason = dbSeason.copy( + // AM (CUSTOM_INFORMATION) --> + ogTitle = season.title, + // <-- AM (CUSTOM_INFORMATION) + seasonNumber = season.seasonNumber, + seasonSourceOrder = season.seasonSourceOrder, + ) + updatedSeasons.add(toChangeSeason) + } + } + } + + // Return if there's nothing to add, delete, or update to avoid unnecessary db transactions. + if (newSeasons.isEmpty() && removedSeasons.isEmpty() && updatedSeasons.isEmpty()) { + if (manualFetch || anime.fetchInterval == 0 || anime.nextUpdate < fetchWindow.first) { + updateAnime.awaitUpdateFetchInterval( + anime, + now, + fetchWindow, + ) + } + return sourceSeasons + } + + if (removedSeasons.isNotEmpty()) { + val toDeleteIds = removedSeasons.map { it.id } + animeRepository.removeParentIdByIds(toDeleteIds) + } + + val toUpdate = newSeasons.map { it.toAnimeUpdate() } + + updatedSeasons.map { it.toAnimeUpdate() } + + if (toUpdate.isNotEmpty()) { + updateAnime.awaitAll(toUpdate) + } + + updateAnime.awaitUpdateLastUpdate(anime.id) + + return sourceSeasons + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt index b6bbe5a940..540201bcf1 100644 --- a/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/model/Anime.kt @@ -18,6 +18,27 @@ val Anime.downloadedFilter: TriState else -> TriState.DISABLED } } + +// AY --> +val Anime.seasonDownloadedFilter: TriState + get() { + if (Injekt.get().downloadedOnly().get()) return TriState.ENABLED_IS + return when (seasonDownloadedFilterRaw) { + Anime.SEASON_SHOW_DOWNLOADED -> TriState.ENABLED_IS + Anime.SEASON_SHOW_NOT_DOWNLOADED -> TriState.ENABLED_NOT + else -> TriState.DISABLED + } + } + +fun Anime.seasonsFiltered(): Boolean { + return seasonDownloadedFilter != TriState.DISABLED || + seasonUnseenFilter != TriState.DISABLED || + seasonStartedFilter != TriState.DISABLED || + seasonBookmarkedFilter != TriState.DISABLED || + seasonCompletedFilter != TriState.DISABLED +} +// <-- AY + fun Anime.episodesFiltered(): Boolean { return unseenFilter != TriState.DISABLED || downloadedFilter != TriState.DISABLED || @@ -36,6 +57,10 @@ fun Anime.toSAnime(): SAnime = SAnime.create().also { it.genre = genre.orEmpty().joinToString() it.status = status.toInt() it.thumbnail_url = thumbnailUrl + // AY --> + it.fetch_type = fetchType + it.season_number = seasonNumber + // <-- AY it.initialized = initialized } @@ -63,6 +88,10 @@ fun Anime.copyFrom(other: SAnime): Anime { ogStatus = other.status.toLong(), // <-- AM (CUSTOM_INFORMATION) updateStrategy = other.update_strategy, + // AY --> + fetchType = other.fetch_type, + seasonNumber = other.season_number, + // <-- AY initialized = other.initialized && initialized, ) } diff --git a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt index 0cc8c55b9e..6a966df1c3 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt @@ -18,10 +18,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -42,45 +44,54 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset import androidx.compose.ui.util.fastAll import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastMap +import aniyomi.domain.anime.SeasonAnime +import aniyomi.domain.anime.SeasonDisplayMode import eu.kanade.presentation.anime.components.AnimeActionRow import eu.kanade.presentation.anime.components.AnimeBottomActionMenu import eu.kanade.presentation.anime.components.AnimeEpisodeListItem import eu.kanade.presentation.anime.components.AnimeInfoBox +import eu.kanade.presentation.anime.components.AnimeSeasonListItem import eu.kanade.presentation.anime.components.AnimeToolbar import eu.kanade.presentation.anime.components.EpisodeDownloadAction -import eu.kanade.presentation.anime.components.EpisodeHeader +import eu.kanade.presentation.anime.components.ItemHeader import eu.kanade.presentation.anime.components.ExpandableAnimeDescription import eu.kanade.presentation.anime.components.MissingEpisodeCountListItem import eu.kanade.presentation.anime.components.NextEpisodeAiringListItem import eu.kanade.presentation.components.relativeDateText import eu.kanade.presentation.util.formatEpisodeNumber import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.animesource.model.FetchType import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.data.download.DownloadProvider import eu.kanade.tachiyomi.data.download.model.Download import eu.kanade.tachiyomi.source.getNameForAnimeInfo import eu.kanade.tachiyomi.ui.anime.AnimeScreenModel +import eu.kanade.tachiyomi.ui.anime.AnimeSeasonItem import eu.kanade.tachiyomi.ui.anime.EpisodeList import eu.kanade.tachiyomi.util.system.copyToClipboard import kotlinx.coroutines.delay import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.domain.anime.model.Anime import tachiyomi.domain.episode.model.Episode -import tachiyomi.domain.episode.service.missingEpisodesCount +import tachiyomi.domain.episode.service.missingEntriesCount import tachiyomi.domain.library.service.LibraryPreferences import tachiyomi.domain.source.model.StubSource import tachiyomi.i18n.MR import tachiyomi.i18n.aniyomi.AYMR +import tachiyomi.presentation.core.components.FastScrollLazyVerticalGrid import tachiyomi.presentation.core.components.TwoPanelBox -import tachiyomi.presentation.core.components.VerticalFastScroller import tachiyomi.presentation.core.components.material.ExtendedFloatingActionButton import tachiyomi.presentation.core.components.material.PullRefresh import tachiyomi.presentation.core.components.material.Scaffold @@ -114,7 +125,9 @@ fun AnimeScreen( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -158,6 +171,12 @@ fun AnimeScreen( onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, onAllEpisodeSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // AY --> + // Season clicked + onSeasonClicked: (SeasonAnime) -> Unit, + onContinueWatchingClicked: ((SeasonAnime) -> Unit)?, + // <-- AY ) { val context = LocalContext.current val onCopyTagToClipboard: (tag: String) -> Unit = { @@ -218,6 +237,10 @@ fun AnimeScreen( onEpisodeSelected = onEpisodeSelected, onAllEpisodeSelected = onAllEpisodeSelected, onInvertSelection = onInvertSelection, + // AY --> + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onContinueWatchingClicked, + // <-- AY ) } else { AnimeScreenLargeImpl( @@ -271,6 +294,10 @@ fun AnimeScreen( onEpisodeSelected = onEpisodeSelected, onAllEpisodeSelected = onAllEpisodeSelected, onInvertSelection = onInvertSelection, + // AY --> + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onContinueWatchingClicked, + // <-- AY ) } } @@ -297,7 +324,9 @@ private fun AnimeScreenSmallImpl( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -342,17 +371,32 @@ private fun AnimeScreenSmallImpl( onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, onAllEpisodeSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // AY --> + // Season clicked + onSeasonClicked: (SeasonAnime) -> Unit, + onClickContinueWatching: ((SeasonAnime) -> Unit)?, + // <-- AY ) { - val episodeListState = rememberLazyListState() + // AY --> + val density = LocalDensity.current + val offsetGridPaddingPx = with(density) { GRID_PADDING.roundToPx() } + val gridSize = remember(state.anime) { state.anime.seasonDisplayGridSize } + val itemListState = rememberLazyGridState() - val (episodes, listItem, isAnySelected) = remember(state) { - Triple( - first = state.processedEpisodes, - second = state.episodeListItems, - third = state.isAnySelected, + val (episodes, seasons, listItem, isAnySelected) = remember(state) { + StateUIData( + episodes = state.processedEpisodes, + seasons = state.processedSeasons, + listItem = state.episodeListItems, + isAnySelected = state.isAnySelected, ) } + var containerHeight by remember { mutableIntStateOf(0) } + var toolbarHeight by remember { mutableIntStateOf(0) } + // <-- AY + BackHandler(enabled = isAnySelected) { onAllEpisodeSelected(false) } @@ -363,10 +407,10 @@ private fun AnimeScreenSmallImpl( episodes.count { it.selected } } val isFirstItemVisible by remember { - derivedStateOf { episodeListState.firstVisibleItemIndex == 0 } + derivedStateOf { itemListState.firstVisibleItemIndex == 0 } } val isFirstItemScrolled by remember { - derivedStateOf { episodeListState.firstVisibleItemScrollOffset > 0 } + derivedStateOf { itemListState.firstVisibleItemScrollOffset > 0 } } val titleAlpha by animateFloatAsState( if (!isFirstItemVisible) 1f else 0f, @@ -400,6 +444,9 @@ private fun AnimeScreenSmallImpl( onInvertSelection = { onInvertSelection() }, titleAlphaProvider = { titleAlpha }, backgroundAlphaProvider = { backgroundAlpha }, + // AY --> + modifier = Modifier.onSizeChanged { toolbarHeight = it.height }, + // <-- AY ) }, bottomBar = { @@ -446,7 +493,7 @@ private fun AnimeScreenSmallImpl( }, icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, onClick = onContinueWatching, - expanded = episodeListState.shouldExpandFAB(), + expanded = itemListState.shouldExpandFAB(), ) } }, @@ -460,128 +507,181 @@ private fun AnimeScreenSmallImpl( indicatorPadding = PaddingValues(top = topPadding), ) { val layoutDirection = LocalLayoutDirection.current - VerticalFastScroller( - listState = episodeListState, - topContentPadding = topPadding, - endContentPadding = contentPadding.calculateEndPadding(layoutDirection), + // AY --> + FastScrollLazyVerticalGrid( + modifier = Modifier + .fillMaxHeight() + .onGloballyPositioned { layoutCoordinates -> + containerHeight = layoutCoordinates.size.height + }, + state = itemListState, + columns = if (gridSize == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(gridSize), + contentPadding = PaddingValues( + start = GRID_PADDING + contentPadding.calculateStartPadding(layoutDirection), + end = GRID_PADDING + contentPadding.calculateEndPadding(layoutDirection), + bottom = contentPadding.calculateBottomPadding(), + ), ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - state = episodeListState, - contentPadding = PaddingValues( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), - bottom = contentPadding.calculateBottomPadding(), - ), + // <-- AY + item( + key = AnimeScreenItem.INFO_BOX, + contentType = AnimeScreenItem.INFO_BOX, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { - item( - key = AnimeScreenItem.INFO_BOX, - contentType = AnimeScreenItem.INFO_BOX, - ) { - AnimeInfoBox( - isTabletUi = false, - appBarPadding = topPadding, - anime = state.anime, - sourceName = remember { state.source.getNameForAnimeInfo() }, - isStubSource = remember { state.source is StubSource }, - onCoverClick = onCoverClicked, - doSearch = onSearch, - ) - } + AnimeInfoBox( + isTabletUi = false, + appBarPadding = topPadding, + anime = state.anime, + sourceName = remember { state.source.getNameForAnimeInfo() }, + isStubSource = remember { state.source is StubSource }, + onCoverClick = onCoverClicked, + doSearch = onSearch, + // AY --> + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + // <-- AY + ) + } - item( - key = AnimeScreenItem.ACTION_ROW, - contentType = AnimeScreenItem.ACTION_ROW, - ) { - AnimeActionRow( - favorite = state.anime.favorite, - trackingCount = state.trackingCount, - nextUpdate = nextUpdate, - isUserIntervalMode = state.anime.fetchInterval < 0, - onAddToLibraryClicked = onAddToLibraryClicked, - onWebViewClicked = onWebViewClicked, - onWebViewLongClicked = onWebViewLongClicked, - onTrackingClicked = onTrackingClicked, - onEditIntervalClicked = onEditIntervalClicked, - onEditCategory = onEditCategoryClicked, - ) - } + item( + key = AnimeScreenItem.ACTION_ROW, + contentType = AnimeScreenItem.ACTION_ROW, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY + ) { + AnimeActionRow( + favorite = state.anime.favorite, + trackingCount = state.trackingCount, + nextUpdate = nextUpdate, + isUserIntervalMode = state.anime.fetchInterval < 0, + onAddToLibraryClicked = onAddToLibraryClicked, + onWebViewClicked = onWebViewClicked, + onWebViewLongClicked = onWebViewLongClicked, + onTrackingClicked = onTrackingClicked, + onEditIntervalClicked = onEditIntervalClicked, + onEditCategory = onEditCategoryClicked, + // AY --> + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + // <-- AY + ) + } - item( - key = AnimeScreenItem.DESCRIPTION_WITH_TAG, - contentType = AnimeScreenItem.DESCRIPTION_WITH_TAG, - ) { - ExpandableAnimeDescription( - defaultExpandState = state.isFromSource, - description = state.anime.description, - tagsProvider = { state.anime.genre }, - notes = state.anime.notes, - onTagSearch = onTagSearch, - onCopyTagToClipboard = onCopyTagToClipboard, - onEditNotes = onEditNotesClicked, - ) + item( + key = AnimeScreenItem.DESCRIPTION_WITH_TAG, + contentType = AnimeScreenItem.DESCRIPTION_WITH_TAG, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY + ) { + ExpandableAnimeDescription( + defaultExpandState = state.isFromSource, + description = state.anime.description, + tagsProvider = { state.anime.genre }, + notes = state.anime.notes, + onTagSearch = onTagSearch, + onCopyTagToClipboard = onCopyTagToClipboard, + onEditNotes = onEditNotesClicked, + // AY --> + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + // <-- AY + ) + } + + item( + key = AnimeScreenItem.EPISODE_HEADER, + contentType = AnimeScreenItem.EPISODE_HEADER, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY + ) { + val missingEpisodeCount = remember(episodes) { + episodes.map { it.episode.episodeNumber }.missingEntriesCount() } + // AY --> + val missingSeasonsCount = remember(seasons) { + seasons.map { it.seasonAnime.anime.seasonNumber }.missingEntriesCount() + } + ItemHeader( + enabled = !isAnySelected, + itemCount = when (state.anime.fetchType) { + FetchType.Seasons -> seasons.size + FetchType.Episodes -> episodes.size + }, + missingItemsCount = when (state.anime.fetchType) { + FetchType.Seasons -> missingSeasonsCount + FetchType.Episodes -> missingEpisodeCount + }, + onClick = onFilterClicked, + fetchType = state.anime.fetchType, + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + // <-- AY + } - item( - key = AnimeScreenItem.EPISODE_HEADER, - contentType = AnimeScreenItem.EPISODE_HEADER, - ) { - val missingEpisodeCount = remember(episodes) { - episodes.map { it.episode.episodeNumber }.missingEpisodesCount() - } - EpisodeHeader( - enabled = !isAnySelected, - episodeCount = episodes.size, - missingEpisodeCount = missingEpisodeCount, - onClick = onFilterClicked, + // AY --> + when (state.anime.fetchType) { + FetchType.Seasons -> { + sharedSeasons( + anime = state.anime, + seasons = seasons, + containerHeight = containerHeight - toolbarHeight, + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onClickContinueWatching, ) } - - // AY --> - if (state.airingTime > 0L) { - item( - key = AnimeScreenItem.AIRING_TIME, - contentType = AnimeScreenItem.AIRING_TIME, - ) { - // Handles the second by second countdown - var timer by remember { mutableLongStateOf(state.airingTime) } - LaunchedEffect(key1 = timer) { - if (timer > 0L) { - delay(1000L) - timer -= 1000L - } - } - if (timer > 0L && - showNextEpisodeAirTime && - state.anime.status.toInt() != SAnime.COMPLETED + // <-- AY + FetchType.Episodes -> { + // AY --> + if (state.airingTime > 0L) { + item( + key = AnimeScreenItem.AIRING_TIME, + contentType = AnimeScreenItem.AIRING_TIME, + span = { GridItemSpan(maxLineSpan) }, ) { - NextEpisodeAiringListItem( - title = stringResource( - AYMR.strings.display_mode_episode, - formatEpisodeNumber(state.airingEpisodeNumber), - ), - date = formatTime(state.airingTime, useDayFormat = true), - ) + // Handles the second by second countdown + var timer by remember { mutableLongStateOf(state.airingTime) } + LaunchedEffect(key1 = timer) { + if (timer > 0L) { + delay(1000L) + timer -= 1000L + } + } + if (timer > 0L && + showNextEpisodeAirTime && + state.anime.status.toInt() != SAnime.COMPLETED + ) { + NextEpisodeAiringListItem( + title = stringResource( + AYMR.strings.display_mode_episode, + formatEpisodeNumber(state.airingEpisodeNumber), + ), + date = formatTime(state.airingTime, useDayFormat = true), + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } } } - } - // <-- AY + // <-- AY - sharedEpisodeItems( - anime = state.anime, - // AM (FILE_SIZE) --> - source = state.source, - showFileSize = showFileSize, - // <-- AM (FILE_SIZE) - episodes = listItem, - isAnyEpisodeSelected = episodes.fastAny { it.selected }, - episodeSwipeStartAction = episodeSwipeStartAction, - episodeSwipeEndAction = episodeSwipeEndAction, - onEpisodeClicked = onEpisodeClicked, - onDownloadEpisode = onDownloadEpisode, - onEpisodeSelected = onEpisodeSelected, - onEpisodeSwipe = onEpisodeSwipe, - ) + sharedEpisodeItems( + anime = state.anime, + // AM (FILE_SIZE) --> + source = state.source, + showFileSize = showFileSize, + // <-- AM (FILE_SIZE) + episodes = listItem, + isAnyEpisodeSelected = episodes.fastAny { it.selected }, + episodeSwipeStartAction = episodeSwipeStartAction, + episodeSwipeEndAction = episodeSwipeEndAction, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, + onEpisodeSwipe = onEpisodeSwipe, + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } } } } @@ -610,7 +710,9 @@ fun AnimeScreenLargeImpl( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -655,22 +757,36 @@ fun AnimeScreenLargeImpl( onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, onAllEpisodeSelected: (Boolean) -> Unit, onInvertSelection: () -> Unit, + + // AY --> + // Season clicked + onSeasonClicked: (SeasonAnime) -> Unit, + onClickContinueWatching: ((SeasonAnime) -> Unit)?, + // <-- AY ) { val layoutDirection = LocalLayoutDirection.current val density = LocalDensity.current - val (episodes, listItem, isAnySelected) = remember(state) { - Triple( - first = state.processedEpisodes, - second = state.episodeListItems, - third = state.isAnySelected, + val (episodes, seasons, listItem, isAnySelected) = remember(state) { + StateUIData( + episodes = state.processedEpisodes, + seasons = state.processedSeasons, + listItem = state.episodeListItems, + isAnySelected = state.isAnySelected, ) } val insetPadding = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal).asPaddingValues() var topBarHeight by remember { mutableIntStateOf(0) } + // AY --> + val offsetGridPaddingPx = with(density) { GRID_PADDING.roundToPx() } + val gridSize = remember(state.anime) { state.anime.seasonDisplayGridSize } - val episodeListState = rememberLazyListState() + var containerHeight by remember { mutableIntStateOf(0) } + var headerHeight by remember { mutableIntStateOf(0) } + + val itemListState = rememberLazyGridState() + // <-- AY BackHandler(enabled = isAnySelected) { onAllEpisodeSelected(false) @@ -757,7 +873,7 @@ fun AnimeScreenLargeImpl( }, icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, onClick = onContinueWatching, - expanded = episodeListState.shouldExpandFAB(), + expanded = itemListState.shouldExpandFAB(), ) } }, @@ -816,78 +932,119 @@ fun AnimeScreenLargeImpl( } }, endContent = { - VerticalFastScroller( - listState = episodeListState, - topContentPadding = contentPadding.calculateTopPadding(), + // AY --> + FastScrollLazyVerticalGrid( + modifier = Modifier + .fillMaxHeight() + .onGloballyPositioned { layoutCoordinates -> + containerHeight = layoutCoordinates.size.height + }, + state = itemListState, + columns = if (gridSize == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(gridSize), + contentPadding = PaddingValues( + start = GRID_PADDING, + end = GRID_PADDING, + top = contentPadding.calculateTopPadding(), + bottom = contentPadding.calculateBottomPadding(), + ), ) { - LazyColumn( - modifier = Modifier.fillMaxHeight(), - state = episodeListState, - contentPadding = PaddingValues( - top = contentPadding.calculateTopPadding(), - bottom = contentPadding.calculateBottomPadding(), - ), + // <-- AY + item( + key = AnimeScreenItem.EPISODE_HEADER, + contentType = AnimeScreenItem.EPISODE_HEADER, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { - item( - key = AnimeScreenItem.EPISODE_HEADER, - contentType = AnimeScreenItem.EPISODE_HEADER, - ) { - val missingEpisodeCount = remember(episodes) { - episodes.map { it.episode.episodeNumber }.missingEpisodesCount() - } - EpisodeHeader( - enabled = !isAnySelected, - episodeCount = episodes.size, - missingEpisodeCount = missingEpisodeCount, - onClick = onFilterButtonClicked, - ) + val missingEpisodeCount = remember(episodes) { + episodes.map { it.episode.episodeNumber }.missingEntriesCount() } - // AY --> - if (state.airingTime > 0L) { - item( - key = AnimeScreenItem.AIRING_TIME, - contentType = AnimeScreenItem.AIRING_TIME, - ) { - // Handles the second by second countdown - var timer by remember { mutableLongStateOf(state.airingTime) } - LaunchedEffect(key1 = timer) { - if (timer > 0L) { - delay(1000L) - timer -= 1000L - } - } - if (timer > 0L && - showNextEpisodeAirTime && - state.anime.status.toInt() != SAnime.COMPLETED + val missingSeasonsCount = remember(seasons) { + seasons.map { it.seasonAnime.anime.seasonNumber }.missingEntriesCount() + } + ItemHeader( + enabled = !isAnySelected, + itemCount = when (state.anime.fetchType) { + FetchType.Seasons -> seasons.size + FetchType.Episodes -> episodes.size + }, + missingItemsCount = when (state.anime.fetchType) { + FetchType.Seasons -> missingSeasonsCount + FetchType.Episodes -> missingEpisodeCount + }, + onClick = onFilterButtonClicked, + fetchType = state.anime.fetchType, + modifier = Modifier + .ignorePadding(offsetGridPaddingPx) + .onSizeChanged { headerHeight = it.height }, + ) + // <-- AY + } + + // AY --> + when (state.anime.fetchType) { + FetchType.Seasons -> { + sharedSeasons( + anime = state.anime, + seasons = seasons, + containerHeight = containerHeight - headerHeight, + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onClickContinueWatching, + ) + } + // <-- AY + FetchType.Episodes -> { + // AY --> + if (state.airingTime > 0L) { + item( + key = AnimeScreenItem.AIRING_TIME, + contentType = AnimeScreenItem.AIRING_TIME, ) { - NextEpisodeAiringListItem( - title = stringResource( - AYMR.strings.display_mode_episode, - formatEpisodeNumber(state.airingEpisodeNumber), - ), - date = formatTime(state.airingTime, useDayFormat = true), - ) + // Handles the second by second countdown + var timer by remember { mutableLongStateOf(state.airingTime) } + LaunchedEffect(key1 = timer) { + if (timer > 0L) { + delay(1000L) + timer -= 1000L + } + } + if (timer > 0L && + showNextEpisodeAirTime && + state.anime.status.toInt() != SAnime.COMPLETED + ) { + NextEpisodeAiringListItem( + title = stringResource( + AYMR.strings.display_mode_episode, + formatEpisodeNumber(state.airingEpisodeNumber), + ), + date = formatTime(state.airingTime, useDayFormat = true), + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } } } - } - // <-- AY + // <-- AY - sharedEpisodeItems( - anime = state.anime, - // AM (FILE_SIZE) --> - source = state.source, - showFileSize = showFileSize, - // <-- AM (FILE_SIZE) - episodes = listItem, - isAnyEpisodeSelected = episodes.fastAny { it.selected }, - episodeSwipeStartAction = episodeSwipeStartAction, - episodeSwipeEndAction = episodeSwipeEndAction, - onEpisodeClicked = onEpisodeClicked, - onDownloadEpisode = onDownloadEpisode, - onEpisodeSelected = onEpisodeSelected, - onEpisodeSwipe = onEpisodeSwipe, - ) + sharedEpisodeItems( + anime = state.anime, + // AM (FILE_SIZE) --> + source = state.source, + showFileSize = showFileSize, + // <-- AM (FILE_SIZE) + episodes = listItem, + isAnyEpisodeSelected = episodes.fastAny { it.selected }, + episodeSwipeStartAction = episodeSwipeStartAction, + episodeSwipeEndAction = episodeSwipeEndAction, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, + onEpisodeSwipe = onEpisodeSwipe, + // AY --> + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + // <-- AY + ) + } } } }, @@ -961,7 +1118,31 @@ private fun SharedAnimeBottomActionMenu( ) } -private fun LazyListScope.sharedEpisodeItems( +// AY --> +private fun LazyGridScope.sharedSeasons( + anime: Anime, + seasons: List, + containerHeight: Int, + onSeasonClicked: (SeasonAnime) -> Unit, + onClickContinueWatching: ((SeasonAnime) -> Unit)?, +) { + items( + items = seasons, + key = { season -> season.seasonAnime.anime }, + span = { GridItemSpan(if (anime.seasonDisplayGridMode == SeasonDisplayMode.List) maxLineSpan else 1) }, + ) { item -> + AnimeSeasonListItem( + anime = anime, + item = item, + containerHeight = containerHeight, + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onClickContinueWatching, + ) + } +} + +private fun LazyGridScope.sharedEpisodeItems( + // <-- AY anime: Anime, // AM (FILE_SIZE) --> source: AnimeSource, @@ -977,6 +1158,9 @@ private fun LazyListScope.sharedEpisodeItems( onDownloadEpisode: ((List, EpisodeDownloadAction) -> Unit)?, onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, onEpisodeSwipe: (EpisodeList.Item, LibraryPreferences.EpisodeSwipeAction) -> Unit, + // AY --> + modifier: Modifier = Modifier, + // <-- AY ) { items( items = episodes, @@ -987,12 +1171,20 @@ private fun LazyListScope.sharedEpisodeItems( } }, contentType = { AnimeScreenItem.EPISODE }, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { item -> val haptic = LocalHapticFeedback.current when (item) { is EpisodeList.MissingCount -> { - MissingEpisodeCountListItem(count = item.count) + MissingEpisodeCountListItem( + count = item.count, + // AY --> + modifier = modifier, + // <-- AY + ) } is EpisodeList.Item -> { // AM (FILE_SIZE) --> @@ -1071,6 +1263,9 @@ private fun LazyListScope.sharedEpisodeItems( // AM (FILE_SIZE) --> fileSize = fileSizeAsync, // <-- AM (FILE_SIZE) + // AY --> + modifier = modifier, + // <-- AY ) } } @@ -1093,6 +1288,13 @@ private fun onEpisodeItemClick( } // AY --> +private data class StateUIData( + val episodes: List, + val seasons: List, + val listItem: List, + val isAnySelected: Boolean, +) + private fun formatTime(milliseconds: Long, useDayFormat: Boolean = false): String { return if (useDayFormat) { String.format( @@ -1123,6 +1325,16 @@ private fun formatTime(milliseconds: Long, useDayFormat: Boolean = false): Strin ) } } + +private val GRID_PADDING = 14.dp +private fun Modifier.ignorePadding(gridPadding: Int) = layout { measurable, constraints -> + val looseConstraints = constraints.offset(gridPadding * 2, 0) + val placeable = measurable.measure(looseConstraints) + + layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } +} // <-- AY // AM (FILE_SIZE) --> diff --git a/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt index 0a0b0be099..1fb4ebf0a9 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt @@ -248,7 +248,10 @@ private fun ColumnScope.DisplayPage( } @Composable -private fun SetAsDefaultDialog( +internal fun SetAsDefaultDialog( + // AY --> + isEpisode: Boolean = true, + // <-- AY onDismissRequest: () -> Unit, onConfirmed: (optionalChecked: Boolean) -> Unit, ) { @@ -256,7 +259,19 @@ private fun SetAsDefaultDialog( AlertDialog( onDismissRequest = onDismissRequest, - title = { Text(text = stringResource(AYMR.strings.episode_settings)) }, + title = { + Text( + // AY --> + text = if (isEpisode) { + stringResource( + AYMR.strings.episode_settings, + ) + } else { + stringResource(AYMR.strings.season_settings) + }, + // <-- AY + ) + }, text = { Column( verticalArrangement = Arrangement.spacedBy(12.dp), diff --git a/app/src/main/java/eu/kanade/presentation/anime/SeasonSettingsDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/SeasonSettingsDialog.kt new file mode 100644 index 0000000000..3b51053af0 --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/SeasonSettingsDialog.kt @@ -0,0 +1,332 @@ +// AY --> +package eu.kanade.presentation.anime + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import aniyomi.domain.anime.SeasonDisplayMode +import eu.kanade.domain.anime.model.seasonDownloadedFilter +import eu.kanade.domain.base.BasePreferences +import eu.kanade.presentation.components.TabbedDialog +import eu.kanade.presentation.components.TabbedDialogPaddings +import kotlinx.collections.immutable.persistentListOf +import tachiyomi.core.common.preference.TriState +import tachiyomi.domain.anime.model.Anime +import tachiyomi.i18n.MR +import tachiyomi.i18n.aniyomi.AYMR +import tachiyomi.presentation.core.components.HeadingItem +import tachiyomi.presentation.core.components.LabeledCheckbox +import tachiyomi.presentation.core.components.RadioItem +import tachiyomi.presentation.core.components.SettingsChipRow +import tachiyomi.presentation.core.components.SettingsItemsPaddings +import tachiyomi.presentation.core.components.SliderItem +import tachiyomi.presentation.core.components.SortItem +import tachiyomi.presentation.core.components.TriStateItem +import tachiyomi.presentation.core.i18n.stringResource +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@Composable +fun SeasonSettingsDialog( + onDismissRequest: () -> Unit, + anime: Anime? = null, + + // Filter page + onDownloadFilterChanged: (TriState) -> Unit, + onUnseenFilterChanged: (TriState) -> Unit, + onStartedFilterChanged: (TriState) -> Unit, + onBookmarkedFilterChanged: (TriState) -> Unit, + onCompletedFilterChanged: (TriState) -> Unit, + + // Sort page + onSortModeChanged: (Long) -> Unit, + + // Display page + onDisplayGridModeChanged: (SeasonDisplayMode) -> Unit, + onDisplayGridSizeChanged: (Int) -> Unit, + onOverlayDownloadedChanged: (Boolean) -> Unit, + onOverlayUnseenChanged: (Boolean) -> Unit, + onOverlayLocalChanged: (Boolean) -> Unit, + onOverlayLangChanged: (Boolean) -> Unit, + onOverlayContinueChanged: (Boolean) -> Unit, + onDisplayModeChanged: (Long) -> Unit, + + // Overflow action + onSetAsDefault: (applyToExistingAnime: Boolean) -> Unit, +) { + var showSetAsDefaultDialog by rememberSaveable { mutableStateOf(false) } + if (showSetAsDefaultDialog) { + SetAsDefaultDialog( + onDismissRequest = { showSetAsDefaultDialog = false }, + isEpisode = false, + onConfirmed = onSetAsDefault, + ) + } + + val downloadedOnly = remember { Injekt.get().downloadedOnly().get() } + + TabbedDialog( + onDismissRequest = onDismissRequest, + tabTitles = persistentListOf( + stringResource(MR.strings.action_filter), + stringResource(MR.strings.action_sort), + stringResource(MR.strings.action_display), + ), + tabOverflowMenuContent = { closeMenu -> + DropdownMenuItem( + text = { Text(stringResource(MR.strings.set_chapter_settings_as_default)) }, + onClick = { + showSetAsDefaultDialog = true + closeMenu() + }, + ) + }, + ) { page -> + Column( + modifier = Modifier + .padding(vertical = TabbedDialogPaddings.Vertical) + .verticalScroll(rememberScrollState()), + ) { + when (page) { + 0 -> { + SeasonFilterPage( + downloadFilter = anime?.seasonDownloadedFilter ?: TriState.DISABLED, + onDownloadFilterChanged = onDownloadFilterChanged + .takeUnless { downloadedOnly }, + unseenFilter = anime?.seasonUnseenFilter ?: TriState.DISABLED, + onUnseenFilterChanged = onUnseenFilterChanged, + startedFilter = anime?.seasonStartedFilter ?: TriState.DISABLED, + onStartedFilterChanged = onStartedFilterChanged, + bookmarkedFilter = anime?.seasonBookmarkedFilter ?: TriState.DISABLED, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + completedFilter = anime?.seasonCompletedFilter ?: TriState.DISABLED, + onCompletedFilterChanged = onCompletedFilterChanged, + ) + } + 1 -> { + SeasonSortPage( + sortingMode = anime?.seasonSorting ?: 0, + sortDescending = anime?.seasonSortDescending() ?: false, + onItemSelected = onSortModeChanged, + ) + } + 2 -> { + SeasonDisplayPage( + displayGridMode = anime?.seasonDisplayGridMode ?: SeasonDisplayMode.CompactGrid, + displayGridModeChange = onDisplayGridModeChanged, + displayGridModeSize = anime?.seasonDisplayGridSize ?: 0, + displayGridModeSizeChange = onDisplayGridSizeChanged, + overlayDownloaded = anime?.seasonDownloadedOverlay ?: false, + overlayDownloadedChange = onOverlayDownloadedChanged, + overlayUnseen = anime?.seasonUnseenOverlay ?: true, + overlayUnseenChange = onOverlayUnseenChanged, + overlayLocal = anime?.seasonLocalOverlay ?: true, + overlayLocalChange = onOverlayLocalChanged, + overlayLang = anime?.seasonLangOverlay ?: false, + overlayLangChange = onOverlayLangChanged, + overlayContinue = anime?.seasonContinueOverlay ?: true, + overlayContinueChange = onOverlayContinueChanged, + displayMode = anime?.seasonDisplayMode ?: 0L, + displayModeChange = onDisplayModeChanged, + ) + } + } + } + } +} + +@Composable +private fun ColumnScope.SeasonFilterPage( + downloadFilter: TriState, + onDownloadFilterChanged: ((TriState) -> Unit)?, + unseenFilter: TriState, + onUnseenFilterChanged: (TriState) -> Unit, + startedFilter: TriState, + onStartedFilterChanged: (TriState) -> Unit, + bookmarkedFilter: TriState, + onBookmarkedFilterChanged: (TriState) -> Unit, + completedFilter: TriState, + onCompletedFilterChanged: (TriState) -> Unit, +) { + TriStateItem( + label = stringResource(MR.strings.label_downloaded), + state = downloadFilter, + onClick = onDownloadFilterChanged, + ) + TriStateItem( + label = stringResource(AYMR.strings.action_filter_unseen), + state = unseenFilter, + onClick = onUnseenFilterChanged, + ) + TriStateItem( + label = stringResource(MR.strings.label_started), + state = startedFilter, + onClick = onStartedFilterChanged, + ) + TriStateItem( + label = stringResource(MR.strings.action_filter_bookmarked), + state = bookmarkedFilter, + onClick = onBookmarkedFilterChanged, + ) + TriStateItem( + label = stringResource(MR.strings.completed), + state = completedFilter, + onClick = onCompletedFilterChanged, + ) +} + +@Composable +private fun ColumnScope.SeasonSortPage( + sortingMode: Long, + sortDescending: Boolean, + onItemSelected: (Long) -> Unit, +) { + listOf( + MR.strings.sort_by_source to Anime.SEASON_SORT_SOURCE, + AYMR.strings.sort_by_season_number to Anime.SEASON_SORT_SEASON, + MR.strings.sort_by_upload_date to Anime.SEASON_SORT_UPLOAD, + MR.strings.action_sort_alpha to Anime.SEASON_SORT_ALPHABET, + AYMR.strings.action_sort_unseen_count to Anime.SEASON_SORT_COUNT, + AYMR.strings.action_sort_last_seen to Anime.SEASON_SORT_LAST_SEEN, + AYMR.strings.action_sort_episode_fetch_date to Anime.SEASON_SORT_FETCHED, + ).map { (titleRes, mode) -> + SortItem( + label = stringResource(titleRes), + sortDescending = sortDescending.takeIf { sortingMode == mode }, + onClick = { onItemSelected(mode) }, + ) + } +} + +private val displayModes = listOf( + MR.strings.action_display_grid to SeasonDisplayMode.CompactGrid, + MR.strings.action_display_comfortable_grid to SeasonDisplayMode.ComfortableGrid, + MR.strings.action_display_cover_only_grid to SeasonDisplayMode.CoverOnlyGrid, + MR.strings.action_display_list to SeasonDisplayMode.List, +) + +@Composable +private fun ColumnScope.SeasonDisplayPage( + displayGridMode: SeasonDisplayMode, + displayGridModeChange: (SeasonDisplayMode) -> Unit, + displayGridModeSize: Int, + displayGridModeSizeChange: (Int) -> Unit, + overlayDownloaded: Boolean, + overlayDownloadedChange: (Boolean) -> Unit, + overlayUnseen: Boolean, + overlayUnseenChange: (Boolean) -> Unit, + overlayLocal: Boolean, + overlayLocalChange: (Boolean) -> Unit, + overlayLang: Boolean, + overlayLangChange: (Boolean) -> Unit, + overlayContinue: Boolean, + overlayContinueChange: (Boolean) -> Unit, + displayMode: Long, + displayModeChange: (Long) -> Unit, +) { + SettingsChipRow(MR.strings.action_display_mode) { + displayModes.map { (titleRes, mode) -> + FilterChip( + selected = displayGridMode == mode, + onClick = { displayGridModeChange(mode) }, + label = { Text(stringResource(titleRes)) }, + ) + } + } + + if (displayGridMode == SeasonDisplayMode.List) { + SliderItem( + value = displayGridModeSize, + valueRange = 0..10, + label = stringResource(AYMR.strings.pref_library_rows), + valueText = if (displayGridModeSize > 0) { + displayGridModeSize.toString() + } else { + stringResource(MR.strings.label_auto) + }, + onChange = { displayGridModeSizeChange(it) }, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } else { + SliderItem( + value = displayGridModeSize, + valueRange = 0..10, + label = stringResource(MR.strings.pref_library_columns), + valueText = if (displayGridModeSize > 0) { + displayGridModeSize.toString() + } else { + stringResource(MR.strings.label_auto) + }, + onChange = { displayGridModeSizeChange(it) }, + pillColor = MaterialTheme.colorScheme.surfaceContainerHighest, + ) + } + + HeadingItem(MR.strings.overlay_header) + LabeledCheckbox( + label = stringResource(AYMR.strings.action_display_download_badge_anime), + checked = overlayDownloaded, + onCheckedChange = overlayDownloadedChange, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + ), + ) + LabeledCheckbox( + label = stringResource(AYMR.strings.action_display_unseen_badge), + checked = overlayUnseen, + onCheckedChange = overlayUnseenChange, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + ), + ) + LabeledCheckbox( + label = stringResource(MR.strings.action_display_local_badge), + checked = overlayLocal, + onCheckedChange = overlayLocalChange, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + ), + ) + LabeledCheckbox( + label = stringResource(MR.strings.action_display_language_badge), + checked = overlayLang, + onCheckedChange = overlayLangChange, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + ), + ) + LabeledCheckbox( + label = stringResource(AYMR.strings.action_display_show_continue_watching_button), + checked = overlayContinue, + onCheckedChange = overlayContinueChange, + modifier = Modifier.padding( + horizontal = SettingsItemsPaddings.Horizontal, + ), + ) + + HeadingItem(AYMR.strings.action_display_grid_mode) + listOf( + MR.strings.show_title to Anime.SEASON_DISPLAY_MODE_SOURCE, + AYMR.strings.show_season_number to Anime.SEASON_DISPLAY_MODE_NUMBER, + ).map { (titleRes, mode) -> + RadioItem( + label = stringResource(titleRes), + selected = displayMode == mode, + onClick = { displayModeChange(mode) }, + ) + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt index 5c0fd580de..181155c054 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeInfoHeader.kt @@ -179,7 +179,9 @@ fun AnimeActionRow( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY onEditIntervalClicked: (() -> Unit)?, onEditCategory: (() -> Unit)?, modifier: Modifier = Modifier, @@ -222,16 +224,20 @@ fun AnimeActionRow( color = if (isUserIntervalMode) MaterialTheme.colorScheme.primary else defaultActionButtonColor, onClick = { onEditIntervalClicked?.invoke() }, ) - AnimeActionButton( - title = if (trackingCount == 0) { - stringResource(MR.strings.manga_tracking_tab) - } else { - pluralStringResource(MR.plurals.num_trackers, count = trackingCount, trackingCount) - }, - icon = if (trackingCount == 0) Icons.Outlined.Sync else Icons.Outlined.Done, - color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary, - onClick = onTrackingClicked, - ) + // AY --> + if (onTrackingClicked != null) { + // <-- AY + AnimeActionButton( + title = if (trackingCount == 0) { + stringResource(MR.strings.manga_tracking_tab) + } else { + pluralStringResource(MR.plurals.num_trackers, count = trackingCount, trackingCount) + }, + icon = if (trackingCount == 0) Icons.Outlined.Sync else Icons.Outlined.Done, + color = if (trackingCount == 0) defaultActionButtonColor else MaterialTheme.colorScheme.primary, + onClick = onTrackingClicked, + ) + } if (onWebViewClicked != null) { AnimeActionButton( title = stringResource(MR.strings.action_web_view), diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSeasonListItem.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSeasonListItem.kt new file mode 100644 index 0000000000..a11399496f --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSeasonListItem.kt @@ -0,0 +1,131 @@ +// AY --> +package eu.kanade.presentation.anime.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.dp +import aniyomi.domain.anime.SeasonAnime +import aniyomi.domain.anime.SeasonDisplayMode +import eu.kanade.presentation.library.components.DownloadsBadge +import eu.kanade.presentation.library.components.AnimeComfortableGridItem +import eu.kanade.presentation.library.components.AnimeCompactGridItem +import eu.kanade.presentation.library.components.AnimeListItem +import eu.kanade.presentation.library.components.LanguageBadge +import eu.kanade.presentation.library.components.UnseenBadge +import eu.kanade.presentation.util.formatEpisodeNumber +import eu.kanade.tachiyomi.ui.anime.AnimeSeasonItem +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.anime.model.AnimeCover +import tachiyomi.i18n.aniyomi.AYMR +import tachiyomi.presentation.core.i18n.stringResource + +@Composable +fun AnimeSeasonListItem( + anime: Anime, + item: AnimeSeasonItem, + containerHeight: Int, + onSeasonClicked: (SeasonAnime) -> Unit, + onClickContinueWatching: ((SeasonAnime) -> Unit)?, +) { + val itemAnime = item.seasonAnime.anime + val title = if (anime.seasonDisplayMode == Anime.SEASON_DISPLAY_MODE_NUMBER) { + stringResource( + AYMR.strings.display_mode_season, + formatEpisodeNumber(itemAnime.seasonNumber), + ) + } else { + itemAnime.title + } + + when (anime.seasonDisplayGridMode) { + SeasonDisplayMode.ComfortableGrid -> { + AnimeComfortableGridItem( + title = title, + coverData = AnimeCover( + animeId = itemAnime.id, + sourceId = itemAnime.source, + isAnimeFavorite = itemAnime.favorite, + url = itemAnime.thumbnailUrl, + lastModified = itemAnime.coverLastModified, + ), + coverBadgeStart = { + DownloadsBadge(count = item.downloadCount) + UnseenBadge(count = item.unseenCount) + }, + coverBadgeEnd = { + LanguageBadge( + isLocal = item.isLocal, + sourceLanguage = item.sourceLanguage, + ) + }, + onLongClick = { onSeasonClicked(item.seasonAnime) }, + onClick = { onSeasonClicked(item.seasonAnime) }, + onClickContinueWatching = if (onClickContinueWatching != null && item.showContinueOverlay) { + { onClickContinueWatching(item.seasonAnime) } + } else { + null + }, + ) + } + SeasonDisplayMode.CompactGrid, SeasonDisplayMode.CoverOnlyGrid -> { + AnimeCompactGridItem( + title = title.takeIf { anime.seasonDisplayGridMode is SeasonDisplayMode.CompactGrid }, + coverData = AnimeCover( + animeId = itemAnime.id, + sourceId = itemAnime.source, + isAnimeFavorite = itemAnime.favorite, + url = itemAnime.thumbnailUrl, + lastModified = itemAnime.coverLastModified, + ), + coverBadgeStart = { + DownloadsBadge(count = item.downloadCount) + UnseenBadge(count = item.unseenCount) + }, + coverBadgeEnd = { + LanguageBadge( + isLocal = item.isLocal, + sourceLanguage = item.sourceLanguage, + ) + }, + onLongClick = { onSeasonClicked(item.seasonAnime) }, + onClick = { onSeasonClicked(item.seasonAnime) }, + onClickContinueWatching = if (onClickContinueWatching != null && item.showContinueOverlay) { + { onClickContinueWatching(item.seasonAnime) } + } else { + null + }, + ) + } + SeasonDisplayMode.List -> { + AnimeListItem( + title = title, + coverData = AnimeCover( + animeId = itemAnime.id, + sourceId = itemAnime.source, + isAnimeFavorite = itemAnime.favorite, + url = itemAnime.thumbnailUrl, + lastModified = itemAnime.coverLastModified, + ), + badge = { + DownloadsBadge(count = item.downloadCount) + UnseenBadge(count = item.unseenCount) + LanguageBadge( + isLocal = item.isLocal, + sourceLanguage = item.sourceLanguage, + ) + }, + onLongClick = { onSeasonClicked(item.seasonAnime) }, + onClick = { onSeasonClicked(item.seasonAnime) }, + onClickContinueWatching = if (onClickContinueWatching != null && item.showContinueOverlay) { + { onClickContinueWatching(item.seasonAnime) } + } else { + null + }, + entries = anime.seasonDisplayGridSize, + containerHeight = containerHeight, + contentPadding = PaddingValues(horizontal = 3.dp, vertical = 3.dp), + ) + } + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt b/app/src/main/java/eu/kanade/presentation/anime/components/ItemHeader.kt similarity index 75% rename from app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt rename to app/src/main/java/eu/kanade/presentation/anime/components/ItemHeader.kt index 4c9df9d92d..db31403503 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeHeader.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/ItemHeader.kt @@ -11,7 +11,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import tachiyomi.i18n.MR +import eu.kanade.tachiyomi.animesource.model.FetchType import tachiyomi.i18n.animiru.AMMR import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.SECONDARY_ALPHA @@ -20,12 +20,15 @@ import tachiyomi.presentation.core.i18n.pluralStringResource import tachiyomi.presentation.core.i18n.stringResource @Composable -fun EpisodeHeader( +fun ItemHeader( enabled: Boolean, - episodeCount: Int?, - missingEpisodeCount: Int, + itemCount: Int?, + missingItemsCount: Int, onClick: () -> Unit, modifier: Modifier = Modifier, + // AY --> + fetchType: FetchType = FetchType.Episodes, + // <-- AY ) { Column( modifier = modifier @@ -38,16 +41,22 @@ fun EpisodeHeader( verticalArrangement = Arrangement.spacedBy(MaterialTheme.padding.extraSmall), ) { Text( - text = if (episodeCount == null) { + text = if (itemCount == null) { stringResource(AYMR.strings.episodes) } else { - pluralStringResource(AYMR.plurals.anime_num_episodes, count = episodeCount, episodeCount) + // AY --> + val pluralCount = when (fetchType) { + FetchType.Seasons -> AYMR.plurals.anime_num_seasons + FetchType.Episodes -> AYMR.plurals.anime_num_episodes + } + pluralStringResource(pluralCount, count = itemCount, itemCount) + // <-- AY }, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onBackground, ) - MissingEpisodesWarning(missingEpisodeCount) + MissingEpisodesWarning(missingItemsCount) } } diff --git a/app/src/main/java/eu/kanade/presentation/library/components/CommonAnimeItem.kt b/app/src/main/java/eu/kanade/presentation/library/components/CommonAnimeItem.kt index d984a96a9d..9c8fd7ca78 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/CommonAnimeItem.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/CommonAnimeItem.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.aspectRatio @@ -342,6 +343,7 @@ fun AnimeListItem( // AY --> entries: Int = 0, containerHeight: Int = 0, + contentPadding: PaddingValues = PaddingValues(horizontal = 16.dp, vertical = 3.dp), // <-- AY ) { Row( @@ -363,7 +365,7 @@ fun AnimeListItem( onLongClick = onLongClick, ) // AY --> - .padding(horizontal = 16.dp, vertical = 3.dp), + .padding(contentPadding), // <-- AY verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt index 0e7caa431f..bc928c79e8 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsLibraryScreen.kt @@ -62,6 +62,9 @@ object SettingsLibraryScreen : SearchableSettings { return listOf( getCategoriesGroup(LocalNavigator.currentOrThrow, allCategories, libraryPreferences), getGlobalUpdateGroup(allCategories, libraryPreferences), + // AY --> + getSeasonBehaviorGroup(libraryPreferences), + // <-- AY getBehaviorGroup(libraryPreferences), ) } @@ -233,6 +236,27 @@ object SettingsLibraryScreen : SearchableSettings { ) } + // AY --> + @Composable + private fun getSeasonBehaviorGroup( + libraryPreferences: LibraryPreferences, + ): Preference.PreferenceGroup { + return Preference.PreferenceGroup( + title = stringResource(AYMR.strings.pref_library_season), + preferenceItems = persistentListOf( + Preference.PreferenceItem.SwitchPreference( + preference = libraryPreferences.updateSeasonOnRefresh(), + title = stringResource(AYMR.strings.pref_update_seasons_refresh), + ), + Preference.PreferenceItem.SwitchPreference( + preference = libraryPreferences.updateSeasonOnLibraryUpdate(), + title = stringResource(AYMR.strings.pref_update_seasons_update), + ), + ), + ) + } + // <-- AY + @Composable private fun getBehaviorGroup( libraryPreferences: LibraryPreferences, diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt index 360bcef4e5..f86b57c7be 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/advanced/ClearDatabaseScreen.kt @@ -39,6 +39,7 @@ import eu.kanade.presentation.browse.components.SourceIcon import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.animesource.model.FetchType import eu.kanade.tachiyomi.util.system.toast import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.flow.collectLatest @@ -48,9 +49,12 @@ import tachiyomi.core.common.util.lang.launchUI import tachiyomi.core.common.util.lang.toLong import tachiyomi.core.common.util.lang.withNonCancellableContext import tachiyomi.data.Database +import tachiyomi.data.source.mapSourceToDomainSource import tachiyomi.domain.source.interactor.GetSourcesWithNonLibraryAnime import tachiyomi.domain.source.model.Source -import tachiyomi.domain.source.model.SourceWithCount +import tachiyomi.domain.source.model.SourceWithIds +import tachiyomi.domain.source.model.StubSource +import tachiyomi.domain.source.service.SourceManager import tachiyomi.i18n.MR import tachiyomi.i18n.animiru.AMMR import tachiyomi.presentation.core.components.LazyColumnWithAction @@ -225,13 +229,38 @@ class ClearDatabaseScreen : Screen() { private class ClearDatabaseScreenModel : StateScreenModel(State.Loading) { private val getSourcesWithNonLibraryAnime: GetSourcesWithNonLibraryAnime = Injekt.get() private val database: Database = Injekt.get() + private val sourceManager: SourceManager = Injekt.get() init { screenModelScope.launchIO { getSourcesWithNonLibraryAnime.subscribe() .collectLatest { list -> + // AY --> + val items = list.groupBy { it.sourceId } + .map { (sourceId, deletableAnime) -> + val source = sourceManager.getOrStub(sourceId) + val domainSource = mapSourceToDomainSource(source).copy( + isStub = source is StubSource, + ) + + val ids = mutableListOf() + val orphaned = mutableListOf() + + deletableAnime.forEach { + ids.add(it.animeId) + if (it.fetchType == FetchType.Seasons) { + val (childrenIds, orphanedIds) = getDeletableChildren(it.animeId) + ids.addAll(childrenIds) + orphaned.addAll(orphanedIds) + } + } + + SourceWithIds(domainSource, ids, orphaned) + } + // <-- AY + mutableState.update { old -> - val items = list.sortedBy { it.name } + val items = items.sortedBy { it.name } when (old) { State.Loading -> State.Ready(items) is State.Ready -> old.copy(items = items) @@ -241,9 +270,44 @@ private class ClearDatabaseScreenModel : StateScreenModel + /** + * Get all children of an anime that can be deleted, as well as any orphans. + * Children that are favorited needs their parentId removed or else they won't be + * able to be removed later. + */ + private suspend fun getDeletableChildren(animeId: Long): Pair, List> { + val ids = mutableListOf() + val orphaned = mutableListOf() + val children = getSourcesWithNonLibraryAnime.getDeletableChildren(animeId) + children.forEach { c -> + if (c.favorite) { + orphaned.add(c.id) + } else { + ids.add(c.id) + if (c.fetchType == FetchType.Seasons) { + val (childrenIds, orphanedIds) = getDeletableChildren(c.id) + ids.addAll(childrenIds) + orphaned.addAll(orphanedIds) + } + } + } + return Pair(ids, orphaned) + } + // <-- AY + suspend fun removeAnimeBySourceId(keepSeenAnime: Boolean) = withNonCancellableContext { val state = state.value as? State.Ready ?: return@withNonCancellableContext - database.animesQueries.deleteNonLibraryAnime(state.selection, keepSeenAnime.toLong()) + // AY --> + val selected = state.items.filter { it.id in state.selection } + + val animeIds = selected.flatMap { it.ids } + val orphaned = selected.flatMap { it.orphaned } + .filterNot { it in animeIds } + + database.animesQueries.deleteAnimesNotInLibraryByAnimeIds(animeIds, keepSeenAnime.toLong()) + database.animesQueries.removeParentIdByIds(orphaned) + // <-- AY database.historyQueries.removeResettedHistory() } @@ -293,7 +357,7 @@ private class ClearDatabaseScreenModel : StateScreenModel, + val items: List, val selection: List = emptyList(), val showConfirmation: Boolean = false, ) : State diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/AnimeBackupCreator.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/AnimeBackupCreator.kt index b1d9b78b85..5d19b86de6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/AnimeBackupCreator.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/create/creators/AnimeBackupCreator.kt @@ -114,6 +114,14 @@ private fun Anime.toBackupAnime( version = this.version, notes = this.notes, initialized = this.initialized, + // AY --> + fetchType = this.fetchType, + parentId = this.parentId, + id = this.id, + seasonFlags = this.seasonFlags, + seasonNumber = this.seasonNumber, + seasonSourceOrder = this.seasonSourceOrder, + // <-- AY ) // AM (CUSTOM_INFORMATION) --> .also { backupAnime -> diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt index 4fc3a41dc0..08184d6925 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupAnime.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.backup.models import eu.kanade.tachiyomi.animesource.model.AnimeUpdateStrategy +import eu.kanade.tachiyomi.animesource.model.FetchType import kotlinx.serialization.Serializable import kotlinx.serialization.protobuf.ProtoNumber import tachiyomi.domain.anime.model.Anime @@ -56,6 +57,16 @@ data class BackupAnime( // AM --> @ProtoNumber(206) var excludedScanlators: List = emptyList(), // <-- AM + + // AY --> + // Aniyomi specific values + @ProtoNumber(501) var fetchType: FetchType = FetchType.Episodes, + @ProtoNumber(502) var parentId: Long? = null, + @ProtoNumber(503) var id: Long? = null, // Used to associate seasons with parents. Do not use for anything else. + @ProtoNumber(504) var seasonFlags: Long = 0, + @ProtoNumber(505) var seasonNumber: Double = -1.0, + @ProtoNumber(506) var seasonSourceOrder: Long = 0, + // <-- AY ) { fun getAnimeImpl(): Anime { return Anime.create().copy( @@ -80,6 +91,13 @@ data class BackupAnime( version = this@BackupAnime.version, notes = this@BackupAnime.notes, initialized = this@BackupAnime.initialized, + // AY --> + fetchType = this@BackupAnime.fetchType, + parentId = this@BackupAnime.parentId, + seasonFlags = this@BackupAnime.seasonFlags, + seasonNumber = this@BackupAnime.seasonNumber, + seasonSourceOrder = this@BackupAnime.seasonSourceOrder, + // <-- AY ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt index 68444ad1d8..00f9ef7e29 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/BackupRestorer.kt @@ -153,11 +153,14 @@ class BackupRestorer( .forEach { ensureActive() + // AY --> + val seasons = backupAnimes.filter { s -> s.parentId == it.id } + // <-- AY try { // AM (CUSTOM_INFORMATION) --> val customInfo = it.getCustomAnimeInfo() // <-- AM (CUSTOM_INFORMATION) - animeRestorer.restore(it, backupCategories, customInfo) + animeRestorer.restore(it, backupCategories, customInfo, seasons) } catch (e: Exception) { val sourceName = sourceMapping[it.source] ?: it.source.toString() errors.add(Date() to "${it.title} [$sourceName]: ${e.message}") diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/AnimeRestorer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/AnimeRestorer.kt index 8f7dde7a0b..46bf3e8a45 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/AnimeRestorer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/restore/restorers/AnimeRestorer.kt @@ -7,6 +7,7 @@ import eu.kanade.tachiyomi.data.backup.models.BackupEpisode import eu.kanade.tachiyomi.data.backup.models.BackupHistory import eu.kanade.tachiyomi.data.backup.models.BackupTracking import tachiyomi.data.DatabaseHandler +import tachiyomi.data.FetchTypeColumnAdapter import tachiyomi.data.UpdateStrategyColumnAdapter import tachiyomi.domain.anime.interactor.FetchInterval import tachiyomi.domain.anime.interactor.GetAnimeByUrlAndSourceId @@ -64,6 +65,9 @@ class AnimeRestorer( // AM (CUSTOM_INFORMATION) --> customInfo: CustomAnimeInfo?, // <-- AM (CUSTOM_INFORMATION) + // AY --> + backupSeasons: List, + // <-- AY ) { handler.await(inTransaction = true) { val dbAnime = findExistingAnime(backupAnime) @@ -74,6 +78,20 @@ class AnimeRestorer( restoreExistingAnime(anime, dbAnime) } + // AY --> + backupSeasons.forEach { bs -> + val dbAnime = findExistingAnime(bs) + val anime = bs.getAnimeImpl().copy( + parentId = restoredAnime.id, + ) + if (dbAnime == null) { + restoreNewAnime(anime) + } else { + restoreExistingAnime(anime, dbAnime) + } + } + // <-- AY + restoreAnimeDetails( anime = restoredAnime, episodes = backupAnime.episodes, @@ -95,9 +113,9 @@ class AnimeRestorer( private suspend fun restoreExistingAnime(anime: Anime, dbAnime: Anime): Anime { return if (anime.version > dbAnime.version) { - updateAnime(dbAnime.copyFrom(anime).copy(id = dbAnime.id)) + updateAnime(dbAnime.copyFrom(anime).copy(id = dbAnime.id, /* AY --> */ parentId = anime.parentId /* <-- AY */)) } else { - updateAnime(anime.copyFrom(dbAnime).copy(id = dbAnime.id)) + updateAnime(anime.copyFrom(dbAnime).copy(id = dbAnime.id, /* AY --> */ parentId = anime.parentId /* <-- AY */)) } } @@ -114,6 +132,10 @@ class AnimeRestorer( // <-- AM (CUSTOM_INFORMATION) initialized = this.initialized || newer.initialized, version = newer.version, + // AY --> + fetchType = newer.fetchType, + parentId = newer.parentId, + // <-- AY ) } @@ -143,6 +165,13 @@ class AnimeRestorer( version = anime.version, isSyncing = 1, notes = anime.notes, + // AY --> + fetchType = anime.fetchType.let(FetchTypeColumnAdapter::encode), + parentId = anime.parentId, + seasonFlags = anime.seasonFlags, + seasonNumber = anime.seasonNumber, + seasonSourceOrder = anime.seasonSourceOrder, + // <-- AY ) } return anime @@ -289,6 +318,13 @@ class AnimeRestorer( updateStrategy = anime.updateStrategy, version = anime.version, notes = anime.notes, + // AY --> + fetchType = anime.fetchType, + parentId = anime.parentId, + seasonFlags = anime.seasonFlags, + seasonNumber = anime.seasonNumber, + seasonSourceOrder = anime.seasonSourceOrder, + // <-- AY ) animesQueries.selectLastInsertedRowId() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt index d2ce77a02f..36d63f3a3a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateJob.kt @@ -23,6 +23,7 @@ import eu.kanade.domain.anime.model.toSAnime import eu.kanade.domain.connection.SyncPreferences import eu.kanade.domain.episode.interactor.SyncEpisodesWithSource import eu.kanade.tachiyomi.animesource.model.AnimeUpdateStrategy +import eu.kanade.tachiyomi.animesource.model.FetchType import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.connection.syncmiru.SyncDataJob @@ -69,6 +70,7 @@ import tachiyomi.domain.library.service.LibraryPreferences.Companion.ANIME_OUTSI import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_CHARGING import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_NETWORK_NOT_METERED import tachiyomi.domain.library.service.LibraryPreferences.Companion.DEVICE_ONLY_ON_WIFI +import tachiyomi.domain.season.interactor.GetAnimeSeasonsByParentId import tachiyomi.domain.source.model.SourceNotInstalledException import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.track.interactor.GetTracks @@ -101,6 +103,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get() private val fetchInterval: FetchInterval = Injekt.get() private val filterEpisodesForDownload: FilterEpisodesForDownload = Injekt.get() + // AY --> + private val getAnimeSeasonsByParentId: GetAnimeSeasonsByParentId = Injekt.get() + // <-- AY // AM (GROUPING) --> private val getTracks: GetTracks = Injekt.get() @@ -235,14 +240,35 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet } // <-- AM (GROUPING) + // AY --> + val includeSeasons = libraryPreferences.updateSeasonOnLibraryUpdate().get() + val lastToUpdateWithSeasons = listToUpdate.flatMap { libAnime -> + when (libAnime.anime.fetchType) { + FetchType.Seasons -> { + if (includeSeasons) { + val seasons = getAnimeSeasonsByParentId.await(libAnime.anime.id) + seasons + .filter { s -> + s.anime.fetchType == FetchType.Episodes && !s.anime.favorite + } + .map { it.toLibraryAnime() } + } else { + emptyList() + } + } + FetchType.Episodes -> listOf(libAnime) + } + } + // <-- AY + val restrictions = libraryPreferences.autoUpdateAnimeRestrictions().get() val skippedUpdates = mutableListOf>() val (_, fetchWindowUpperBound) = fetchInterval.getWindow(ZonedDateTime.now()) - animeToUpdate = listToUpdate + animeToUpdate = lastToUpdateWithSeasons .filter { when { - it.anime.updateStrategy == AnimeUpdateStrategy.ONLY_FETCH_ONCE && it.totalEpisodes > 0L -> { + it.anime.updateStrategy == AnimeUpdateStrategy.ONLY_FETCH_ONCE && it.totalCount > 0L -> { skippedUpdates.add( it.anime to context.stringResource(MR.strings.skipped_reason_not_always_update), ) @@ -261,7 +287,7 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet false } - ANIME_NON_SEEN in restrictions && it.totalEpisodes > 0L && !it.hasStarted -> { + ANIME_NON_SEEN in restrictions && /* AY --> */ it.totalCount /* <-- AY */ > 0L && !it.hasStarted -> { skippedUpdates.add( it.anime to context.stringResource(AMMR.strings.skipped_reason_not_started_anime), ) @@ -320,7 +346,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet ensureActive() // Don't continue to update if anime is not in library - if (getAnime.await(anime.id)?.favorite != true) { + // AY --> + if (anime.parentId == null && getAnime.await(anime.id)?.favorite != true) { + // <-- AY return@forEach } @@ -410,7 +438,9 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // Get anime from database to account for if it was removed during the update and // to get latest data so it doesn't get overwritten later on - val dbAnime = getAnime.await(anime.id)?.takeIf { it.favorite } ?: return emptyList() + // AY --> + val dbAnime = getAnime.await(anime.id)?.takeIf { it.parentId != null || it.favorite } ?: return emptyList() + // <-- AY return syncEpisodesWithSource.await(episodes, dbAnime, source, false, fetchWindow) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt index 7dad70caf2..b1538afd31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -34,12 +34,14 @@ import tachiyomi.data.Animes import tachiyomi.data.Database import tachiyomi.data.DatabaseHandler import tachiyomi.data.DateColumnAdapter +import tachiyomi.data.FetchTypeColumnAdapter import tachiyomi.data.History import tachiyomi.data.StringListColumnAdapter import tachiyomi.data.UpdateStrategyColumnAdapter import tachiyomi.domain.anime.interactor.GetCustomAnimeInfo import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.storage.service.StorageManager +import tachiyomi.source.local.LocalFetchTypeManager import tachiyomi.source.local.image.LocalCoverManager import tachiyomi.source.local.io.LocalSourceFileSystem import uy.kohesive.injekt.api.InjektModule @@ -88,6 +90,9 @@ class AppModule(val app: Application) : InjektModule { animesAdapter = Animes.Adapter( genreAdapter = StringListColumnAdapter, update_strategyAdapter = UpdateStrategyColumnAdapter, + // AY --> + fetch_typeAdapter = FetchTypeColumnAdapter, + // <-- AY ), ) } @@ -138,6 +143,9 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { AndroidStorageFolderProvider(app) } addSingletonFactory { LocalSourceFileSystem(get()) } addSingletonFactory { LocalCoverManager(app, get()) } + // AY --> + addSingletonFactory { LocalFetchTypeManager(app, get()) } + // <-- AY addSingletonFactory { StorageManager(app, get()) } // AY --> diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt index 2baf10b4d1..13c7711463 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt @@ -57,6 +57,9 @@ class AndroidSourceManager( context, Injekt.get(), Injekt.get(), + // AY --> + Injekt.get(), + // <-- AY ), ), ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreen.kt index cedecded45..9468cde0b9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreen.kt @@ -32,6 +32,7 @@ import eu.kanade.presentation.anime.DuplicateAnimeDialog import eu.kanade.presentation.anime.EditCoverAction import eu.kanade.presentation.anime.EpisodeOptionsDialogScreen import eu.kanade.presentation.anime.EpisodeSettingsDialog +import eu.kanade.presentation.anime.SeasonSettingsDialog import eu.kanade.presentation.anime.components.AnimeCoverDialog import eu.kanade.presentation.anime.components.DeleteEpisodesDialog import eu.kanade.presentation.anime.components.ScanlatorFilterDialog @@ -45,6 +46,7 @@ import eu.kanade.presentation.util.formatEpisodeNumber import eu.kanade.presentation.util.isTabletUi import eu.kanade.tachiyomi.animesource.AnimeSource import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.FetchType import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.source.isLocalOrStub import eu.kanade.tachiyomi.ui.anime.notes.AnimeNotesScreen @@ -148,7 +150,11 @@ class AnimeScreen( } // <-- AY }, - onDownloadEpisode = screenModel::runEpisodeDownloadActions.takeIf { !successState.source.isLocalOrStub() }, + onDownloadEpisode = screenModel::runEpisodeDownloadActions.takeIf { + // AY --> + !successState.source.isLocalOrStub() && successState.anime.fetchType == FetchType.Episodes + // <-- AY + }, onAddToLibraryClicked = { screenModel.toggleFavorite() haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -173,7 +179,9 @@ class AnimeScreen( } else { screenModel.showTrackDialog() } - }, + // AY --> + }.takeIf { successState.anime.fetchType == FetchType.Episodes }, + // <-- AY onTagSearch = { scope.launch { performGenreSearch(navigator, it, screenModel.source!!) } }, onFilterButtonClicked = screenModel::showSettingsDialog, onRefresh = screenModel::fetchAllFromSource, @@ -188,7 +196,11 @@ class AnimeScreen( onSearch = { query, global -> scope.launch { performSearch(navigator, query, global) } }, onCoverClicked = screenModel::showCoverDialog, onShareClicked = { shareAnime(context, screenModel.anime, screenModel.source) }.takeIf { isHttpSource }, - onDownloadActionClicked = screenModel::runDownloadAction.takeIf { !successState.source.isLocalOrStub() }, + onDownloadActionClicked = screenModel::runDownloadAction.takeIf { + // AY --> + !successState.source.isLocalOrStub() && successState.anime.fetchType == FetchType.Episodes + // <-- AY + }, onEditCategoryClicked = screenModel::showChangeCategoryDialog.takeIf { successState.anime.favorite }, onEditFetchIntervalClicked = screenModel::showSetFetchIntervalDialog.takeIf { successState.anime.favorite @@ -200,7 +212,11 @@ class AnimeScreen( onSettingsClicked = { navigator.push(SourcePreferencesScreen(successState.source.id)) }.takeIf { isConfigurableSource }, - onSkipIntroClicked = screenModel::showAnimeSkipIntroDialog.takeIf { successState.anime.favorite }, + onSkipIntroClicked = screenModel::showAnimeSkipIntroDialog.takeIf { + // AY --> + successState.anime.favorite && successState.anime.fetchType == FetchType.Episodes + // <-- AY + }, // <-- AY // AM (CUSTOM_INFORMATION) --> onEditInfoClicked = screenModel::showEditAnimeInfoDialog, @@ -217,6 +233,19 @@ class AnimeScreen( onEpisodeSelected = screenModel::toggleSelection, onAllEpisodeSelected = screenModel::toggleAllSelection, onInvertSelection = screenModel::invertSelection, + // AY --> + onSeasonClicked = { + navigator.push(AnimeScreen(it.id)) + }, + onContinueWatchingClicked = { + scope.launchIO { + val episode = screenModel.getNextUnseenEpisode(it.anime) + episode?.let { ep -> + openEpisode(context, ep, screenModel.alwaysUseExternalPlayer) + } + } + }, + // <-- AY ) var showScanlatorsDialog by remember { mutableStateOf(false) } @@ -271,7 +300,7 @@ class AnimeScreen( onDismissRequest = onDismissRequest, ) } - AnimeScreenModel.Dialog.SettingsSheet -> EpisodeSettingsDialog( + AnimeScreenModel.Dialog.EpisodeSettingsSheet -> EpisodeSettingsDialog( onDismissRequest = onDismissRequest, anime = successState.anime, onDownloadFilterChanged = screenModel::setDownloadedFilter, @@ -287,6 +316,27 @@ class AnimeScreen( scanlatorFilterActive = successState.scanlatorFilterActive, onScanlatorFilterClicked = { showScanlatorsDialog = true }, ) + // AY --> + AnimeScreenModel.Dialog.SeasonSettingsSheet -> SeasonSettingsDialog( + onDismissRequest = onDismissRequest, + anime = successState.anime, + onDownloadFilterChanged = screenModel::setSeasonDownloadedFilter, + onUnseenFilterChanged = screenModel::setSeasonUnseenFilter, + onStartedFilterChanged = screenModel::setSeasonStartedFilter, + onBookmarkedFilterChanged = screenModel::setSeasonBookmarkedFilter, + onCompletedFilterChanged = screenModel::setSeasonCompletedFilter, + onSortModeChanged = screenModel::setSeasonSorting, + onDisplayGridModeChanged = screenModel::setSeasonDisplayGridMode, + onDisplayGridSizeChanged = screenModel::setSeasonDisplayGridSize, + onOverlayDownloadedChanged = screenModel::setSeasonDownloadOverlay, + onOverlayUnseenChanged = screenModel::setSeasonUnseenOverlay, + onOverlayLocalChanged = screenModel::setSeasonLocalOverlay, + onOverlayLangChanged = screenModel::setSeasonLangOverlay, + onOverlayContinueChanged = screenModel::setSeasonContinueOverlay, + onDisplayModeChanged = screenModel::setSeasonDisplayMode, + onSetAsDefault = screenModel::setSeasonCurrentSettingsAsDefault, + ) + // <-- AY AnimeScreenModel.Dialog.TrackSheet -> { NavigatorAdaptiveSheet( screen = TrackInfoDialogHomeScreen( diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreenModel.kt index 1ee162016e..0a113c2f8f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeScreenModel.kt @@ -8,6 +8,8 @@ import androidx.compose.runtime.Immutable import androidx.compose.ui.util.fastAny import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle +import aniyomi.domain.anime.SeasonAnime +import aniyomi.domain.anime.SeasonDisplayMode import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope import eu.kanade.core.util.addOrRemove @@ -15,9 +17,12 @@ import eu.kanade.core.util.insertSeparators import eu.kanade.domain.anime.interactor.GetExcludedScanlators import eu.kanade.domain.anime.interactor.SetAnimeViewerFlags import eu.kanade.domain.anime.interactor.SetExcludedScanlators +import eu.kanade.domain.anime.interactor.SyncSeasonsWithSource import eu.kanade.domain.anime.interactor.UpdateAnime import eu.kanade.domain.anime.model.downloadedFilter import eu.kanade.domain.anime.model.episodesFiltered +import eu.kanade.domain.anime.model.seasonDownloadedFilter +import eu.kanade.domain.anime.model.seasonsFiltered import eu.kanade.domain.anime.model.toSAnime import eu.kanade.domain.episode.interactor.GetAvailableScanlators import eu.kanade.domain.episode.interactor.SetSeenStatus @@ -31,6 +36,9 @@ import eu.kanade.presentation.anime.DownloadAction import eu.kanade.presentation.anime.components.EpisodeDownloadAction import eu.kanade.presentation.util.formattedMessage import eu.kanade.tachiyomi.animesource.AnimeSource +import eu.kanade.tachiyomi.animesource.UnmeteredSource +import eu.kanade.tachiyomi.animesource.model.FetchType +import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.model.Video import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager @@ -49,8 +57,11 @@ import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.trimOrNull import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine @@ -70,20 +81,23 @@ import tachiyomi.core.common.util.lang.launchNonCancellable import tachiyomi.core.common.util.lang.withIOContext import tachiyomi.core.common.util.lang.withUIContext import tachiyomi.core.common.util.system.logcat -import tachiyomi.domain.anime.interactor.GetAnimeWithEpisodes +import tachiyomi.domain.anime.interactor.GetAnimeWithEpisodesAndSeasons import tachiyomi.domain.anime.interactor.GetDuplicateLibraryAnime import tachiyomi.domain.anime.interactor.SetAnimeEpisodeFlags +import tachiyomi.domain.anime.interactor.SetAnimeSeasonFlags import tachiyomi.domain.anime.interactor.SetCustomAnimeInfo import tachiyomi.domain.anime.model.Anime import tachiyomi.domain.anime.model.AnimeUpdate import tachiyomi.domain.anime.model.AnimeWithEpisodeCount import tachiyomi.domain.anime.model.CustomAnimeInfo +import tachiyomi.domain.anime.model.NoSeasonsException import tachiyomi.domain.anime.model.applyFilter import tachiyomi.domain.anime.repository.AnimeRepository import tachiyomi.domain.category.interactor.GetCategories import tachiyomi.domain.category.interactor.SetAnimeCategories import tachiyomi.domain.category.model.Category import tachiyomi.domain.download.service.DownloadPreferences +import tachiyomi.domain.episode.interactor.GetEpisodesByAnimeId import tachiyomi.domain.episode.interactor.SetAnimeDefaultEpisodeFlags import tachiyomi.domain.episode.interactor.UpdateEpisode import tachiyomi.domain.episode.model.Episode @@ -92,6 +106,9 @@ import tachiyomi.domain.episode.model.NoEpisodesException import tachiyomi.domain.episode.service.calculateEpisodeGap import tachiyomi.domain.episode.service.getEpisodeSort import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.season.interactor.SetAnimeDefaultSeasonFlags +import tachiyomi.domain.season.service.getSeasonSortComparator +import tachiyomi.domain.season.service.seasonSortAlphabetically import tachiyomi.domain.source.service.SourceManager import tachiyomi.domain.storage.service.StoragePreferences import tachiyomi.domain.track.interactor.GetTracks @@ -121,22 +138,34 @@ class AnimeScreenModel( private val trackEpisode: TrackEpisode = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(), - private val getAnimeAndEpisodes: GetAnimeWithEpisodes = Injekt.get(), + // AY --> + private val getAnimeAndEpisodesAndSeasons: GetAnimeWithEpisodesAndSeasons = Injekt.get(), + // <-- AY private val getDuplicateLibraryAnime: GetDuplicateLibraryAnime = Injekt.get(), private val getAvailableScanlators: GetAvailableScanlators = Injekt.get(), private val getExcludedScanlators: GetExcludedScanlators = Injekt.get(), private val setExcludedScanlators: SetExcludedScanlators = Injekt.get(), private val setAnimeEpisodeFlags: SetAnimeEpisodeFlags = Injekt.get(), private val setAnimeDefaultEpisodeFlags: SetAnimeDefaultEpisodeFlags = Injekt.get(), + // AY --> + private val setAnimeSeasonFlags: SetAnimeSeasonFlags = Injekt.get(), + private val setAnimeDefaultSeasonFlags: SetAnimeDefaultSeasonFlags = Injekt.get(), + // <-- AY private val setSeenStatus: SetSeenStatus = Injekt.get(), private val updateEpisode: UpdateEpisode = Injekt.get(), private val updateAnime: UpdateAnime = Injekt.get(), private val syncEpisodesWithSource: SyncEpisodesWithSource = Injekt.get(), + // AY --> + private val syncSeasonsWithSource: SyncSeasonsWithSource = Injekt.get(), + // <-- AY private val getCategories: GetCategories = Injekt.get(), private val getTracks: GetTracks = Injekt.get(), private val addTracks: AddTracks = Injekt.get(), private val setAnimeCategories: SetAnimeCategories = Injekt.get(), private val animeRepository: AnimeRepository = Injekt.get(), + // AY --> + private val getEpisodesByAnimeId: GetEpisodesByAnimeId = Injekt.get(), + // <-- AY private val filterEpisodesForDownload: FilterEpisodesForDownload = Injekt.get(), // AM (FILE_SIZE) --> private val storagePreferences: StoragePreferences = Injekt.get(), @@ -206,16 +235,19 @@ class AnimeScreenModel( init { screenModelScope.launchIO { combine( - getAnimeAndEpisodes.subscribe(animeId, applyScanlatorFilter = true).distinctUntilChanged(), + getAnimeAndEpisodesAndSeasons.subscribe(animeId, applyScanlatorFilter = true).distinctUntilChanged(), downloadCache.changes, downloadManager.queueState, - ) { animeAndEpisodes, _, _ -> animeAndEpisodes } + ) { animeAndEpisodesAndSeasons, _, _ -> animeAndEpisodesAndSeasons } .flowWithLifecycle(lifecycle) - .collectLatest { (anime, episodes) -> + .collectLatest { (anime, episodes, seasons) -> updateSuccessState { it.copy( anime = anime, episodes = episodes.toEpisodeListItems(anime), + // AY --> + seasons = seasons.toAnimeSeasonItems(), + // <-- AY ) } } @@ -246,27 +278,58 @@ class AnimeScreenModel( observeDownloads() screenModelScope.launchIO { - val anime = getAnimeAndEpisodes.awaitAnime(animeId) - val episodes = getAnimeAndEpisodes.awaitEpisodes(animeId, applyScanlatorFilter = true) - .toEpisodeListItems(anime) + // AY --> + val oldAnime = getAnimeAndEpisodesAndSeasons.awaitAnime(animeId) + + // TODO(16): Remove checks + val source = sourceManager.getOrStub(oldAnime.source) + val anime = if (source.javaClass.declaredMethods.any { it.name == "getSeasonList" }) { + oldAnime + } else { + oldAnime.copy(fetchType = FetchType.Episodes) + } + + val episodes = if (anime.fetchType == FetchType.Seasons) { + emptyList() + } else { + getAnimeAndEpisodesAndSeasons.awaitEpisodes(animeId) + .toEpisodeListItems(anime) + } + + val seasons = if (anime.fetchType == FetchType.Episodes) { + emptyList() + } else { + getAnimeAndEpisodesAndSeasons.awaitSeasons(animeId) + .toAnimeSeasonItems() + } + // <-- AY if (!anime.favorite) { setAnimeDefaultEpisodeFlags.await(anime) + // AY --> + setAnimeDefaultSeasonFlags.await(anime) + // <-- AY } val needRefreshInfo = !anime.initialized - val needRefreshEpisode = episodes.isEmpty() + // AY --> + val needRefreshEpisode = episodes.isEmpty() && anime.fetchType == FetchType.Episodes + val needRefreshSeason = seasons.isEmpty() && anime.fetchType == FetchType.Seasons + // <-- AY // Show what we have earlier mutableState.update { State.Success( anime = anime, - source = Injekt.get().getOrStub(anime.source), + source = source, isFromSource = isFromSource, episodes = episodes, availableScanlators = getAvailableScanlators.await(animeId), excludedScanlators = getExcludedScanlators.await(animeId), - isRefreshingData = needRefreshInfo || needRefreshEpisode, + // AY --> + seasons = seasons, + isRefreshingData = needRefreshInfo || needRefreshEpisode || needRefreshSeason, + // <-- AY dialog = null, hideMissingEpisodes = libraryPreferences.hideMissingEpisodes().get(), ) @@ -279,7 +342,11 @@ class AnimeScreenModel( if (screenModelScope.isActive) { val fetchFromSourceTasks = listOf( async { if (needRefreshInfo) fetchAnimeFromSource() }, - async { if (needRefreshEpisode) fetchEpisodesFromSource() }, + async { + // AY --> + if (needRefreshEpisode || needRefreshSeason) fetchEpisodesAndSeasonsFromSource() + // <-- AY + }, ) fetchFromSourceTasks.awaitAll() } @@ -294,7 +361,7 @@ class AnimeScreenModel( updateSuccessState { it.copy(isRefreshingData = true) } val fetchFromSourceTasks = listOf( async { fetchAnimeFromSource(manualFetch) }, - async { fetchEpisodesFromSource(manualFetch) }, + async { fetchEpisodesAndSeasonsFromSource(manualFetch) }, ) fetchFromSourceTasks.awaitAll() updateSuccessState { it.copy(isRefreshingData = false) } @@ -667,29 +734,82 @@ class AnimeScreenModel( } } - /** - * Requests an updated list of episodes from the source. - */ + // AY --> + private fun List.toAnimeSeasonItems(): List { + return map { seasonAnime -> + AnimeSeasonItem( + seasonAnime = seasonAnime, + downloadCount = downloadManager.getDownloadCount(seasonAnime.anime).toLong(), + unseenCount = seasonAnime.unseenCount, + isLocal = seasonAnime.anime.isLocal(), + sourceLanguage = sourceManager.getOrStub(seasonAnime.anime.source).lang, + showContinueOverlay = false, + ) + } + } + // <-- AY + private suspend fun fetchEpisodesFromSource(manualFetch: Boolean = false) { val state = successState ?: return try { withIOContext { - val episodes = state.source.getEpisodeList(state.anime.toSAnime()) + updateEpisodesFromSource(state.anime, state.source, manualFetch) + } + } catch (e: Throwable) { + val message = if (e is NoEpisodesException) { + context.stringResource(AYMR.strings.no_episodes_error) + } else { + logcat(LogPriority.ERROR, e) + with(context) { e.formattedMessage } + } - val newEpisodes = syncEpisodesWithSource.await( - episodes, + screenModelScope.launch { + snackbarHostState.showSnackbar(message = message) + } + val newAnime = animeRepository.getAnimeById(animeId) + updateSuccessState { it.copy(anime = newAnime, isRefreshingData = false) } + } + } + + // AY --> + private suspend fun updateEpisodesFromSource( + anime: Anime, + source: AnimeSource, + manualFetch: Boolean = false, + ) { + val episodes = source.getEpisodeList(anime.toSAnime()) + + val newEpisodes = syncEpisodesWithSource.await( + episodes, + anime, + source, + manualFetch, + ) + + if (manualFetch) { + downloadNewEpisodes(newEpisodes) + } + } + + private suspend fun fetchSeasonsFromSource(manualFetch: Boolean = false) { + val state = successState ?: return + try { + withIOContext { + val seasons = state.source.getSeasonList(state.anime.toSAnime()) + + val newSeasons = syncSeasonsWithSource.await( + seasons, state.anime, state.source, - manualFetch, ) - if (manualFetch) { - downloadNewEpisodes(newEpisodes) + if (libraryPreferences.updateSeasonOnRefresh().get()) { + fetchEpisodesFromSeasons(newSeasons, manualFetch) } } } catch (e: Throwable) { - val message = if (e is NoEpisodesException) { - context.stringResource(AYMR.strings.no_episodes_error) + val message = if (e is NoSeasonsException) { + context.stringResource(AYMR.strings.no_seasons_error) } else { logcat(LogPriority.ERROR, e) with(context) { e.formattedMessage } @@ -703,6 +823,51 @@ class AnimeScreenModel( } } + /** + * Requests an updated list of episodes and seasons from the source. + */ + private suspend fun fetchEpisodesAndSeasonsFromSource(manualFetch: Boolean = false) { + val state = successState ?: return + + when (state.anime.fetchType) { + FetchType.Seasons -> fetchSeasonsFromSource(manualFetch) + FetchType.Episodes -> fetchEpisodesFromSource(manualFetch) + } + } + + /** + * Fetch episodes from all seasons of an anime. + */ + private suspend fun CoroutineScope.fetchEpisodesFromSeasons(seasons: List, manualFetch: Boolean) { + val state = successState ?: return + + val fetch: suspend (Anime) -> Unit = { s -> + // Only fetch seasons with `Episodes` fetch type and only for non completed, unless they + // haven't been fetched at all. + if (s.fetchType === FetchType.Episodes && (s.lastUpdate == 0L || s.status.toInt() != SAnime.COMPLETED)) { + try { + updateEpisodesFromSource(s, state.source, manualFetch) + } catch (e: Throwable) { + logcat(LogPriority.ERROR, e) + } + } + } + + if (state.source is UnmeteredSource) { + seasons.map { s -> + async(Dispatchers.IO) { + fetch(s) + } + }.awaitAll() + } else { + seasons.forEach { s -> + ensureActive() + fetch(s) + } + } + } + // <-- AY + /** * @throws IllegalStateException if the swipe action is [LibraryPreferences.EpisodeSwipeAction.Disabled] */ @@ -751,6 +916,12 @@ class AnimeScreenModel( } } + // AY --> + suspend fun getNextUnseenEpisode(anime: Anime): Episode? { + return getEpisodesByAnimeId.await(anime.id).getNextUnseen(anime, downloadManager) + } + // <-- AY + /** * Returns the next unseen episode or null if everything is seen. */ @@ -1120,6 +1291,220 @@ class AnimeScreenModel( } } + // AY --> + /** + * Sets the season download filter and requests an UI update. + * @param state whether to display only downloaded seasons or all seasons. + */ + fun setSeasonDownloadedFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_DOWNLOADED + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_NOT_DOWNLOADED + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetDownloadedFilter(anime, flag) + } + } + + /** + * Sets the season seen filter and requests an UI update. + * @param state whether to display only unseen seasons or all seasons. + */ + fun setSeasonUnseenFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_UNSEEN + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_SEEN + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetUnseenFilter(anime, flag) + } + } + + /** + * Sets the season started filter and requests an UI update. + * @param state whether to display only started seasons or all seasons. + */ + fun setSeasonStartedFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_STARTED + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_NOT_STARTED + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetStartedFilter(anime, flag) + } + } + + /** + * Sets the season bookmarked filter and requests an UI update. + * @param state whether to display only bookmarked seasons or all seasons. + */ + fun setSeasonBookmarkedFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_BOOKMARKED + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_NOT_BOOKMARKED + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetBookmarkedFilter(anime, flag) + } + } + + /** + * Sets the season completed filter and requests an UI update. + * @param state whether to display only completed seasons or all seasons. + */ + fun setSeasonCompletedFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_COMPLETED + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_NOT_COMPLETED + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetCompletedFilter(anime, flag) + } + } + + /** + * Sets the season sorting method and requests an UI update. + * @param sort the sorting mode. + */ + fun setSeasonSorting(sort: Long) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetSortingModeOrFlipOrder(anime, sort) + } + } + + /** + * Sets the season grid display method and requests an UI update. + * @param mode the display mode. + */ + fun setSeasonDisplayGridMode(mode: SeasonDisplayMode) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetGridMode(anime, mode) + } + } + + /** + * Sets the season grid size and requests an UI update. + * @param size the size. + */ + fun setSeasonDisplayGridSize(size: Int) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetGridSize(anime, size) + } + } + + /** + * Sets the season download overlay and requests an UI update. + * @param visible the visibility. + */ + fun setSeasonDownloadOverlay(visible: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetDownloadedOverlay(anime, visible) + } + } + + /** + * Sets the season unseen overlay and requests an UI update. + * @param visible the visibility. + */ + fun setSeasonUnseenOverlay(visible: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetUnseenOverlay(anime, visible) + } + } + + /** + * Sets the season local overlay and requests an UI update. + * @param visible the visibility. + */ + fun setSeasonLocalOverlay(visible: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetLocalOverlay(anime, visible) + } + } + + /** + * Sets the season lang overlay and requests an UI update. + * @param visible the visibility. + */ + fun setSeasonLangOverlay(visible: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetLangOverlay(anime, visible) + } + } + + /** + * Sets the season continue overlay and requests an UI update. + * @param visible the visibility. + */ + fun setSeasonContinueOverlay(visible: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetContinueOverlay(anime, visible) + } + } + + /** + * Sets the active season display mode. + * @param mode the mode to set. + */ + fun setSeasonDisplayMode(mode: Long) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetDisplayMode(anime, mode) + } + } + + fun setSeasonCurrentSettingsAsDefault(applyToExisting: Boolean) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + libraryPreferences.setSeasonSettingsDefault(anime) + if (applyToExisting) { + setAnimeDefaultSeasonFlags.awaitAll() + } + snackbarHostState.showSnackbar( + message = context.stringResource(AYMR.strings.season_settings_updated), + ) + } + } + // <-- AY + fun resetToDefaultSettings() { val anime = successState?.anime ?: return screenModelScope.launchNonCancellable { @@ -1295,7 +1680,10 @@ class AnimeScreenModel( // <-- AM (CUSTOM_INFORMATION) data object ChangeAnimeSkipIntro : Dialog - data object SettingsSheet : Dialog + // AY --> + data object EpisodeSettingsSheet : Dialog + data object SeasonSettingsSheet : Dialog + // <-- AY data object TrackSheet : Dialog data object FullCover : Dialog } @@ -1309,7 +1697,14 @@ class AnimeScreenModel( } fun showSettingsDialog() { - updateSuccessState { it.copy(dialog = Dialog.SettingsSheet) } + updateSuccessState { + // AY --> + when (it.anime.fetchType) { + FetchType.Seasons -> it.copy(dialog = Dialog.SeasonSettingsSheet) + FetchType.Episodes -> it.copy(dialog = Dialog.EpisodeSettingsSheet) + } + // <-- AY + } } fun showTrackDialog() { @@ -1357,6 +1752,9 @@ class AnimeScreenModel( val source: AnimeSource, val isFromSource: Boolean, val episodes: List, + // AY --> + val seasons: List, + // <-- AY val availableScanlators: Set, val excludedScanlators: Set, val trackingCount: Int = 0, @@ -1373,6 +1771,12 @@ class AnimeScreenModel( ), // <-- AY ) : State { + // AY --> + val processedSeasons by lazy { + seasons.applySeasonFilters(anime).toList() + } + // <-- AY + val processedEpisodes by lazy { episodes.applyFilters(anime).toList() } @@ -1429,7 +1833,12 @@ class AnimeScreenModel( get() = excludedScanlators.intersect(availableScanlators).isNotEmpty() val filterActive: Boolean - get() = scanlatorFilterActive || anime.episodesFiltered() + // AY --> + get() = scanlatorFilterActive || when (anime.fetchType) { + FetchType.Episodes -> anime.episodesFiltered() + FetchType.Seasons -> anime.seasonsFiltered() + } + // <-- AY /** * Applies the view filters to the list of episodes obtained from the database. @@ -1452,6 +1861,44 @@ class AnimeScreenModel( .filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalAnime } } .sortedWith { (episode1), (episode2) -> getEpisodeSort(anime).invoke(episode1, episode2) } } + + // AY --> + private fun List.applySeasonFilters(anime: Anime): Sequence { + val unseenFilter = anime.seasonUnseenFilter + val downloadedFilter = anime.seasonDownloadedFilter + val startedFilter = anime.seasonStartedFilter + val bookmarkedFilter = anime.seasonBookmarkedFilter + val completedFilter = anime.seasonCompletedFilter + + val comparator = getSeasonSortComparator(anime) + .let { if (anime.seasonSortDescending()) it.reversed() else it } + .thenComparator(seasonSortAlphabetically) + + return asSequence() + .filter { (season) -> applyFilter(unseenFilter) { !season.seen } } + .filter { (season) -> applyFilter(startedFilter) { season.hasStarted } } + .filter { (season) -> + applyFilter(completedFilter) { season.anime.status.toInt() == SAnime.COMPLETED } + } + .filter { (season) -> applyFilter(bookmarkedFilter) { season.hasBookmarks } } + .filter { applyFilter(downloadedFilter) { it.downloadCount > 0 || it.seasonAnime.anime.isLocal() } } + .sortedWith(compareBy(comparator) { it.seasonAnime }) + .map { + val itemAnime = it.seasonAnime.anime + AnimeSeasonItem( + seasonAnime = it.seasonAnime, + downloadCount = if (anime.seasonDownloadedOverlay) it.downloadCount else -1L, + unseenCount = if (anime.seasonUnseenOverlay) it.unseenCount else -1L, + isLocal = anime.seasonLocalOverlay && it.isLocal, + sourceLanguage = if (anime.seasonLangOverlay) it.sourceLanguage else "", + showContinueOverlay = + anime.seasonContinueOverlay && + it.unseenCount > 0 && + itemAnime.fetchType == FetchType.Episodes, + ) + } + } + // <-- AY } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeSeasonItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeSeasonItem.kt new file mode 100644 index 0000000000..eab3b258ce --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeSeasonItem.kt @@ -0,0 +1,14 @@ +// AY --> +package eu.kanade.tachiyomi.ui.anime + +import aniyomi.domain.anime.SeasonAnime + +data class AnimeSeasonItem( + val seasonAnime: SeasonAnime, + val downloadCount: Long = -1L, + val unseenCount: Long = -1L, + val isLocal: Boolean = false, + val sourceLanguage: String = "", + val showContinueOverlay: Boolean = false, +) +// <-- AY 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 60f49a7f07..04611564d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/library/LibraryScreenModel.kt @@ -396,7 +396,9 @@ class LibraryScreenModel( else -> anime1.libraryAnime.unseenCount.compareTo(anime2.libraryAnime.unseenCount) } LibrarySort.Type.TotalEpisodes -> { - anime1.libraryAnime.totalEpisodes.compareTo(anime2.libraryAnime.totalEpisodes) + // AY --> + anime1.libraryAnime.totalCount.compareTo(anime2.libraryAnime.totalCount) + // <-- AY } LibrarySort.Type.LatestEpisode -> { anime1.libraryAnime.latestUpload.compareTo(anime2.libraryAnime.latestUpload) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt index cacf38dba5..129f7c3648 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerViewModel.kt @@ -1303,7 +1303,9 @@ class PlayerViewModel @JvmOverloads constructor( getHosterVideoLinksJob = viewModelScope.launchIO { _hosterState.update { _ -> hosterList.map { hoster -> - if (hoster.videoList == null) { + if (hoster.lazy) { + HosterState.Idle(hoster.hosterName) + } else if (hoster.videoList == null) { HosterState.Loading(hoster.hosterName) } else { val videoList = hoster.videoList!! @@ -1478,7 +1480,11 @@ class PlayerViewModel @JvmOverloads constructor( _hosterState.updateAt(index, HosterState.Loading(hosterName)) viewModelScope.launchIO { - val hosterState = EpisodeLoader.loadHosterVideos(currentSource.value!!, hosterList.value[index]) + val hosterState = EpisodeLoader.loadHosterVideos( + source = currentSource.value!!, + hoster = hosterList.value[index], + force = true, + ) _hosterState.updateAt(index, hosterState) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/loader/EpisodeLoader.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/loader/EpisodeLoader.kt index ebe10ec36d..36e699d5eb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/loader/EpisodeLoader.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/loader/EpisodeLoader.kt @@ -66,7 +66,11 @@ class EpisodeLoader { */ private suspend fun getHostersOnHttp(episode: Episode, source: AnimeHttpSource): List { // TODO(1.6): Remove else block when dropping support for ext lib <1.6 - return if (source.javaClass.declaredMethods.any { it.name == "getHosterList" }) { + return if (source.javaClass.declaredMethods.any { + it.name in + listOf("getHosterList", "hosterListRequest", "hosterListParse") + } + ) { source.getHosterList(episode.toSEpisode()) .let { source.run { it.sortHosters() } } } else { @@ -132,12 +136,18 @@ class EpisodeLoader { * @param hoster the hoster. */ private suspend fun getVideos(source: AnimeSource, hoster: Hoster): List