diff --git a/CHANGELOG.md b/CHANGELOG.md index 894dcc052b..394f47e85f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ### Other - Merged from Aniyomi and Mihon ([@Secozzi](https://github.com/Secozzi)) ([#102](https://github.com/quickdesh/Animiru/pull/102)) +- Add support for extension lib 16 ([@Secozzi](https://github.com/Secozzi)) ([#104](https://github.com/quickdesh/Animiru/pull/104)) ## [v0.17.2.0] - 2024-07-27 ### Fixes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a4d9bd538..d6381492ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,6 @@ Surround the new code with: - **AM (REMOVE_TABBED_SCREENS)** --> Refactoring from Aniyomi code to Animiru! - **AM (REMOVE_ACRA_FIREBASE)** --> Refactoring from Aniyomi code to Animiru! - **AM (REMOVE_LIBRARIES)** --> Refactoring from Aniyomi code to Animiru! -- **AM (FILLERMARK)** --> Thank you Quickdesh! - **AM (BROWSE)** --> Thank you Quickdesh! - **AM (KEYBOARD_CONTROLS)** --> Thank you Quickdesh! - **AM (NAVIGATION_PILL)** --> Thank you Quickdesh! diff --git a/app/src/main/java/eu/kanade/domain/DomainModule.kt b/app/src/main/java/eu/kanade/domain/DomainModule.kt index 979d62bccb..286d2d2c38 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()) } @@ -156,9 +160,17 @@ class DomainModule : InjektModule { addFactory { SetExcludedScanlators(get()) } addFactory { MigrateAnimeUseCase( - get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), get(), + get(), get(), 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/interactor/UpdateAnime.kt b/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt index 6fb63facf3..262e03f0f9 100644 --- a/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt +++ b/app/src/main/java/eu/kanade/domain/anime/interactor/UpdateAnime.kt @@ -1,7 +1,9 @@ package eu.kanade.domain.anime.interactor +import eu.kanade.domain.anime.model.hasCustomBackground import eu.kanade.domain.anime.model.hasCustomCover import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadManager import tachiyomi.domain.anime.interactor.FetchInterval @@ -33,6 +35,9 @@ class UpdateAnime( remoteAnime: SAnime, manualFetch: Boolean, coverCache: CoverCache = Injekt.get(), + // AY --> + backgroundCache: BackgroundCache = Injekt.get(), + // <-- AY libraryPreferences: LibraryPreferences = Injekt.get(), downloadManager: DownloadManager = Injekt.get(), ): Boolean { @@ -66,18 +71,46 @@ class UpdateAnime( } } + // AY --> + val backgroundLastModified = + when { + // Never refresh backgrounds if the url is empty to avoid "losing" existing backgrounds + remoteAnime.background_url.isNullOrEmpty() -> null + !manualFetch && localAnime.backgroundUrl == remoteAnime.background_url -> null + localAnime.isLocal() -> Instant.now().toEpochMilli() + localAnime.hasCustomBackground(backgroundCache) -> { + backgroundCache.deleteFromCache(localAnime, false) + null + } + else -> { + backgroundCache.deleteFromCache(localAnime, false) + Instant.now().toEpochMilli() + } + } + // <-- AY + val thumbnailUrl = remoteAnime.thumbnail_url?.takeIf { it.isNotEmpty() } + // AY --> + val backgroundUrl = remoteAnime.background_url?.takeIf { it.isNotEmpty() } + // <-- AY + val success = animeRepository.update( AnimeUpdate( id = localAnime.id, title = title, coverLastModified = coverLastModified, + // AY --> + backgroundLastModified = backgroundLastModified, + // <-- AY author = remoteAnime.author, artist = remoteAnime.artist, description = remoteAnime.description, genre = remoteAnime.getGenres(), thumbnailUrl = thumbnailUrl, + // AY --> + backgroundUrl = backgroundUrl, + // <-- AY status = remoteAnime.status.toLong(), updateStrategy = remoteAnime.update_strategy, initialized = true, @@ -107,6 +140,12 @@ class UpdateAnime( return animeRepository.update(AnimeUpdate(id = animeId, coverLastModified = Instant.now().toEpochMilli())) } + // AY --> + suspend fun awaitUpdateBackgroundLastModified(animeId: Long): Boolean { + return animeRepository.update(AnimeUpdate(id = animeId, backgroundLastModified = Instant.now().toEpochMilli())) + } + // <-- AY + suspend fun awaitUpdateFavorite(animeId: Long, favorite: Boolean): Boolean { val dateAdded = when (favorite) { true -> Instant.now().toEpochMilli() 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..9b9378137e 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 @@ -2,6 +2,7 @@ package eu.kanade.domain.anime.model import eu.kanade.domain.base.BasePreferences import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import tachiyomi.core.common.preference.TriState import tachiyomi.domain.anime.model.Anime @@ -18,13 +19,35 @@ 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 || + seasonCompletedFilter != TriState.DISABLED || + seasonBookmarkedFilter != TriState.DISABLED || + seasonFillermarkedFilter != TriState.DISABLED +} +// <-- AY + fun Anime.episodesFiltered(): Boolean { return unseenFilter != TriState.DISABLED || downloadedFilter != TriState.DISABLED || bookmarkedFilter != TriState.DISABLED || - // AM (FILLERMARK) --> + // AY --> fillermarkedFilter != TriState.DISABLED - // <-- AM (FILLERMARK) + // <-- AY } fun Anime.toSAnime(): SAnime = SAnime.create().also { @@ -36,6 +59,11 @@ fun Anime.toSAnime(): SAnime = SAnime.create().also { it.genre = genre.orEmpty().joinToString() it.status = status.toInt() it.thumbnail_url = thumbnailUrl + // AY --> + it.background_url = backgroundUrl + it.fetch_type = fetchType + it.season_number = seasonNumber + // <-- AY it.initialized = initialized } @@ -51,6 +79,9 @@ fun Anime.copyFrom(other: SAnime): Anime { } // <-- AM (CUSTOM_INFORMATION) val thumbnailUrl = other.thumbnail_url ?: thumbnailUrl + // AY --> + val backgroundUrl = other.background_url ?: backgroundUrl + // <-- AY return this.copy( // AM (CUSTOM_INFORMATION) --> ogAuthor = author, @@ -62,7 +93,14 @@ fun Anime.copyFrom(other: SAnime): Anime { // AM (CUSTOM_INFORMATION) --> ogStatus = other.status.toLong(), // <-- AM (CUSTOM_INFORMATION) + // AY --> + backgroundUrl = backgroundUrl, + // <-- AY updateStrategy = other.update_strategy, + // AY --> + fetchType = other.fetch_type, + seasonNumber = other.season_number, + // <-- AY initialized = other.initialized && initialized, ) } @@ -70,3 +108,9 @@ fun Anime.copyFrom(other: SAnime): Anime { fun Anime.hasCustomCover(coverCache: CoverCache = Injekt.get()): Boolean { return coverCache.getCustomCoverFile(id).exists() } + +// AY --> +fun Anime.hasCustomBackground(backgroundCache: BackgroundCache = Injekt.get()): Boolean { + return backgroundCache.getCustomBackgroundFile(id).exists() +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt b/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt index fe01c2cc8e..f13a2a0b45 100644 --- a/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt +++ b/app/src/main/java/eu/kanade/domain/episode/interactor/SyncEpisodesWithSource.kt @@ -127,11 +127,26 @@ class SyncEpisodesWithSource( name = episode.name, episodeNumber = episode.episodeNumber, scanlator = episode.scanlator, + // AY --> + summary = episode.summary, + // <-- AY sourceOrder = episode.sourceOrder, ) if (episode.dateUpload != 0L) { toChangeEpisode = toChangeEpisode.copy(dateUpload = episode.dateUpload) } + // AY --> + if (!toChangeEpisode.fillermark) { + toChangeEpisode = toChangeEpisode.copy( + fillermark = sourceEpisode.fillermark, + ) + } + if (toChangeEpisode.previewUrl.isNullOrBlank()) { + toChangeEpisode = toChangeEpisode.copy( + previewUrl = sourceEpisode.previewUrl, + ) + } + // <-- AY updatedEpisodes.add(toChangeEpisode) } } diff --git a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt index 11908706c4..7b8d69eb0a 100644 --- a/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt +++ b/app/src/main/java/eu/kanade/domain/episode/model/Episode.kt @@ -12,7 +12,14 @@ fun Episode.toSEpisode(): SEpisode { it.name = name it.date_upload = dateUpload it.episode_number = episodeNumber.toFloat() + // AY --> + it.fillermark = fillermark + // <-- AY it.scanlator = scanlator + // AY --> + it.summary = summary + it.preview_url = previewUrl + // <-- AY } } @@ -22,7 +29,14 @@ fun Episode.copyFromSEpisode(sEpisode: SEpisode): Episode { url = sEpisode.url, dateUpload = sEpisode.date_upload, episodeNumber = sEpisode.episode_number.toDouble(), + // AY --> + fillermark = sEpisode.fillermark, + // <-- AY scanlator = sEpisode.scanlator?.ifBlank { null }?.trim(), + // AY --> + summary = sEpisode.summary?.ifBlank { null }, + previewUrl = sEpisode.preview_url?.ifBlank { null }, + // <-- AY ) } @@ -32,11 +46,15 @@ fun Episode.toDbEpisode(): DbEpisode = EpisodeImpl().also { it.url = url it.name = name it.scanlator = scanlator + // AY --> + it.summary = summary + it.preview_url = previewUrl + // <-- AY it.seen = seen it.bookmark = bookmark - // AM (FILLERMARK) --> + // AY --> it.fillermark = fillermark - // <-- AM (FILLERMARK) + // <-- AY it.last_second_seen = lastSecondSeen it.date_fetch = dateFetch it.date_upload = dateUpload diff --git a/app/src/main/java/eu/kanade/domain/episode/model/EpisodeFilter.kt b/app/src/main/java/eu/kanade/domain/episode/model/EpisodeFilter.kt index 9ecdd5e4e1..378fcdee55 100644 --- a/app/src/main/java/eu/kanade/domain/episode/model/EpisodeFilter.kt +++ b/app/src/main/java/eu/kanade/domain/episode/model/EpisodeFilter.kt @@ -18,15 +18,15 @@ fun List.applyFilters(anime: Anime, downloadManager: DownloadManager): val unseenFilter = anime.unseenFilter val downloadedFilter = anime.downloadedFilter val bookmarkedFilter = anime.bookmarkedFilter - // AM (FILLERMARK) --> + // AY --> val fillermarkedFilter = anime.fillermarkedFilter - // <-- AM (FILLERMARK) + // <-- AY return filter { episode -> applyFilter(unseenFilter) { !episode.seen } } .filter { episode -> applyFilter(bookmarkedFilter) { episode.bookmark } } - // AM (FILLERMARK) --> + // AY --> .filter { episode -> applyFilter(fillermarkedFilter) { episode.fillermark } } - // <-- AM (FILLERMARK) + // <-- AY .filter { episode -> applyFilter(downloadedFilter) { val downloaded = downloadManager.isEpisodeDownloaded( @@ -52,15 +52,15 @@ fun List.applyFilters(anime: Anime): Sequence + // AY --> val fillermarkedFilter = anime.fillermarkedFilter - // <-- AM (FILLERMARK) + // <-- AY return asSequence() .filter { (episode) -> applyFilter(unseenFilter) { !episode.seen } } .filter { (episode) -> applyFilter(bookmarkedFilter) { episode.bookmark } } - // AM (FILLERMARK) --> + // AY --> .filter { (episode) -> applyFilter(fillermarkedFilter) { episode.fillermark } } - // <-- AM (FILLERMARK) + // <-- AY .filter { applyFilter(downloadedFilter) { it.isDownloaded || isLocalAnime } } .sortedWith { (episode1), (episode2) -> getEpisodeSort(anime).invoke(episode1, episode2) } } 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..900e99f8df 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/AnimeScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets @@ -18,10 +19,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 +45,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.ExpandableAnimeDescription +import eu.kanade.presentation.anime.components.ItemHeader 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 +126,9 @@ fun AnimeScreen( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -144,9 +158,9 @@ fun AnimeScreen( // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked: (List, fillermarked: Boolean) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, @@ -158,6 +172,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 = { @@ -208,9 +228,9 @@ fun AnimeScreen( // <-- AM (CUSTOM_INFORMATION) onEditNotesClicked = onEditNotesClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked = onMultiFillermarkClicked, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMultiDeleteClicked = onMultiDeleteClicked, @@ -218,6 +238,10 @@ fun AnimeScreen( onEpisodeSelected = onEpisodeSelected, onAllEpisodeSelected = onAllEpisodeSelected, onInvertSelection = onInvertSelection, + // AY --> + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onContinueWatchingClicked, + // <-- AY ) } else { AnimeScreenLargeImpl( @@ -261,9 +285,9 @@ fun AnimeScreen( // <-- AM (CUSTOM_INFORMATION) onEditNotesClicked = onEditNotesClicked, onMultiBookmarkClicked = onMultiBookmarkClicked, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked = onMultiFillermarkClicked, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, onMultiDeleteClicked = onMultiDeleteClicked, @@ -271,6 +295,10 @@ fun AnimeScreen( onEpisodeSelected = onEpisodeSelected, onAllEpisodeSelected = onAllEpisodeSelected, onInvertSelection = onInvertSelection, + // AY --> + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onContinueWatchingClicked, + // <-- AY ) } } @@ -297,7 +325,9 @@ private fun AnimeScreenSmallImpl( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -328,9 +358,9 @@ private fun AnimeScreenSmallImpl( // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked: (List, fillermarked: Boolean) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, @@ -342,141 +372,164 @@ 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 toolbarHeight by remember { mutableIntStateOf(0) } + // <-- AY + BackHandler(enabled = isAnySelected) { onAllEpisodeSelected(false) } - Scaffold( - topBar = { - val selectedEpisodeCount: Int = remember(episodes) { - episodes.count { it.selected } - } - val isFirstItemVisible by remember { - derivedStateOf { episodeListState.firstVisibleItemIndex == 0 } - } - val isFirstItemScrolled by remember { - derivedStateOf { episodeListState.firstVisibleItemScrollOffset > 0 } - } - val titleAlpha by animateFloatAsState( - if (!isFirstItemVisible) 1f else 0f, - label = "Top Bar Title", - ) - val backgroundAlpha by animateFloatAsState( - if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f, - label = "Top Bar Background", - ) - AnimeToolbar( - title = state.anime.title, - hasFilters = state.filterActive, - navigateUp = navigateUp, - onClickFilter = onFilterClicked, - onClickShare = onShareClicked, - onClickDownload = onDownloadActionClicked, - onClickEditCategory = onEditCategoryClicked, - onClickRefresh = onRefresh, - onClickMigrate = onMigrateClicked, - // AY --> - onClickSettings = onSettingsClicked, - onClickSkipIntro = onSkipIntroClicked, - // <-- AY - // AM (CUSTOM_INFORMATION) --> - onClickEditInfo = onEditInfoClicked.takeIf { state.anime.favorite }, - // <-- AM (CUSTOM_INFORMATION) - onClickEditNotes = onEditNotesClicked, - actionModeCounter = selectedEpisodeCount, - onCancelActionMode = { onAllEpisodeSelected(false) }, - onSelectAll = { onAllEpisodeSelected(true) }, - onInvertSelection = { onInvertSelection() }, - titleAlphaProvider = { titleAlpha }, - backgroundAlphaProvider = { backgroundAlpha }, - ) - }, - bottomBar = { - val selectedEpisodes = remember(episodes) { - episodes.filter { it.selected } - } - SharedAnimeBottomActionMenu( - selected = selectedEpisodes, - // AY --> - onEpisodeClicked = onEpisodeClicked, - alwaysUseExternalPlayer = alwaysUseExternalPlayer, - // <-- AY - onMultiBookmarkClicked = onMultiBookmarkClicked, - // AM (FILLERMARK) --> - onMultiFillermarkClicked = onMultiFillermarkClicked, - // <-- AM (FILLERMARK) - onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, - onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, - onDownloadEpisode = onDownloadEpisode, - onMultiDeleteClicked = onMultiDeleteClicked, - fillFraction = 1f, - ) - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - floatingActionButton = { - val isFABVisible = remember(episodes) { - episodes.fastAny { !it.episode.seen } && !isAnySelected - } - AnimatedVisibility( - visible = isFABVisible, - enter = fadeIn(), - exit = fadeOut(), - ) { - ExtendedFloatingActionButton( - text = { - val isWatching = remember(state.episodes) { - state.episodes.fastAny { it.episode.seen } - } - Text( - text = stringResource( - if (isWatching) MR.strings.action_resume else MR.strings.action_start, - ), - ) - }, - icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, - onClick = onContinueWatching, - expanded = episodeListState.shouldExpandFAB(), + // AY --> + BoxWithConstraints { + val density = LocalDensity.current + val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.roundToPx() } + // <-- AY + Scaffold( + topBar = { + val selectedEpisodeCount: Int = remember(episodes) { + episodes.count { it.selected } + } + val isFirstItemVisible by remember { + derivedStateOf { itemListState.firstVisibleItemIndex == 0 } + } + val isFirstItemScrolled by remember { + derivedStateOf { itemListState.firstVisibleItemScrollOffset > 0 } + } + val titleAlpha by animateFloatAsState( + if (!isFirstItemVisible) 1f else 0f, + label = "Top Bar Title", ) - } - }, - ) { contentPadding -> - val topPadding = contentPadding.calculateTopPadding() + val backgroundAlpha by animateFloatAsState( + if (!isFirstItemVisible || isFirstItemScrolled) 1f else 0f, + label = "Top Bar Background", + ) + AnimeToolbar( + title = state.anime.title, + hasFilters = state.filterActive, + navigateUp = navigateUp, + onClickFilter = onFilterClicked, + onClickShare = onShareClicked, + onClickDownload = onDownloadActionClicked, + onClickEditCategory = onEditCategoryClicked, + onClickRefresh = onRefresh, + onClickMigrate = onMigrateClicked, + // AY --> + onClickSettings = onSettingsClicked, + onClickSkipIntro = onSkipIntroClicked, + // <-- AY + // AM (CUSTOM_INFORMATION) --> + onClickEditInfo = onEditInfoClicked.takeIf { state.anime.favorite }, + // <-- AM (CUSTOM_INFORMATION) + onClickEditNotes = onEditNotesClicked, + actionModeCounter = selectedEpisodeCount, + onCancelActionMode = { onAllEpisodeSelected(false) }, + onSelectAll = { onAllEpisodeSelected(true) }, + onInvertSelection = { onInvertSelection() }, + titleAlphaProvider = { titleAlpha }, + backgroundAlphaProvider = { backgroundAlpha }, + // AY --> + modifier = Modifier.onSizeChanged { toolbarHeight = it.height }, + // <-- AY + ) + }, + bottomBar = { + val selectedEpisodes = remember(episodes) { + episodes.filter { it.selected } + } + SharedAnimeBottomActionMenu( + selected = selectedEpisodes, + // AY --> + onEpisodeClicked = onEpisodeClicked, + alwaysUseExternalPlayer = alwaysUseExternalPlayer, + // <-- AY + onMultiBookmarkClicked = onMultiBookmarkClicked, + // AY --> + onMultiFillermarkClicked = onMultiFillermarkClicked, + // <-- AY + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onDownloadEpisode = onDownloadEpisode, + onMultiDeleteClicked = onMultiDeleteClicked, + fillFraction = 1f, + ) + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + val isFABVisible = remember(episodes) { + episodes.fastAny { !it.episode.seen } && !isAnySelected + } + AnimatedVisibility( + visible = isFABVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExtendedFloatingActionButton( + text = { + val isWatching = remember(state.episodes) { + state.episodes.fastAny { it.episode.seen } + } + Text( + text = stringResource( + if (isWatching) MR.strings.action_resume else MR.strings.action_start, + ), + ) + }, + icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, + onClick = onContinueWatching, + expanded = itemListState.shouldExpandFAB(), + ) + } + }, + ) { contentPadding -> + val topPadding = contentPadding.calculateTopPadding() - PullRefresh( - refreshing = state.isRefreshingData, - onRefresh = onRefresh, - enabled = !isAnySelected, - indicatorPadding = PaddingValues(top = topPadding), - ) { - val layoutDirection = LocalLayoutDirection.current - VerticalFastScroller( - listState = episodeListState, - topContentPadding = topPadding, - endContentPadding = contentPadding.calculateEndPadding(layoutDirection), + PullRefresh( + refreshing = state.isRefreshingData, + onRefresh = onRefresh, + enabled = !isAnySelected, + indicatorPadding = PaddingValues(top = topPadding), ) { - LazyColumn( + val layoutDirection = LocalLayoutDirection.current + // AY --> + FastScrollLazyVerticalGrid( modifier = Modifier.fillMaxHeight(), - state = episodeListState, + state = itemListState, + columns = if (gridSize == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(gridSize), contentPadding = PaddingValues( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), + start = GRID_PADDING + contentPadding.calculateStartPadding(layoutDirection), + end = GRID_PADDING + contentPadding.calculateEndPadding(layoutDirection), bottom = contentPadding.calculateBottomPadding(), ), ) { + // <-- AY item( key = AnimeScreenItem.INFO_BOX, contentType = AnimeScreenItem.INFO_BOX, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { AnimeInfoBox( isTabletUi = false, @@ -486,12 +539,18 @@ private fun AnimeScreenSmallImpl( 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, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { AnimeActionRow( favorite = state.anime.favorite, @@ -504,12 +563,18 @@ private fun AnimeScreenSmallImpl( onTrackingClicked = onTrackingClicked, onEditIntervalClicked = onEditIntervalClicked, onEditCategory = onEditCategoryClicked, + // AY --> + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + // <-- AY ) } item( key = AnimeScreenItem.DESCRIPTION_WITH_TAG, contentType = AnimeScreenItem.DESCRIPTION_WITH_TAG, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { ExpandableAnimeDescription( defaultExpandState = state.isFromSource, @@ -519,69 +584,111 @@ private fun AnimeScreenSmallImpl( 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 }.missingEpisodesCount() + episodes.map { it.episode.episodeNumber }.missingEntriesCount() + } + // AY --> + val missingSeasonsCount = remember(seasons) { + seasons.map { it.seasonAnime.anime.seasonNumber }.missingEntriesCount() } - EpisodeHeader( + ItemHeader( enabled = !isAnySelected, - episodeCount = episodes.size, - missingEpisodeCount = missingEpisodeCount, + 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 } // 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 + when (state.anime.fetchType) { + FetchType.Seasons -> { + sharedSeasons( + anime = state.anime, + seasons = seasons, + containerHeight = containerHeightPx - toolbarHeight, + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onClickContinueWatching, + listItemModifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } + // <-- AY + FetchType.Episodes -> { + // AY --> + if (state.airingTime > 0L) { + item( + key = AnimeScreenItem.AIRING_TIME, + contentType = AnimeScreenItem.AIRING_TIME, + span = { GridItemSpan(maxLineSpan) }, + ) { + // 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), + ) + } } } - 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), - ) - } + // <-- AY + + sharedEpisodeItems( + anime = state.anime, + // AM (FILE_SIZE) --> + source = state.source, + showFileSize = showFileSize, + // <-- AM (FILE_SIZE) + episodes = listItem, + isAnyEpisodeSelected = episodes.fastAny { it.selected }, + // AY --> + showSummaries = state.showSummaries, + showPreviews = state.showPreviews, + // <-- AY + episodeSwipeStartAction = episodeSwipeStartAction, + episodeSwipeEndAction = episodeSwipeEndAction, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, + onEpisodeSwipe = onEpisodeSwipe, + itemModifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) } } - // <-- 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, - ) } } } @@ -610,7 +717,9 @@ fun AnimeScreenLargeImpl( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY // For tags menu onTagSearch: (String) -> Unit, @@ -641,9 +750,9 @@ fun AnimeScreenLargeImpl( // For bottom action menu onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked: (List, fillermarked: Boolean) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onMultiDeleteClicked: (List) -> Unit, @@ -655,243 +764,300 @@ 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() + val itemListState = rememberLazyGridState() + // <-- AY BackHandler(enabled = isAnySelected) { onAllEpisodeSelected(false) } - Scaffold( - topBar = { - val selectedEpisodeCount = remember(episodes) { - episodes.count { it.selected } - } - AnimeToolbar( - modifier = Modifier.onSizeChanged { topBarHeight = it.height }, - title = state.anime.title, - hasFilters = state.filterActive, - navigateUp = navigateUp, - onClickFilter = onFilterButtonClicked, - onClickShare = onShareClicked, - onClickDownload = onDownloadActionClicked, - onClickEditCategory = onEditCategoryClicked, - onClickRefresh = onRefresh, - onClickMigrate = onMigrateClicked, - // AY --> - onClickSettings = onSettingsClicked, - onClickSkipIntro = onSkipIntroClicked, - // <-- AY - // AM (CUSTOM_INFORMATION) --> - onClickEditInfo = onEditInfoClicked.takeIf { state.anime.favorite }, - // <-- AM (CUSTOM_INFORMATION) - onClickEditNotes = onEditNotesClicked, - onCancelActionMode = { onAllEpisodeSelected(false) }, - actionModeCounter = selectedEpisodeCount, - onSelectAll = { onAllEpisodeSelected(true) }, - onInvertSelection = { onInvertSelection() }, - titleAlphaProvider = { 1f }, - backgroundAlphaProvider = { 1f }, - ) - }, - bottomBar = { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.BottomEnd, - ) { - val selectedEpisodes = remember(episodes) { - episodes.filter { it.selected } + // AY --> + BoxWithConstraints { + val density = LocalDensity.current + val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.roundToPx() } + // <-- AY + Scaffold( + topBar = { + val selectedEpisodeCount = remember(episodes) { + episodes.count { it.selected } } - SharedAnimeBottomActionMenu( - selected = selectedEpisodes, + AnimeToolbar( + modifier = Modifier.onSizeChanged { topBarHeight = it.height }, + title = state.anime.title, + hasFilters = state.filterActive, + navigateUp = navigateUp, + onClickFilter = onFilterButtonClicked, + onClickShare = onShareClicked, + onClickDownload = onDownloadActionClicked, + onClickEditCategory = onEditCategoryClicked, + onClickRefresh = onRefresh, + onClickMigrate = onMigrateClicked, // AY --> - onEpisodeClicked = onEpisodeClicked, - alwaysUseExternalPlayer = alwaysUseExternalPlayer, + onClickSettings = onSettingsClicked, + onClickSkipIntro = onSkipIntroClicked, // <-- AY - onMultiBookmarkClicked = onMultiBookmarkClicked, - // AM (FILLERMARK) --> - onMultiFillermarkClicked = onMultiFillermarkClicked, - // <-- AM (FILLERMARK) - onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, - onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, - onDownloadEpisode = onDownloadEpisode, - onMultiDeleteClicked = onMultiDeleteClicked, - fillFraction = 0.5f, + // AM (CUSTOM_INFORMATION) --> + onClickEditInfo = onEditInfoClicked.takeIf { state.anime.favorite }, + // <-- AM (CUSTOM_INFORMATION) + onClickEditNotes = onEditNotesClicked, + onCancelActionMode = { onAllEpisodeSelected(false) }, + actionModeCounter = selectedEpisodeCount, + onSelectAll = { onAllEpisodeSelected(true) }, + onInvertSelection = { onInvertSelection() }, + titleAlphaProvider = { 1f }, + backgroundAlphaProvider = { 1f }, ) - } - }, - snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, - floatingActionButton = { - val isFABVisible = remember(episodes) { - episodes.fastAny { !it.episode.seen } && !isAnySelected - } - AnimatedVisibility( - visible = isFABVisible, - enter = fadeIn(), - exit = fadeOut(), + }, + bottomBar = { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.BottomEnd, + ) { + val selectedEpisodes = remember(episodes) { + episodes.filter { it.selected } + } + SharedAnimeBottomActionMenu( + selected = selectedEpisodes, + // AY --> + onEpisodeClicked = onEpisodeClicked, + alwaysUseExternalPlayer = alwaysUseExternalPlayer, + // <-- AY + onMultiBookmarkClicked = onMultiBookmarkClicked, + // AY --> + onMultiFillermarkClicked = onMultiFillermarkClicked, + // <-- AY + onMultiMarkAsSeenClicked = onMultiMarkAsSeenClicked, + onMarkPreviousAsSeenClicked = onMarkPreviousAsSeenClicked, + onDownloadEpisode = onDownloadEpisode, + onMultiDeleteClicked = onMultiDeleteClicked, + fillFraction = 0.5f, + ) + } + }, + snackbarHost = { SnackbarHost(hostState = snackbarHostState) }, + floatingActionButton = { + val isFABVisible = remember(episodes) { + episodes.fastAny { !it.episode.seen } && !isAnySelected + } + AnimatedVisibility( + visible = isFABVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + ExtendedFloatingActionButton( + text = { + val isWatching = remember(state.episodes) { + state.episodes.fastAny { it.episode.seen } + } + Text( + text = stringResource( + if (isWatching) MR.strings.action_resume else MR.strings.action_start, + ), + ) + }, + icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, + onClick = onContinueWatching, + expanded = itemListState.shouldExpandFAB(), + ) + } + }, + ) { contentPadding -> + PullRefresh( + refreshing = state.isRefreshingData, + onRefresh = onRefresh, + enabled = !isAnySelected, + indicatorPadding = PaddingValues( + start = insetPadding.calculateStartPadding(layoutDirection), + top = with(density) { topBarHeight.toDp() }, + end = insetPadding.calculateEndPadding(layoutDirection), + ), ) { - ExtendedFloatingActionButton( - text = { - val isWatching = remember(state.episodes) { - state.episodes.fastAny { it.episode.seen } + TwoPanelBox( + modifier = Modifier.padding( + start = contentPadding.calculateStartPadding(layoutDirection), + end = contentPadding.calculateEndPadding(layoutDirection), + ), + startContent = { + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = contentPadding.calculateBottomPadding()), + ) { + AnimeInfoBox( + isTabletUi = true, + appBarPadding = contentPadding.calculateTopPadding(), + anime = state.anime, + sourceName = remember { state.source.getNameForAnimeInfo() }, + isStubSource = remember { state.source is StubSource }, + onCoverClick = onCoverClicked, + doSearch = onSearch, + ) + 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, + ) + ExpandableAnimeDescription( + defaultExpandState = true, + description = state.anime.description, + tagsProvider = { state.anime.genre }, + notes = state.anime.notes, + onTagSearch = onTagSearch, + onCopyTagToClipboard = onCopyTagToClipboard, + onEditNotes = onEditNotesClicked, + ) } - Text( - text = stringResource( - if (isWatching) MR.strings.action_resume else MR.strings.action_start, - ), - ) }, - icon = { Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) }, - onClick = onContinueWatching, - expanded = episodeListState.shouldExpandFAB(), - ) - } - }, - ) { contentPadding -> - PullRefresh( - refreshing = state.isRefreshingData, - onRefresh = onRefresh, - enabled = !isAnySelected, - indicatorPadding = PaddingValues( - start = insetPadding.calculateStartPadding(layoutDirection), - top = with(density) { topBarHeight.toDp() }, - end = insetPadding.calculateEndPadding(layoutDirection), - ), - ) { - TwoPanelBox( - modifier = Modifier.padding( - start = contentPadding.calculateStartPadding(layoutDirection), - end = contentPadding.calculateEndPadding(layoutDirection), - ), - startContent = { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(bottom = contentPadding.calculateBottomPadding()), - ) { - AnimeInfoBox( - isTabletUi = true, - appBarPadding = contentPadding.calculateTopPadding(), - anime = state.anime, - sourceName = remember { state.source.getNameForAnimeInfo() }, - isStubSource = remember { state.source is StubSource }, - onCoverClick = onCoverClicked, - doSearch = onSearch, - ) - 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, - ) - ExpandableAnimeDescription( - defaultExpandState = true, - description = state.anime.description, - tagsProvider = { state.anime.genre }, - notes = state.anime.notes, - onTagSearch = onTagSearch, - onCopyTagToClipboard = onCopyTagToClipboard, - onEditNotes = onEditNotesClicked, - ) - } - }, - endContent = { - VerticalFastScroller( - listState = episodeListState, - topContentPadding = contentPadding.calculateTopPadding(), - ) { - LazyColumn( + endContent = { + // AY --> + FastScrollLazyVerticalGrid( modifier = Modifier.fillMaxHeight(), - state = episodeListState, + 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(), ), ) { + // <-- AY item( key = AnimeScreenItem.EPISODE_HEADER, contentType = AnimeScreenItem.EPISODE_HEADER, + // AY --> + span = { GridItemSpan(maxLineSpan) }, + // <-- AY ) { val missingEpisodeCount = remember(episodes) { - episodes.map { it.episode.episodeNumber }.missingEpisodesCount() + episodes.map { it.episode.episodeNumber }.missingEntriesCount() + } + // AY --> + val missingSeasonsCount = remember(seasons) { + seasons.map { it.seasonAnime.anime.seasonNumber }.missingEntriesCount() } - EpisodeHeader( + ItemHeader( enabled = !isAnySelected, - episodeCount = episodes.size, - missingEpisodeCount = missingEpisodeCount, + 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), ) + // <-- AY } // 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 + when (state.anime.fetchType) { + FetchType.Seasons -> { + sharedSeasons( + anime = state.anime, + seasons = seasons, + containerHeight = containerHeightPx - topBarHeight, + onSeasonClicked = onSeasonClicked, + onClickContinueWatching = onClickContinueWatching, + listItemModifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } + // <-- AY + FetchType.Episodes -> { + // 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 + ) { + NextEpisodeAiringListItem( + title = stringResource( + AYMR.strings.display_mode_episode, + formatEpisodeNumber(state.airingEpisodeNumber), + ), + date = formatTime(state.airingTime, useDayFormat = true), + modifier = Modifier.ignorePadding(offsetGridPaddingPx), + ) + } } } - 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), - ) - } + // <-- AY + + sharedEpisodeItems( + anime = state.anime, + // AM (FILE_SIZE) --> + source = state.source, + showFileSize = showFileSize, + // <-- AM (FILE_SIZE) + episodes = listItem, + isAnyEpisodeSelected = episodes.fastAny { it.selected }, + // AY --> + showSummaries = state.showSummaries, + showPreviews = state.showPreviews, + // <-- AY + episodeSwipeStartAction = episodeSwipeStartAction, + episodeSwipeEndAction = episodeSwipeEndAction, + onEpisodeClicked = onEpisodeClicked, + onDownloadEpisode = onDownloadEpisode, + onEpisodeSelected = onEpisodeSelected, + onEpisodeSwipe = onEpisodeSwipe, + // AY --> + itemModifier = 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, - ) } - } - }, - ) + }, + ) + } } } } @@ -904,9 +1070,9 @@ private fun SharedAnimeBottomActionMenu( alwaysUseExternalPlayer: Boolean, // <-- AY onMultiBookmarkClicked: (List, bookmarked: Boolean) -> Unit, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked: (List, fillermarked: Boolean) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked: (List, markAsSeen: Boolean) -> Unit, onMarkPreviousAsSeenClicked: (Episode) -> Unit, onDownloadEpisode: ((List, EpisodeDownloadAction) -> Unit)?, @@ -923,14 +1089,14 @@ private fun SharedAnimeBottomActionMenu( onRemoveBookmarkClicked = { onMultiBookmarkClicked.invoke(selected.fastMap { it.episode }, false) }.takeIf { selected.fastAll { it.episode.bookmark } }, - // AM (FILLERMARK) --> + // AY --> onFillermarkClicked = { onMultiFillermarkClicked.invoke(selected.fastMap { it.episode }, true) }.takeIf { selected.fastAny { !it.episode.fillermark } }, onRemoveFillermarkClicked = { onMultiFillermarkClicked.invoke(selected.fastMap { it.episode }, false) }.takeIf { selected.fastAll { it.episode.fillermark } }, - // <-- AM (FILLERMARK) + // <-- AY onMarkAsSeenClicked = { onMultiMarkAsSeenClicked(selected.fastMap { it.episode }, true) }.takeIf { selected.fastAny { !it.episode.seen } }, @@ -961,7 +1127,33 @@ private fun SharedAnimeBottomActionMenu( ) } -private fun LazyListScope.sharedEpisodeItems( +// AY --> +private fun LazyGridScope.sharedSeasons( + anime: Anime, + seasons: List, + containerHeight: Int, + onSeasonClicked: (SeasonAnime) -> Unit, + onClickContinueWatching: ((SeasonAnime) -> Unit)?, + listItemModifier: Modifier = Modifier, +) { + 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, + listItemModifier = listItemModifier, + ) + } +} + +private fun LazyGridScope.sharedEpisodeItems( + // <-- AY anime: Anime, // AM (FILE_SIZE) --> source: AnimeSource, @@ -969,6 +1161,10 @@ private fun LazyListScope.sharedEpisodeItems( // <-- AM (FILE_SIZE) episodes: List, isAnyEpisodeSelected: Boolean, + // AY --> + showSummaries: Boolean, + showPreviews: Boolean, + // <-- AY episodeSwipeStartAction: LibraryPreferences.EpisodeSwipeAction, episodeSwipeEndAction: LibraryPreferences.EpisodeSwipeAction, // AY --> @@ -977,6 +1173,9 @@ private fun LazyListScope.sharedEpisodeItems( onDownloadEpisode: ((List, EpisodeDownloadAction) -> Unit)?, onEpisodeSelected: (EpisodeList.Item, Boolean, Boolean, Boolean) -> Unit, onEpisodeSwipe: (EpisodeList.Item, LibraryPreferences.EpisodeSwipeAction) -> Unit, + // AY --> + itemModifier: Modifier = Modifier, + // <-- AY ) { items( items = episodes, @@ -987,12 +1186,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 = itemModifier, + // <-- AY + ) } is EpisodeList.Item -> { // AM (FILE_SIZE) --> @@ -1037,12 +1244,19 @@ private fun LazyListScope.sharedEpisodeItems( // <-- AY }, scanlator = item.episode.scanlator.takeIf { !it.isNullOrBlank() }, + // AY --> + summary = item.episode.summary.takeIf { !it.isNullOrBlank() && showSummaries }, + previewUrl = item.episode.previewUrl.takeIf { !it.isNullOrBlank() && showPreviews }, + // <-- AY seen = item.episode.seen, bookmark = item.episode.bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = item.episode.fillermark, - // <-- AM (FILLERMARK) + // <-- AY selected = item.selected, + // AY --> + isAnyEpisodeSelected = isAnyEpisodeSelected, + // <-- AY downloadIndicatorEnabled = !isAnyEpisodeSelected && !anime.isLocal(), downloadStateProvider = { item.downloadState }, downloadProgressProvider = { item.downloadProgress }, @@ -1071,6 +1285,9 @@ private fun LazyListScope.sharedEpisodeItems( // AM (FILE_SIZE) --> fileSize = fileSizeAsync, // <-- AM (FILE_SIZE) + // AY --> + modifier = itemModifier, + // <-- AY ) } } @@ -1093,6 +1310,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 +1347,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..0813ba4691 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/EpisodeSettingsDialog.kt @@ -37,6 +37,7 @@ import tachiyomi.domain.anime.model.Anime import tachiyomi.i18n.MR import tachiyomi.i18n.animiru.AMMR import tachiyomi.i18n.aniyomi.AYMR +import tachiyomi.presentation.core.components.CheckboxItem import tachiyomi.presentation.core.components.LabeledCheckbox import tachiyomi.presentation.core.components.RadioItem import tachiyomi.presentation.core.components.SortItem @@ -53,13 +54,17 @@ fun EpisodeSettingsDialog( onDownloadFilterChanged: (TriState) -> Unit, onUnseenFilterChanged: (TriState) -> Unit, onBookmarkedFilterChanged: (TriState) -> Unit, - // AM (FILLERMARK) --> + // AY --> onFillermarkedFilterChanged: (TriState) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY scanlatorFilterActive: Boolean, onScanlatorFilterClicked: (() -> Unit), onSortModeChanged: (Long) -> Unit, onDisplayModeChanged: (Long) -> Unit, + // AY --> + onShowPreviewsEnabled: (Long) -> Unit, + onShowSummariesEnabled: (Long) -> Unit, + // <-- AY onSetAsDefault: (applyToExistingAnime: Boolean) -> Unit, onResetToDefault: () -> Unit, ) { @@ -112,10 +117,10 @@ fun EpisodeSettingsDialog( onUnseenFilterChanged = onUnseenFilterChanged, bookmarkedFilter = anime?.bookmarkedFilter ?: TriState.DISABLED, onBookmarkedFilterChanged = onBookmarkedFilterChanged, - // AM (FILLERMARK) --> + // AY --> fillermarkedFilter = anime?.fillermarkedFilter ?: TriState.DISABLED, onFillermarkedFilterChanged = onFillermarkedFilterChanged, - // <-- AM (FILLERMARK) + // <-- AY scanlatorFilterActive = scanlatorFilterActive, onScanlatorFilterClicked = onScanlatorFilterClicked, ) @@ -130,7 +135,13 @@ fun EpisodeSettingsDialog( 2 -> { DisplayPage( displayMode = anime?.displayMode ?: 0, - onItemSelected = onDisplayModeChanged, + // AY --> + onDisplayModeChanged = onDisplayModeChanged, + showPreviews = anime?.showPreviews() ?: true, + onShowPreviewsEnabled = onShowPreviewsEnabled, + showSummaries = anime?.showSummaries() ?: true, + onShowSummariesEnabled = onShowSummariesEnabled, + // <-- AY ) } } @@ -146,10 +157,10 @@ private fun ColumnScope.FilterPage( onUnseenFilterChanged: (TriState) -> Unit, bookmarkedFilter: TriState, onBookmarkedFilterChanged: (TriState) -> Unit, - // AM (FILLERMARK) --> + // AY --> fillermarkedFilter: TriState, onFillermarkedFilterChanged: (TriState) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY scanlatorFilterActive: Boolean, onScanlatorFilterClicked: (() -> Unit), ) { @@ -168,13 +179,13 @@ private fun ColumnScope.FilterPage( state = bookmarkedFilter, onClick = onBookmarkedFilterChanged, ) - // AM (FILLERMARK) --> + // AY --> TriStateItem( - label = stringResource(AMMR.strings.action_filter_fillermarked), + label = stringResource(AYMR.strings.action_filter_fillermarked), state = fillermarkedFilter, onClick = onFillermarkedFilterChanged, ) - // <-- AM (FILLERMARK) + // <-- AY ScanlatorFilterItem( active = scanlatorFilterActive, onClick = onScanlatorFilterClicked, @@ -233,7 +244,13 @@ private fun ColumnScope.SortPage( @Composable private fun ColumnScope.DisplayPage( displayMode: Long, - onItemSelected: (Long) -> Unit, + // AY --> + onDisplayModeChanged: (Long) -> Unit, + showPreviews: Boolean, + onShowPreviewsEnabled: (Long) -> Unit, + showSummaries: Boolean, + onShowSummariesEnabled: (Long) -> Unit, + // <-- AY ) { listOf( MR.strings.show_title to Anime.EPISODE_DISPLAY_NAME, @@ -242,13 +259,30 @@ private fun ColumnScope.DisplayPage( RadioItem( label = stringResource(titleRes), selected = displayMode == mode, - onClick = { onItemSelected(mode) }, + onClick = { onDisplayModeChanged(mode) }, ) } + // AY --> + val showPreviewsFlag = if (showPreviews) Anime.EPISODE_SHOW_NOT_PREVIEWS else Anime.EPISODE_SHOW_PREVIEWS + CheckboxItem( + label = stringResource(AYMR.strings.show_episode_previews), + checked = showPreviews, + onClick = { onShowPreviewsEnabled(showPreviewsFlag) }, + ) + val showSummariesFlag = if (showSummaries) Anime.EPISODE_SHOW_NOT_SUMMARIES else Anime.EPISODE_SHOW_SUMMARIES + CheckboxItem( + label = stringResource(AYMR.strings.show_episode_summaries), + checked = showSummaries, + onClick = { onShowSummariesEnabled(showSummariesFlag) }, + ) + // <-- AY } @Composable -private fun SetAsDefaultDialog( +internal fun SetAsDefaultDialog( + // AY --> + isEpisode: Boolean = true, + // <-- AY onDismissRequest: () -> Unit, onConfirmed: (optionalChecked: Boolean) -> Unit, ) { @@ -256,7 +290,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..c66578415e --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/SeasonSettingsDialog.kt @@ -0,0 +1,342 @@ +// 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, + onCompletedFilterChanged: (TriState) -> Unit, + onBookmarkedFilterChanged: (TriState) -> Unit, + onFillermarkedFilterChanged: (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, + completedFilter = anime?.seasonCompletedFilter ?: TriState.DISABLED, + onCompletedFilterChanged = onCompletedFilterChanged, + bookmarkedFilter = anime?.seasonBookmarkedFilter ?: TriState.DISABLED, + onBookmarkedFilterChanged = onBookmarkedFilterChanged, + fillermarkedFilter = anime?.seasonFillermarkedFilter ?: TriState.DISABLED, + onFillermarkedFilterChanged = onFillermarkedFilterChanged, + ) + } + 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, + completedFilter: TriState, + onCompletedFilterChanged: (TriState) -> Unit, + bookmarkedFilter: TriState, + onBookmarkedFilterChanged: (TriState) -> Unit, + fillermarkedFilter: TriState, + onFillermarkedFilterChanged: (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.completed), + state = completedFilter, + onClick = onCompletedFilterChanged, + ) + TriStateItem( + label = stringResource(MR.strings.action_filter_bookmarked), + state = bookmarkedFilter, + onClick = onBookmarkedFilterChanged, + ) + TriStateItem( + label = stringResource(AYMR.strings.action_filter_fillermarked), + state = fillermarkedFilter, + onClick = onFillermarkedFilterChanged, + ) +} + +@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/AnimeBottomActionMenu.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt index 18484d4259..6f25d6f978 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeBottomActionMenu.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.shape.ZeroCornerSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.Input import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.automirrored.outlined.LabelOff import androidx.compose.material.icons.automirrored.outlined.OpenInNew import androidx.compose.material.icons.outlined.BookmarkAdd import androidx.compose.material.icons.outlined.BookmarkRemove @@ -32,6 +33,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DoneAll import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material.icons.outlined.SwapCalls import androidx.compose.material3.DropdownMenuItem @@ -79,10 +81,10 @@ fun AnimeBottomActionMenu( modifier: Modifier = Modifier, onBookmarkClicked: (() -> Unit)? = null, onRemoveBookmarkClicked: (() -> Unit)? = null, - // AM (FILLERMARK) --> + // AY --> onFillermarkClicked: (() -> Unit)? = null, onRemoveFillermarkClicked: (() -> Unit)? = null, - // <-- AM (FILLERMARK) + // <-- AY onMarkAsSeenClicked: (() -> Unit)? = null, onMarkAsUnseenClicked: (() -> Unit)? = null, onMarkPreviousAsSeenClicked: (() -> Unit)? = null, @@ -108,12 +110,12 @@ fun AnimeBottomActionMenu( color = MaterialTheme.colorScheme.surfaceContainerHigh, ) { val haptic = LocalHapticFeedback.current - // AM (FILLERMARK) --> + // AY --> val confirm = remember { mutableStateListOf(false, false, false, false, false, false, false, false, false, false, false) } val confirmRange = 0..<11 - // <-- AM (FILLERMARK) + // <-- AY var resetJob: Job? = remember { null } val onLongClickItem: (Int) -> Unit = { toConfirmIndex -> haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -151,11 +153,11 @@ fun AnimeBottomActionMenu( onClick = onRemoveBookmarkClicked, ) } - // AM (FILLERMARK) --> + // AY --> if (onFillermarkClicked != null) { Button( - title = stringResource(AMMR.strings.action_fillermark_episode), - icon = ImageVector.vectorResource(id = R.drawable.ic_fillermark_24dp), + title = stringResource(AYMR.strings.action_fillermark_episode), + icon = Icons.Outlined.NewLabel, toConfirm = confirm[2], onLongClick = { onLongClickItem(2) }, onClick = onFillermarkClicked, @@ -163,14 +165,14 @@ fun AnimeBottomActionMenu( } if (onRemoveFillermarkClicked != null) { Button( - title = stringResource(AMMR.strings.action_remove_fillermark_episode), - icon = ImageVector.vectorResource(id = R.drawable.ic_fillermark_border_24dp), + title = stringResource(AYMR.strings.action_remove_fillermark_episode), + icon = Icons.AutoMirrored.Outlined.LabelOff, toConfirm = confirm[3], onLongClick = { onLongClickItem(3) }, onClick = onRemoveFillermarkClicked, ) } - // <-- AM (FILLERMARK) + // <-- AY if (onMarkAsSeenClicked != null) { Button( title = stringResource(AMMR.strings.am_action_mark_as_seen), diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCover.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCover.kt index 809230e920..f9a17e6ee4 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCover.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCover.kt @@ -18,6 +18,10 @@ import eu.kanade.tachiyomi.R enum class AnimeCover(val ratio: Float) { Square(1f / 1f), Book(2f / 3f), + + // AY --> + Thumb(16f / 9f), + // <-- AY ; @Composable diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt index 85af4540ef..129b625816 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeEpisodeListItem.kt @@ -1,15 +1,20 @@ package eu.kanade.presentation.anime.components +import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.outlined.LabelOff import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Circle import androidx.compose.material.icons.outlined.BookmarkAdd @@ -18,6 +23,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.Done import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.FileDownloadOff +import androidx.compose.material.icons.outlined.NewLabel import androidx.compose.material.icons.outlined.RemoveDone import androidx.compose.material3.Icon import androidx.compose.material3.LocalContentColor @@ -28,6 +34,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -36,11 +43,17 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import coil3.request.ImageRequest +import coil3.request.crossfade import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.download.model.Download import me.saket.swipe.SwipeableActionsBox @@ -51,6 +64,7 @@ import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.DISABLED_ALPHA import tachiyomi.presentation.core.components.material.SECONDARY_ALPHA import tachiyomi.presentation.core.i18n.stringResource +import tachiyomi.presentation.core.util.secondaryItemAlpha import tachiyomi.presentation.core.util.selectedBackground @Composable @@ -59,12 +73,19 @@ fun AnimeEpisodeListItem( date: String?, watchProgress: String?, scanlator: String?, + // AY --> + summary: String?, + previewUrl: String?, + // <-- AY seen: Boolean, bookmark: Boolean, - // AM (FILLERMARK) --> + // AY --> fillermark: Boolean, - // <-- AM (FILLERMARK) + // <-- AY selected: Boolean, + // AY --> + isAnyEpisodeSelected: Boolean, + // <-- AY downloadIndicatorEnabled: Boolean, downloadStateProvider: () -> Download.State, downloadProgressProvider: () -> Int, @@ -83,9 +104,9 @@ fun AnimeEpisodeListItem( action = episodeSwipeStartAction, seen = seen, bookmark = bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = fillermark, - // <-- AM (FILLERMARK) + // <-- AY downloadState = downloadStateProvider(), background = MaterialTheme.colorScheme.primaryContainer, onSwipe = { onEpisodeSwipe(episodeSwipeStartAction) }, @@ -94,23 +115,26 @@ fun AnimeEpisodeListItem( action = episodeSwipeEndAction, seen = seen, bookmark = bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = fillermark, - // <-- AM (FILLERMARK) + // <-- AY downloadState = downloadStateProvider(), background = MaterialTheme.colorScheme.primaryContainer, onSwipe = { onEpisodeSwipe(episodeSwipeEndAction) }, ) SwipeableActionsBox( - modifier = Modifier.clipToBounds(), + modifier = modifier.clipToBounds(), startActions = listOfNotNull(start), endActions = listOfNotNull(end), swipeThreshold = swipeActionThreshold, backgroundUntilSwipeThreshold = MaterialTheme.colorScheme.surfaceContainerLowest, ) { Row( - modifier = modifier + // AY --> + verticalAlignment = Alignment.CenterVertically, + // <-- AY + modifier = Modifier .selectedBackground(selected) .combinedClickable( onClick = onClick, @@ -118,115 +142,166 @@ fun AnimeEpisodeListItem( ) .padding(start = 16.dp, top = 12.dp, end = 8.dp, bottom = 12.dp), ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { + // AY --> + if (previewUrl.isNullOrBlank() && summary.isNullOrBlank()) { + SimpleEpisodeListItemImpl( + title = title, + date = date, + watchProgress = watchProgress, + fillermark = fillermark, + scanlator = scanlator, + seen = seen, + bookmark = bookmark, + downloadIndicatorEnabled = downloadIndicatorEnabled, + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onDownloadClick = onDownloadClick, + // AM (FILE_SIZE) --> + fileSize = fileSize, + // <-- AM (FILE_SIZE) + ) + return@Row + } + + Column { Row( horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically, ) { - var textHeight by remember { mutableIntStateOf(0) } - if (!seen) { - Icon( - imageVector = Icons.Filled.Circle, - contentDescription = stringResource(AYMR.strings.unseen), - modifier = Modifier - .height(8.dp) - .padding(end = 4.dp), - tint = MaterialTheme.colorScheme.primary, - ) - } - if (bookmark) { - Icon( - imageVector = Icons.Filled.Bookmark, - contentDescription = stringResource(MR.strings.action_filter_bookmarked), - modifier = Modifier - .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), - tint = MaterialTheme.colorScheme.primary, - ) - } - // AM (FILLERMARK) --> - if (fillermark) { - Icon( - imageVector = ImageVector.vectorResource(id = R.drawable.ic_fillermark_24dp), - contentDescription = stringResource(AMMR.strings.action_filter_fillermarked), - modifier = Modifier - .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }), - tint = MaterialTheme.colorScheme.tertiary, - ) - Spacer(modifier = Modifier.width(2.dp)) - } - // <-- AM (FILLERMARK) - Text( - text = title, - style = MaterialTheme.typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - onTextLayout = { textHeight = it.size.height }, - color = LocalContentColor.current.copy(alpha = if (seen) DISABLED_ALPHA else 1f), - ) - } + EpisodeThumbnail(previewUrl = previewUrl) - Row { - val subtitleStyle = MaterialTheme.typography.bodySmall - .merge( - color = LocalContentColor.current - .copy(alpha = if (seen) DISABLED_ALPHA else SECONDARY_ALPHA), - ) - ProvideTextStyle(value = subtitleStyle) { - if (date != null) { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + val titleLines = if (previewUrl == null) 1 else 2 Text( - text = date, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - if (watchProgress != null || scanlator != null) DotSeparatorText() - } - if (watchProgress != null) { - Text( - text = watchProgress, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = LocalContentColor.current.copy(alpha = DISABLED_ALPHA), - ) - if (scanlator != null) DotSeparatorText() - } - if (scanlator != null) { - Text( - text = scanlator, - maxLines = 1, + text = title, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 14.sp), + modifier = Modifier.weight(1f), + maxLines = titleLines, + minLines = titleLines, overflow = TextOverflow.Ellipsis, + color = LocalContentColor.current.copy(alpha = if (seen) DISABLED_ALPHA else 1f), ) + + if (previewUrl == null) { + BookmarkDownloadIcons( + bookmark = bookmark, + downloadIndicatorEnabled = downloadIndicatorEnabled, + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onDownloadClick = onDownloadClick, + // AM (FILE_SIZE) --> + fileSize = fileSize, + // <-- AM (FILE_SIZE) + ) + } } + + EpisodeSummary( + seen = seen, + isAnyEpisodeSelected = isAnyEpisodeSelected, + summary = summary, + ) } } - } - EpisodeDownloadIndicator( - enabled = downloadIndicatorEnabled, - modifier = Modifier.padding(start = 4.dp), - downloadStateProvider = downloadStateProvider, - downloadProgressProvider = downloadProgressProvider, - onClick = { onDownloadClick?.invoke(it) }, - // AM (FILE_SIZE) --> - fileSize = fileSize, - // <-- AM (FILE_SIZE) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + EpisodeInformation( + seen = seen, + date = date, + watchProgress = watchProgress, + fillermark = fillermark, + scanlator = scanlator, + ) + + if (previewUrl != null) { + BookmarkDownloadIcons( + bookmark = bookmark, + downloadIndicatorEnabled = downloadIndicatorEnabled, + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onDownloadClick = onDownloadClick, + // AM (FILE_SIZE) --> + fileSize = fileSize, + // <-- AM (FILE_SIZE) + ) + } + } + } + // <-- AY } } } -// AM (FILLERMARK) --> +// AY --> +@Composable +private fun RowScope.SimpleEpisodeListItemImpl( + title: String, + date: String?, + watchProgress: String?, + fillermark: Boolean, + scanlator: String?, + seen: Boolean, + bookmark: Boolean, + downloadIndicatorEnabled: Boolean, + downloadStateProvider: () -> Download.State, + downloadProgressProvider: () -> Int, + onDownloadClick: ((EpisodeDownloadAction) -> Unit)?, + // AM (FILE_SIZE) --> + fileSize: Long?, + // <-- AM (FILE_SIZE) + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(if (fillermark) 0.dp else 6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalContentColor.current.copy(alpha = if (seen) DISABLED_ALPHA else 1f), + ) + + EpisodeInformation( + seen = seen, + date = date, + watchProgress = watchProgress, + fillermark = fillermark, + scanlator = scanlator, + ) + } + + BookmarkDownloadIcons( + bookmark = bookmark, + downloadIndicatorEnabled = downloadIndicatorEnabled, + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onDownloadClick = onDownloadClick, + // AM (FILE_SIZE) --> + fileSize = fileSize, + // <-- AM (FILE_SIZE) + ) +} +// <-- AY + @Composable -// <-- AM (FILLERMARK) private fun getSwipeAction( action: LibraryPreferences.EpisodeSwipeAction, seen: Boolean, bookmark: Boolean, - // AM (FILLERMARK) --> + // AY --> fillermark: Boolean, - // <-- AM (FILLERMARK) + // <-- AY downloadState: Download.State, background: Color, onSwipe: () -> Unit, @@ -244,21 +319,14 @@ private fun getSwipeAction( isUndo = bookmark, onSwipe = onSwipe, ) - // AM (FILLERMARK) --> - LibraryPreferences.EpisodeSwipeAction.ToggleFillermark -> { - val icon = if (!fillermark) { - ImageVector.vectorResource(id = R.drawable.ic_fillermark_24dp) - } else { - ImageVector.vectorResource(id = R.drawable.ic_fillermark_border_24dp) - } - swipeAction( - icon = icon, - background = background, - isUndo = bookmark, - onSwipe = onSwipe, - ) - } - // <-- AM (FILLERMARK) + // AY --> + LibraryPreferences.EpisodeSwipeAction.ToggleFillermark -> swipeAction( + icon = if (!fillermark) Icons.Outlined.NewLabel else Icons.AutoMirrored.Outlined.LabelOff, + background = background, + isUndo = fillermark, + onSwipe = onSwipe, + ) + // <-- AY LibraryPreferences.EpisodeSwipeAction.Download -> swipeAction( icon = when (downloadState) { Download.State.NOT_DOWNLOADED, Download.State.ERROR -> Icons.Outlined.Download @@ -284,13 +352,11 @@ fun NextEpisodeAiringListItem( ) { Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - var textHeight by remember { mutableIntStateOf(0) } Text( text = title, - style = MaterialTheme.typography.bodyMedium, + style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 14.sp), maxLines = 1, overflow = TextOverflow.Ellipsis, - onTextLayout = { textHeight = it.size.height }, modifier = Modifier.alpha(SECONDARY_ALPHA), color = MaterialTheme.colorScheme.primary, ) @@ -298,7 +364,7 @@ fun NextEpisodeAiringListItem( Spacer(modifier = Modifier.height(6.dp)) Row(modifier = Modifier.alpha(SECONDARY_ALPHA)) { ProvideTextStyle( - value = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp), + value = MaterialTheme.typography.bodySmall, ) { Text( text = date, @@ -335,3 +401,177 @@ private fun swipeAction( } private val swipeActionThreshold = 56.dp + +// AY --> +@Composable +private fun EpisodeThumbnail( + previewUrl: String?, +) { + val targetWidth = ((LocalConfiguration.current.screenWidthDp * 0.4f).coerceAtMost(250f)) + if (previewUrl != null) { + AnimeCover.Thumb( + modifier = Modifier + .width(targetWidth.dp) + .padding(end = 8.dp), + data = ImageRequest.Builder(LocalContext.current) + .data(previewUrl) + .crossfade(true) + .build(), + ) + } +} + +@Composable +private fun EpisodeSummary( + seen: Boolean, + isAnyEpisodeSelected: Boolean, + summary: String?, +) { + var expandSummary by remember { mutableStateOf(false) } + if (summary != null) { + Text( + text = summary, + style = MaterialTheme.typography.labelMedium, + maxLines = if (expandSummary) Int.MAX_VALUE else 3, + minLines = 3, + fontWeight = FontWeight.Normal, + fontSize = 10.sp, + lineHeight = 11.sp, + overflow = TextOverflow.Ellipsis, + color = LocalContentColor.current.copy( + alpha = if (seen) DISABLED_ALPHA else SECONDARY_ALPHA, + ), + modifier = Modifier.padding(bottom = 4.dp, start = 4.dp, end = 4.dp) + .then( + if (isAnyEpisodeSelected) { + Modifier + } else { + Modifier.clickable { expandSummary = !expandSummary } + }, + ), + ) + } +} + +@Composable +private fun EpisodeInformation( + seen: Boolean, + date: String?, + watchProgress: String?, + fillermark: Boolean, + scanlator: String?, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + val subtitleStyle = MaterialTheme.typography.bodySmall + .merge(color = LocalContentColor.current.copy(alpha = if (seen) DISABLED_ALPHA else SECONDARY_ALPHA)) + ProvideTextStyle(value = subtitleStyle) { + if (fillermark) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = stringResource(AYMR.strings.filler), + tint = MaterialTheme.colorScheme.tertiary.copy(alpha = subtitleStyle.alpha), + modifier = Modifier.padding(end = 4.dp), + ) + Text( + text = stringResource(AYMR.strings.filler), + maxLines = 1, + color = MaterialTheme.colorScheme.tertiary.copy(alpha = subtitleStyle.alpha), + modifier = Modifier.padding(end = 4.dp), + ) + } + if (date != null) { + Text( + text = date, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (watchProgress != null || scanlator != null) DotSeparatorText() + } + if (watchProgress != null) { + Text( + text = watchProgress, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalContentColor.current.copy(alpha = DISABLED_ALPHA), + ) + if (scanlator != null) DotSeparatorText() + } + if (scanlator != null) { + Text( + text = scanlator, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun BookmarkDownloadIcons( + bookmark: Boolean, + downloadIndicatorEnabled: Boolean, + downloadStateProvider: () -> Download.State, + downloadProgressProvider: () -> Int, + onDownloadClick: ((EpisodeDownloadAction) -> Unit)?, + // AM (FILE_SIZE) --> + fileSize: Long?, + // <-- AM (FILE_SIZE) +) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (bookmark) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = stringResource(MR.strings.action_filter_bookmarked), + modifier = Modifier + .secondaryItemAlpha() + .padding(start = 4.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + + EpisodeDownloadIndicator( + enabled = downloadIndicatorEnabled, + modifier = Modifier + .padding(start = 4.dp), + downloadStateProvider = downloadStateProvider, + downloadProgressProvider = downloadProgressProvider, + onClick = { onDownloadClick?.invoke(it) }, + // AM (FILE_SIZE) --> + fileSize = fileSize, + // <-- AM (FILE_SIZE) + ) + } +} + +@Preview +@Composable +fun AnimeEpisodeListItemPreview() { + AnimeEpisodeListItem( + title = "Ep. 1 - To You, 2000 Years in the Future: The Fall of Zhiganshina (1)", + date = "7/4/13", + watchProgress = null, + scanlator = null, + summary = "As Titans continue to rampage, the townspeople gather at the inner gate. But a new Titan breaks " + + "through and this one is unlike the others. Source: crunchyroll", + previewUrl = null, + seen = false, + bookmark = false, + fillermark = true, + selected = false, + isAnyEpisodeSelected = false, + downloadIndicatorEnabled = true, + downloadStateProvider = { Download.State.NOT_DOWNLOADED }, + downloadProgressProvider = { 0 }, + episodeSwipeStartAction = LibraryPreferences.EpisodeSwipeAction.Disabled, + episodeSwipeEndAction = LibraryPreferences.EpisodeSwipeAction.Disabled, + onLongClick = {}, + onClick = {}, + onDownloadClick = {}, + onEpisodeSwipe = {}, + // AM (FILE_SIZE) --> + fileSize = null, + // <-- AM (FILE_SIZE) + ) +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeImagesDialog.kt similarity index 65% rename from app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt rename to app/src/main/java/eu/kanade/presentation/anime/components/AnimeImagesDialog.kt index 945bd8df00..369838a295 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/AnimeCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeImagesDialog.kt @@ -10,7 +10,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.outlined.Close import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.Save @@ -26,6 +30,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,24 +52,56 @@ import eu.kanade.presentation.anime.EditCoverAction import eu.kanade.presentation.components.AppBar import eu.kanade.presentation.components.AppBarActions import eu.kanade.presentation.components.DropdownMenu +import eu.kanade.tachiyomi.data.coil.useBackground import eu.kanade.tachiyomi.util.ReaderPageImageView import kotlinx.collections.immutable.persistentListOf +import tachiyomi.core.common.util.lang.launchUI import tachiyomi.domain.anime.model.Anime import tachiyomi.i18n.MR +import tachiyomi.i18n.aniyomi.AYMR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication @Composable -fun AnimeCoverDialog( +fun AnimeImagesDialog( anime: Anime, isCustomCover: Boolean, + // AY --> + isCustomBackground: Boolean, + // <-- AY snackbarHostState: SnackbarHostState, + // AY --> + pagerState: PagerState, + // <-- AY onShareClick: () -> Unit, onSaveClick: () -> Unit, onEditClick: ((EditCoverAction) -> Unit)?, onDismissRequest: () -> Unit, ) { + // AY --> + val scope = rememberCoroutineScope() + val isCover = pagerState.currentPage != 1 + + val arrowIcon = if (isCover) { + Icons.AutoMirrored.Outlined.KeyboardArrowRight + } else { + Icons.AutoMirrored.Outlined.KeyboardArrowLeft + } + + val (editImageStringResource, alternateImageStringResource) = if (isCover) { + MR.strings.action_edit_cover to AYMR.strings.action_edit_background + } else { + AYMR.strings.action_edit_background to MR.strings.action_edit_cover + } + + val onImageSwitchClicked: () -> Unit = { + scope.launchUI { + pagerState.animateScrollToPage(1 - pagerState.currentPage) + } + } + // <-- AY + Dialog( onDismissRequest = onDismissRequest, properties = DialogProperties( @@ -89,6 +126,14 @@ fun AnimeCoverDialog( contentDescription = stringResource(MR.strings.action_close), ) } + // AY --> + IconButton(onClick = onImageSwitchClicked) { + Icon( + imageVector = arrowIcon, + contentDescription = stringResource(alternateImageStringResource), + ) + } + // <-- AY } Spacer(modifier = Modifier.weight(1f)) ActionsPill { @@ -111,7 +156,9 @@ fun AnimeCoverDialog( var expanded by remember { mutableStateOf(false) } IconButton( onClick = { - if (isCustomCover) { + // AY --> + if ((isCover && isCustomCover) || (!isCover && isCustomBackground)) { + // <-- AY expanded = true } else { onEditClick(EditCoverAction.EDIT) @@ -120,7 +167,7 @@ fun AnimeCoverDialog( ) { Icon( imageVector = Icons.Outlined.Edit, - contentDescription = stringResource(MR.strings.action_edit_cover), + contentDescription = stringResource(editImageStringResource), ) } DropdownMenu( @@ -157,37 +204,46 @@ fun AnimeCoverDialog( .fillMaxSize() .clickableNoIndication(onClick = onDismissRequest), ) { - AndroidView( - factory = { - ReaderPageImageView(it).apply { - onViewClicked = onDismissRequest - clipToPadding = false - clipChildren = false - } - }, - update = { view -> - val request = ImageRequest.Builder(view.context) - .data(anime) - .size(Size.ORIGINAL) - .memoryCachePolicy(CachePolicy.DISABLED) - .target { image -> - val drawable = image.asDrawable(view.context.resources) - // Copy bitmap in case it came from memory cache - // Because SSIV needs to thoroughly read the image - val copy = (drawable as? BitmapDrawable) - ?.bitmap - ?.copy(Bitmap.Config.HARDWARE, false) - ?.toDrawable(view.context.resources) - ?: drawable - view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500)) + // AY --> + HorizontalPager( + state = pagerState, + ) { page -> + // <-- AY + AndroidView( + factory = { + ReaderPageImageView(it).apply { + onViewClicked = onDismissRequest + clipToPadding = false + clipChildren = false } - .build() - view.context.imageLoader.enqueue(request) - - view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx) - }, - modifier = Modifier.fillMaxSize(), - ) + }, + update = { view -> + val context = view.context + val request = ImageRequest.Builder(context) + .data(anime) + // AY --> + .useBackground(page == 1) + // <-- AY + .size(Size.ORIGINAL) + .memoryCachePolicy(CachePolicy.DISABLED) + .target { image -> + val drawable = image.asDrawable(context.resources) + // Copy bitmap in case it came from memory cache + // Because SSIV needs to thoroughly read the image + val copy = (drawable as? BitmapDrawable) + ?.bitmap + ?.copy(Bitmap.Config.HARDWARE, false) + ?.toDrawable(view.context.resources) + ?: drawable + view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500)) + } + .build() + context.imageLoader.enqueue(request) + view.updatePadding(top = statusBarPaddingPx, bottom = bottomPaddingPx) + }, + modifier = Modifier.fillMaxSize(), + ) + } } } } 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..7ad4d27e6f 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 @@ -90,6 +90,7 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.presentation.components.DropdownMenu import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.data.coil.useBackground import eu.kanade.tachiyomi.util.system.copyToClipboard import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownTokenTypes @@ -129,6 +130,9 @@ fun AnimeInfoBox( AsyncImage( model = ImageRequest.Builder(LocalContext.current) .data(anime) + // AY --> + .useBackground(true) + // <-- AY .crossfade(true) .build(), contentDescription = null, @@ -179,7 +183,9 @@ fun AnimeActionRow( onAddToLibraryClicked: () -> Unit, onWebViewClicked: (() -> Unit)?, onWebViewLongClicked: (() -> Unit)?, - onTrackingClicked: () -> Unit, + // AY --> + onTrackingClicked: (() -> Unit)?, + // <-- AY onEditIntervalClicked: (() -> Unit)?, onEditCategory: (() -> Unit)?, modifier: Modifier = Modifier, @@ -222,16 +228,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..cba3ba657a --- /dev/null +++ b/app/src/main/java/eu/kanade/presentation/anime/components/AnimeSeasonListItem.kt @@ -0,0 +1,133 @@ +// AY --> +package eu.kanade.presentation.anime.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import aniyomi.domain.anime.SeasonAnime +import aniyomi.domain.anime.SeasonDisplayMode +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.DownloadsBadge +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)?, + listItemModifier: Modifier = Modifier, +) { + 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, + modifier = listItemModifier, + ) + } + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeDownloadIndicator.kt b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeDownloadIndicator.kt index 66e53366d8..013e7e8d47 100644 --- a/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeDownloadIndicator.kt +++ b/app/src/main/java/eu/kanade/presentation/anime/components/EpisodeDownloadIndicator.kt @@ -312,7 +312,7 @@ private fun Modifier.commonClickable( ), ) -private val IndicatorSize = 26.dp +internal val IndicatorSize = 26.dp private val IndicatorPadding = 2.dp // To match composable parameter name when used later 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 64% 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..7dcd2cb0c2 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,27 +41,39 @@ 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(fetchType, missingItemsCount) } } @Composable -private fun MissingEpisodesWarning(count: Int) { +private fun MissingEpisodesWarning(fetchType: FetchType, count: Int) { if (count == 0) { return } + // AM --> + val pluralRes = when (fetchType) { + FetchType.Seasons -> AMMR.plurals.missing_seasons + FetchType.Episodes -> AMMR.plurals.missing_episodes + } + // <-- AM Text( - text = pluralStringResource(AMMR.plurals.missing_episodes, count = count, count), + text = pluralStringResource(pluralRes, count = count, count), maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, diff --git a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt index cd3103d260..099bb7efe5 100644 --- a/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt +++ b/app/src/main/java/eu/kanade/presentation/browse/components/BrowseSourceList.kt @@ -1,7 +1,9 @@ package eu.kanade.presentation.browse.components +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -10,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems @@ -32,39 +35,41 @@ fun BrowseSourceList( onAnimeLongClick: (Anime) -> Unit, ) { // AY --> - var containerHeight by remember { mutableIntStateOf(0) } - // <-- AY - LazyColumn( - contentPadding = contentPadding + PaddingValues(vertical = 8.dp), - // AY --> - modifier = Modifier - .onGloballyPositioned { layoutCoordinates -> - containerHeight = layoutCoordinates.size.height - topBarHeight - }, + val sourceListState = rememberLazyListState() + BoxWithConstraints { + val density = LocalDensity.current + val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.roundToPx() } // <-- AY - ) { - item { - if (animeList.loadState.prepend is LoadState.Loading) { - BrowseSourceLoadingItem() + + LazyColumn( + state = sourceListState, + contentPadding = contentPadding + PaddingValues(vertical = 8.dp), + ) { + item { + if (animeList.loadState.prepend is LoadState.Loading) { + BrowseSourceLoadingItem() + } } - } - items(count = animeList.itemCount) { index -> - val anime by animeList[index]?.collectAsState() ?: return@items - BrowseSourceListItem( - anime = anime, - onClick = { onAnimeClick(anime) }, - onLongClick = { onAnimeLongClick(anime) }, - // AY --> - entries = entries, - containerHeight = containerHeight, - // <-- AY - ) - } + items(count = animeList.itemCount) { index -> + val anime by animeList[index]?.collectAsState() ?: return@items + BrowseSourceListItem( + anime = anime, + onClick = { onAnimeClick(anime) }, + onLongClick = { onAnimeLongClick(anime) }, + // AY --> + entries = entries, + containerHeight = containerHeightPx - topBarHeight, + // <-- AY + ) + } - item { - if (animeList.loadState.refresh is LoadState.Loading || animeList.loadState.append is LoadState.Loading) { - BrowseSourceLoadingItem() + item { + if (animeList.loadState.refresh is LoadState.Loading || + animeList.loadState.append is LoadState.Loading + ) { + BrowseSourceLoadingItem() + } } } } 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..4c248c2cde 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,10 +343,11 @@ fun AnimeListItem( // AY --> entries: Int = 0, containerHeight: Int = 0, + modifier: Modifier = Modifier, // <-- AY ) { Row( - modifier = Modifier + modifier = modifier .selectedBackground(isSelected) .height( // AY --> diff --git a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt index eca674422c..ca4cdf834f 100644 --- a/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt +++ b/app/src/main/java/eu/kanade/presentation/library/components/LibraryPager.kt @@ -1,6 +1,7 @@ package eu.kanade.presentation.library.components import android.content.res.Configuration +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +20,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import eu.kanade.core.preference.PreferenceMutableState import eu.kanade.tachiyomi.ui.library.LibraryItem @@ -46,88 +48,87 @@ fun LibraryPager( onClickContinueWatching: ((LibraryAnime) -> Unit)?, ) { // AY --> - var containerHeight by remember { mutableIntStateOf(0) } - // <-- AY - HorizontalPager( - modifier = Modifier.fillMaxSize() - // AY --> - .onGloballyPositioned { layoutCoordinates -> - containerHeight = layoutCoordinates.size.height - }, + BoxWithConstraints { + val density = LocalDensity.current + val containerHeightPx = with(density) { this@BoxWithConstraints.maxHeight.roundToPx() } // <-- AY - state = state, - verticalAlignment = Alignment.Top, - ) { page -> - if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) { - // To make sure only one offscreen page is being composed - return@HorizontalPager - } - val category = getCategoryForPage(page) - val items = getItemsForCategory(category) - - if (items.isEmpty()) { - LibraryPagerEmptyScreen( - searchQuery = searchQuery, - hasActiveFilters = hasActiveFilters, - contentPadding = contentPadding, - onGlobalSearchClicked = onGlobalSearchClicked, - ) - return@HorizontalPager - } - val displayMode by getDisplayMode(page) - // AY --> - val configuration = LocalConfiguration.current - val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - val columns by remember(isLandscape) { getColumnsForOrientation(isLandscape) } - // <-- AY - - val onClickAnime: (LibraryAnime) -> Unit = { onClickAnime(category, it) } - val onLongClickAnime: (LibraryAnime) -> Unit = { onLongClickAnime(category, it) } + HorizontalPager( + modifier = Modifier.fillMaxSize(), + state = state, + verticalAlignment = Alignment.Top, + ) { page -> + if (page !in ((state.currentPage - 1)..(state.currentPage + 1))) { + // To make sure only one offscreen page is being composed + return@HorizontalPager + } + val category = getCategoryForPage(page) + val items = getItemsForCategory(category) - when (displayMode) { - LibraryDisplayMode.List -> { - LibraryList( - items = items, - // AY --> - entries = columns, - containerHeight = containerHeight, - // <-- AY - contentPadding = contentPadding, - selection = selection, - onClick = onClickAnime, - onLongClick = onLongClickAnime, - onClickContinueWatching = onClickContinueWatching, + if (items.isEmpty()) { + LibraryPagerEmptyScreen( searchQuery = searchQuery, - onGlobalSearchClicked = onGlobalSearchClicked, - ) - } - LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { - LibraryCompactGrid( - items = items, - showTitle = displayMode is LibraryDisplayMode.CompactGrid, - columns = columns, + hasActiveFilters = hasActiveFilters, contentPadding = contentPadding, - selection = selection, - onClick = onClickAnime, - onLongClick = onLongClickAnime, - onClickContinueWatching = onClickContinueWatching, - searchQuery = searchQuery, onGlobalSearchClicked = onGlobalSearchClicked, ) + return@HorizontalPager } - LibraryDisplayMode.ComfortableGrid -> { - LibraryComfortableGrid( - items = items, - columns = columns, - contentPadding = contentPadding, - selection = selection, - onClick = onClickAnime, - onLongClick = onLongClickAnime, - onClickContinueWatching = onClickContinueWatching, - searchQuery = searchQuery, - onGlobalSearchClicked = onGlobalSearchClicked, - ) + + val displayMode by getDisplayMode(page) + // AY --> + val configuration = LocalConfiguration.current + val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + val columns by remember(isLandscape) { getColumnsForOrientation(isLandscape) } + // <-- AY + + val onClickAnime: (LibraryAnime) -> Unit = { onClickAnime(category, it) } + val onLongClickAnime: (LibraryAnime) -> Unit = { onLongClickAnime(category, it) } + + when (displayMode) { + LibraryDisplayMode.List -> { + LibraryList( + items = items, + // AY --> + entries = columns, + containerHeight = containerHeightPx, + // <-- AY + contentPadding = contentPadding, + selection = selection, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + onClickContinueWatching = onClickContinueWatching, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + LibraryDisplayMode.CompactGrid, LibraryDisplayMode.CoverOnlyGrid -> { + LibraryCompactGrid( + items = items, + showTitle = displayMode is LibraryDisplayMode.CompactGrid, + columns = columns, + contentPadding = contentPadding, + selection = selection, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + onClickContinueWatching = onClickContinueWatching, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } + LibraryDisplayMode.ComfortableGrid -> { + LibraryComfortableGrid( + items = items, + columns = columns, + contentPadding = contentPadding, + selection = selection, + onClick = onClickAnime, + onLongClick = onLongClickAnime, + onClickContinueWatching = onClickContinueWatching, + searchQuery = searchQuery, + onGlobalSearchClicked = onGlobalSearchClicked, + ) + } } } } diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt index 9a84a92be5..8d870c8230 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsDownloadScreen.kt @@ -95,6 +95,12 @@ object SettingsDownloadScreen : SearchableSettings { preference = downloadPreferences.removeBookmarkedEpisodes(), title = stringResource(AMMR.strings.am_pref_remove_bookmarked_episodes), ), + // AY --> + Preference.PreferenceItem.SwitchPreference( + preference = downloadPreferences.downloadFillermarkedEpisodes(), + title = stringResource(AYMR.strings.pref_download_fillermarked_items), + ), + // <-- AY getExcludedCategoriesPreference( downloadPreferences = downloadPreferences, categories = { categories }, 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..dadbda68f6 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, @@ -247,10 +271,10 @@ object SettingsLibraryScreen : SearchableSettings { stringResource(MR.strings.disabled), LibraryPreferences.EpisodeSwipeAction.ToggleBookmark to stringResource(AYMR.strings.action_bookmark_episode), - // AM (FILLERMARK) --> + // AY --> LibraryPreferences.EpisodeSwipeAction.ToggleFillermark to - stringResource(AMMR.strings.action_fillermark_episode), - // <-- AM (FILLERMARK) + stringResource(AYMR.strings.action_fillermark_episode), + // <-- AY LibraryPreferences.EpisodeSwipeAction.ToggleSeen to stringResource(AMMR.strings.am_action_mark_as_seen), LibraryPreferences.EpisodeSwipeAction.Download to @@ -265,10 +289,10 @@ object SettingsLibraryScreen : SearchableSettings { stringResource(MR.strings.disabled), LibraryPreferences.EpisodeSwipeAction.ToggleBookmark to stringResource(AYMR.strings.action_bookmark_episode), - // AM (FILLERMARK) --> + // AY --> LibraryPreferences.EpisodeSwipeAction.ToggleFillermark to - stringResource(AMMR.strings.action_fillermark_episode), - // <-- AM (FILLERMARK) + stringResource(AYMR.strings.action_fillermark_episode), + // <-- AY LibraryPreferences.EpisodeSwipeAction.ToggleSeen to stringResource(AMMR.strings.am_action_mark_as_seen), LibraryPreferences.EpisodeSwipeAction.Download to 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..e2efef7a0f 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,45 @@ 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 +358,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/presentation/player/components/PlayerSheet.kt b/app/src/main/java/eu/kanade/presentation/player/components/PlayerSheet.kt index c87e01235d..a75b1ecab3 100644 --- a/app/src/main/java/eu/kanade/presentation/player/components/PlayerSheet.kt +++ b/app/src/main/java/eu/kanade/presentation/player/components/PlayerSheet.kt @@ -85,7 +85,7 @@ fun PlayerSheet( val density = LocalDensity.current val latestOnDismissRequest by rememberUpdatedState(onDismissRequest) val maxWidth = if (LocalConfiguration.current.orientation == ORIENTATION_LANDSCAPE) { - 640.dp + 720.dp } else { 420.dp } diff --git a/app/src/main/java/eu/kanade/presentation/player/components/SwitchPreference.kt b/app/src/main/java/eu/kanade/presentation/player/components/SwitchPreference.kt index 270c5fc37f..1143a33a9a 100644 --- a/app/src/main/java/eu/kanade/presentation/player/components/SwitchPreference.kt +++ b/app/src/main/java/eu/kanade/presentation/player/components/SwitchPreference.kt @@ -20,12 +20,15 @@ package eu.kanade.presentation.player.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role +import tachiyomi.presentation.core.components.material.padding @Composable fun SwitchPreference( @@ -37,6 +40,7 @@ fun SwitchPreference( Row( modifier = modifier .toggleable(value, true, Role.Switch, onValueChange) + .padding(horizontal = MaterialTheme.padding.large, vertical = MaterialTheme.padding.small) .fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt index 3cd6f08353..b84f9b1a03 100644 --- a/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt +++ b/app/src/main/java/eu/kanade/presentation/theme/TachiyomiTheme.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RippleConfiguration import androidx.compose.runtime.Composable import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.AppTheme @@ -85,14 +86,14 @@ private fun getThemeColorScheme( } // AY --> -private const val RIPPLE_DRAGGED_ALPHA = .5f -private const val RIPPLE_FOCUSED_ALPHA = .6f -private const val RIPPLE_HOVERED_ALPHA = .4f -private const val RIPPLE_PRESSED_ALPHA = .6f +private const val RIPPLE_DRAGGED_ALPHA = .1f +private const val RIPPLE_FOCUSED_ALPHA = .1f +private const val RIPPLE_HOVERED_ALPHA = .1f +private const val RIPPLE_PRESSED_ALPHA = .1f val playerRippleConfiguration @Composable get() = RippleConfiguration( - color = MaterialTheme.colorScheme.primaryContainer, + color = if (isSystemInDarkTheme()) Color.White else Color.Black, rippleAlpha = RippleAlpha( draggedAlpha = RIPPLE_DRAGGED_ALPHA, focusedAlpha = RIPPLE_FOCUSED_ALPHA, diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index 054ca887d0..9559eec3ed 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -159,9 +159,9 @@ fun UpdatesBottomBar( selected: List, onDownloadEpisode: (List, EpisodeDownloadAction) -> Unit, onMultiBookmarkClicked: (List, bookmark: Boolean) -> Unit, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked: (List, fillermarked: Boolean) -> Unit, - // <-- AM (FILLERMARK) + // <-- AY onMultiMarkAsSeenClicked: (List, seen: Boolean) -> Unit, onMultiDeleteClicked: (List) -> Unit, onOpenEpisode: (UpdatesItem, altPlayer: Boolean) -> Unit, @@ -176,14 +176,14 @@ fun UpdatesBottomBar( onRemoveBookmarkClicked = { onMultiBookmarkClicked.invoke(selected, false) }.takeIf { selected.fastAll { it.update.bookmark } }, - // AM (FILLERMARK) --> + // AY --> onFillermarkClicked = { onMultiFillermarkClicked.invoke(selected, true) }.takeIf { selected.fastAny { !it.update.fillermark } }, onRemoveFillermarkClicked = { onMultiFillermarkClicked.invoke(selected, false) }.takeIf { selected.fastAll { it.update.fillermark } }, - // <-- AM (FILLERMARK) + // <-- AY onMarkAsSeenClicked = { onMultiMarkAsSeenClicked(selected, true) }.takeIf { selected.fastAny { !it.update.seen } }, diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt index 78aba155b0..93b6f9230c 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesUiItem.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Circle import androidx.compose.material3.Icon @@ -225,6 +226,20 @@ private fun UpdatesUiItem( ) Spacer(modifier = Modifier.width(2.dp)) } + // AY --> + if (update.fillermark) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = stringResource(AYMR.strings.action_filter_fillermarked), + modifier = Modifier + .sizeIn( + maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }, + ), + tint = MaterialTheme.colorScheme.tertiary, + ) + Spacer(modifier = Modifier.width(2.dp)) + } + // <-- AY Text( text = update.episodeName, maxLines = 1, diff --git a/app/src/main/java/eu/kanade/tachiyomi/App.kt b/app/src/main/java/eu/kanade/tachiyomi/App.kt index 03d6db40ba..3b9d043d5a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/App.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/App.kt @@ -29,8 +29,8 @@ import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.model.setAppCompatDelegateThemeMode import eu.kanade.tachiyomi.crash.CrashActivity import eu.kanade.tachiyomi.crash.GlobalExceptionHandler -import eu.kanade.tachiyomi.data.coil.AnimeCoverFetcher import eu.kanade.tachiyomi.data.coil.AnimeCoverKeyer +import eu.kanade.tachiyomi.data.coil.AnimeImageFetcher import eu.kanade.tachiyomi.data.coil.AnimeKeyer import eu.kanade.tachiyomi.data.coil.BufferedSourceFetcher import eu.kanade.tachiyomi.data.connection.discord.DiscordRPCService @@ -42,7 +42,6 @@ import eu.kanade.tachiyomi.network.NetworkHelper import eu.kanade.tachiyomi.network.NetworkPreferences import eu.kanade.tachiyomi.ui.base.delegate.SecureActivityDelegate import eu.kanade.tachiyomi.util.system.DeviceUtil -import eu.kanade.tachiyomi.util.system.GLUtil import eu.kanade.tachiyomi.util.system.WebViewUtil import eu.kanade.tachiyomi.util.system.animatorDurationScale import eu.kanade.tachiyomi.util.system.cancelNotification @@ -59,7 +58,6 @@ import org.conscrypt.Conscrypt import tachiyomi.core.common.i18n.stringResource import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore -import tachiyomi.core.common.util.system.ImageUtil import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import tachiyomi.presentation.widget.WidgetManager @@ -175,8 +173,8 @@ class App : Application(), DefaultLifecycleObserver, SingletonImageLoader.Factor add(OkHttpNetworkFetcherFactory(callFactoryLazy::value)) // Fetcher.Factory add(BufferedSourceFetcher.Factory()) - add(AnimeCoverFetcher.AnimeCoverFactory(callFactoryLazy)) - add(AnimeCoverFetcher.AnimeFactory(callFactoryLazy)) + add(AnimeImageFetcher.AnimeCoverFactory(callFactoryLazy)) + add(AnimeImageFetcher.AnimeFactory(callFactoryLazy)) // Keyer add(AnimeCoverKeyer()) add(AnimeKeyer()) 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..d7472f0d28 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,18 @@ data class BackupAnime( // AM --> @ProtoNumber(206) var excludedScanlators: List = emptyList(), // <-- AM + + // AY --> + // Aniyomi specific values + @ProtoNumber(500) var backgroundUrl: String? = null, + // @ProtoNumber(501) Broken in aniyomi, do not use + @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, + @ProtoNumber(507) var fetchType: FetchType = FetchType.Episodes, + // <-- AY ) { fun getAnimeImpl(): Anime { return Anime.create().copy( @@ -69,6 +82,9 @@ data class BackupAnime( ogStatus = this@BackupAnime.status.toLong(), // <-- AM (CUSTOM_INFORMATION) thumbnailUrl = this@BackupAnime.thumbnailUrl, + // AY --> + backgroundUrl = this@BackupAnime.backgroundUrl, + // <-- AY favorite = this@BackupAnime.favorite, source = this@BackupAnime.source, dateAdded = this@BackupAnime.dateAdded, @@ -80,6 +96,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/models/BackupEpisode.kt b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupEpisode.kt index a90dd3eec6..de82a29810 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupEpisode.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/backup/models/BackupEpisode.kt @@ -13,9 +13,6 @@ data class BackupEpisode( @ProtoNumber(3) var scanlator: String? = null, @ProtoNumber(4) var seen: Boolean = false, @ProtoNumber(5) var bookmark: Boolean = false, - // AM (FILLERMARK) --> - @ProtoNumber(15) var fillermark: Boolean = false, - // <-- AM (FILLERMARK) // lastSecondSeen is called progress in 1.x @ProtoNumber(6) var lastSecondSeen: Long = 0, // AY --> @@ -28,6 +25,13 @@ data class BackupEpisode( @ProtoNumber(10) var sourceOrder: Long = 0, @ProtoNumber(11) var lastModifiedAt: Long = 0, @ProtoNumber(12) var version: Long = 0, + + // AY --> + // Aniyomi specific values + @ProtoNumber(501) var fillermark: Boolean = false, + @ProtoNumber(502) var summary: String? = null, + @ProtoNumber(503) var previewUrl: String? = null, + // <-- AY ) { fun toEpisodeImpl(): Episode { return Episode.create().copy( @@ -35,11 +39,15 @@ data class BackupEpisode( name = this@BackupEpisode.name, episodeNumber = this@BackupEpisode.episodeNumber.toDouble(), scanlator = this@BackupEpisode.scanlator, + // AY --> + summary = this@BackupEpisode.summary, + previewUrl = this@BackupEpisode.previewUrl, + // <-- AY seen = this@BackupEpisode.seen, bookmark = this@BackupEpisode.bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = this@BackupEpisode.fillermark, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen = this@BackupEpisode.lastSecondSeen, // AY --> totalSeconds = this@BackupEpisode.totalSeconds, @@ -61,9 +69,9 @@ val backupEpisodeMapper = { scanlator: String?, seen: Boolean, bookmark: Boolean, - // AM (FILLERMARK) --> + // AY --> fillermark: Boolean, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen: Long, // AY --> totalSeconds: Long, @@ -75,17 +83,25 @@ val backupEpisodeMapper = { lastModifiedAt: Long, version: Long, _: Long, + // AY --> + summary: String?, + previewUrl: String?, + // <-- AY -> BackupEpisode( url = url, name = name, episodeNumber = episodeNumber.toFloat(), scanlator = scanlator, + // AY --> + summary = summary, + previewUrl = previewUrl, + // <-- AY seen = seen, bookmark = bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = fillermark, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen = lastSecondSeen, // AY --> totalSeconds = totalSeconds, 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..df1cd984ef 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,13 @@ 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 +136,10 @@ class AnimeRestorer( // <-- AM (CUSTOM_INFORMATION) initialized = this.initialized || newer.initialized, version = newer.version, + // AY --> + fetchType = newer.fetchType, + parentId = newer.parentId, + // <-- AY ) } @@ -143,6 +169,15 @@ 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, + backgroundUrl = anime.backgroundUrl, + backgroundLastModified = anime.backgroundLastModified, + // <-- AY ) } return anime @@ -179,9 +214,9 @@ class AnimeRestorer( .copy( id = dbEpisode.id, bookmark = episode.bookmark || dbEpisode.bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = episode.fillermark || dbEpisode.fillermark, - // <-- AM (FILLERMARK) + // <-- AY ) if (dbEpisode.seen && !updatedEpisode.seen) { updatedEpisode = updatedEpisode.copy( @@ -214,9 +249,9 @@ class AnimeRestorer( episode.scanlator, episode.seen, episode.bookmark, - // AM (FILLERMARK) --> + // AY --> episode.fillermark, - // <-- AM (FILLERMARK) + // <-- AY episode.lastSecondSeen, // AY --> episode.totalSeconds, @@ -226,6 +261,10 @@ class AnimeRestorer( episode.dateFetch, episode.dateUpload, episode.version, + // AY --> + episode.summary, + episode.previewUrl, + // <-- AY ) } } @@ -239,11 +278,15 @@ class AnimeRestorer( url = null, name = null, scanlator = null, + // AY --> + summary = null, + previewUrl = null, + // <-- AY seen = episode.seen, bookmark = episode.bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = episode.fillermark, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen = episode.lastSecondSeen, // AY --> totalSeconds = episode.totalSeconds, @@ -289,6 +332,15 @@ 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, + backgroundUrl = anime.backgroundUrl, + backgroundLastModified = anime.backgroundLastModified, + // <-- AY ) animesQueries.selectLastInsertedRowId() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/cache/BackgroundCache.kt b/app/src/main/java/eu/kanade/tachiyomi/data/cache/BackgroundCache.kt new file mode 100644 index 0000000000..c442559208 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/cache/BackgroundCache.kt @@ -0,0 +1,107 @@ +// AY --> +package eu.kanade.tachiyomi.data.cache + +import android.content.Context +import eu.kanade.tachiyomi.util.storage.DiskUtil +import tachiyomi.domain.anime.model.Anime +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Class used to create background cache. + * It is used to store the background of the library. + * Names of files are created with the md5 of the background URL. + * + * @param context the application context. + * @constructor creates an instance of the background cache. + */ +class BackgroundCache(private val context: Context) { + + companion object { + private const val BACKGROUNDS_DIR = "backgrounds" + private const val CUSTOM_BACKGROUNDS_DIR = "backgrounds/custom" + } + + /** + * Cache directory used for cache management. + */ + private val cacheDir = getCacheDir(BACKGROUNDS_DIR) + + private val customBackgroundCacheDir = getCacheDir(CUSTOM_BACKGROUNDS_DIR) + + /** + * Returns the background from cache. + * + * @param animeBackgroundUrl the anime. + * @return background image. + */ + fun getBackgroundFile(animeBackgroundUrl: String?): File? { + return animeBackgroundUrl?.let { + File(cacheDir, DiskUtil.hashKeyForDisk(it)) + } + } + + /** + * Returns the custom background from cache. + * + * @param animeId the anime id. + * @return background image. + */ + fun getCustomBackgroundFile(animeId: Long?): File { + return File(customBackgroundCacheDir, DiskUtil.hashKeyForDisk(animeId.toString())) + } + + /** + * Saves the given stream as the anime's custom background to cache. + * + * @param anime the anime. + * @param inputStream the stream to copy. + * @throws IOException if there's any error. + */ + @Throws(IOException::class) + fun setCustomBackgroundToCache(anime: Anime, inputStream: InputStream) { + getCustomBackgroundFile(anime.id).outputStream().use { + inputStream.copyTo(it) + } + } + + /** + * Delete the background files of the anime from the cache. + * + * @param anime the anime. + * @param deleteCustomBackground whether the custom background should be deleted. + * @return number of files that were deleted. + */ + fun deleteFromCache(anime: Anime, deleteCustomBackground: Boolean = false): Int { + var deleted = 0 + + getBackgroundFile(anime.backgroundUrl)?.let { + if (it.exists() && it.delete()) ++deleted + } + + if (deleteCustomBackground) { + if (deleteCustomBackground(anime.id)) ++deleted + } + + return deleted + } + + /** + * Delete custom background of the anime from the cache + * + * @param animeId the anime id. + * @return whether the background was deleted. + */ + fun deleteCustomBackground(animeId: Long?): Boolean { + return getCustomBackgroundFile(animeId).let { + it.exists() && it.delete() + } + } + + private fun getCacheDir(dir: String): File { + return context.getExternalFilesDir(dir) + ?: File(context.filesDir, dir).also { it.mkdirs() } + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverKeyer.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverKeyer.kt index 143fb9d05a..51402ce350 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverKeyer.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverKeyer.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.coil import coil3.key.Keyer import coil3.request.Options +import eu.kanade.domain.anime.model.hasCustomBackground import eu.kanade.domain.anime.model.hasCustomCover import eu.kanade.tachiyomi.data.cache.CoverCache import tachiyomi.domain.anime.model.AnimeCover @@ -11,11 +12,14 @@ import tachiyomi.domain.anime.model.Anime as DomainAnime class AnimeKeyer : Keyer { override fun key(data: DomainAnime, options: Options): String { - return if (data.hasCustomCover()) { - "anime;${data.id};${data.coverLastModified}" - } else { - "anime;${data.thumbnailUrl};${data.coverLastModified}" + // AY --> + return when { + options.useBackground && data.hasCustomBackground() -> "anime;${data.id};${data.backgroundLastModified}" + options.useBackground -> "anime;${data.backgroundUrl};${data.backgroundLastModified}" + data.hasCustomCover() -> "anime;${data.id};${data.coverLastModified}" + else -> "anime;${data.thumbnailUrl};${data.coverLastModified}" } + // <-- AY } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeImageFetcher.kt similarity index 91% rename from app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverFetcher.kt rename to app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeImageFetcher.kt index 055e7cc282..033ad00cd5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeCoverFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/AnimeImageFetcher.kt @@ -13,8 +13,9 @@ import coil3.getOrDefault import coil3.request.Options import com.hippo.unifile.UniFile import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache -import eu.kanade.tachiyomi.data.coil.AnimeCoverFetcher.Companion.USE_CUSTOM_COVER_KEY +import eu.kanade.tachiyomi.data.coil.AnimeImageFetcher.Companion.USE_CUSTOM_COVER_KEY import eu.kanade.tachiyomi.network.await import logcat.LogPriority import okhttp3.CacheControl @@ -45,7 +46,7 @@ import java.io.IOException * Available request parameter: * - [USE_CUSTOM_COVER_KEY]: Use custom cover if set by user, default is true */ -class AnimeCoverFetcher( +class AnimeImageFetcher( private val url: String?, private val isLibraryAnime: Boolean, private val options: Options, @@ -302,15 +303,37 @@ class AnimeCoverFetcher( ) : Fetcher.Factory { private val coverCache: CoverCache by injectLazy() + + // AY --> + private val backgroundCache: BackgroundCache by injectLazy() + + // <-- AY private val sourceManager: SourceManager by injectLazy() override fun create(data: Anime, options: Options, imageLoader: ImageLoader): Fetcher { - return AnimeCoverFetcher( - url = data.thumbnailUrl, + // AY --> + val isBackground = options.useBackground + val url = if (isBackground) data.backgroundUrl else data.thumbnailUrl + + val coverCacheLazy = if (isBackground) { + lazy { backgroundCache.getBackgroundFile(url) } + } else { + lazy { coverCache.getCoverFile(url) } + } + + val customCoverCacheLazy = if (isBackground) { + lazy { backgroundCache.getCustomBackgroundFile(data.id) } + } else { + lazy { coverCache.getCustomCoverFile(data.id) } + } + // <-- AY + + return AnimeImageFetcher( + url = url, isLibraryAnime = data.favorite, options = options, - coverFileLazy = lazy { coverCache.getCoverFile(data.thumbnailUrl) }, - customCoverFileLazy = lazy { coverCache.getCustomCoverFile(data.id) }, + coverFileLazy = coverCacheLazy, + customCoverFileLazy = customCoverCacheLazy, diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! }, sourceLazy = lazy { sourceManager.get(data.source) as? AnimeHttpSource }, callFactoryLazy = callFactoryLazy, @@ -327,7 +350,7 @@ class AnimeCoverFetcher( private val sourceManager: SourceManager by injectLazy() override fun create(data: AnimeCover, options: Options, imageLoader: ImageLoader): Fetcher { - return AnimeCoverFetcher( + return AnimeImageFetcher( url = data.url, isLibraryAnime = data.isAnimeFavorite, options = options, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt index 7a920bf398..05f4b119f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/Utils.kt @@ -42,3 +42,14 @@ val Options.customDecoder: Boolean get() = getExtra(customDecoderKey) private val customDecoderKey = Extras.Key(default = false) + +// AY --> +fun ImageRequest.Builder.useBackground(enable: Boolean) = apply { + extras[useBackgroundKey] = enable +} + +val Options.useBackground: Boolean + get() = getExtra(useBackgroundKey) + +private val useBackgroundKey = Extras.Key(default = false) +// <-- AY diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Episode.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Episode.kt index 6c1e5acffc..3bb72e6760 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Episode.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/Episode.kt @@ -16,10 +16,6 @@ interface Episode : SEpisode, Serializable { var bookmark: Boolean - // AM (FILLERMARK) --> - var fillermark: Boolean - // <-- AM (FILLERMARK) - var last_second_seen: Long // AY --> @@ -45,9 +41,9 @@ fun Episode.toDomainEpisode(): DomainEpisode? { animeId = anime_id!!, seen = seen, bookmark = bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = fillermark, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen = last_second_seen, // AY --> totalSeconds = total_seconds, @@ -59,6 +55,10 @@ fun Episode.toDomainEpisode(): DomainEpisode? { dateUpload = date_upload, episodeNumber = episode_number.toDouble(), scanlator = scanlator, + // AY --> + summary = summary, + previewUrl = preview_url, + // <-- AY lastModifiedAt = last_modified, version = version, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/EpisodeImpl.kt b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/EpisodeImpl.kt index b847d50157..78c573a590 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/database/models/EpisodeImpl.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/database/models/EpisodeImpl.kt @@ -14,13 +14,19 @@ class EpisodeImpl : Episode { override var scanlator: String? = null + // AY --> + override var summary: String? = null + + override var preview_url: String? = null + // <-- AY + override var seen: Boolean = false override var bookmark: Boolean = false - // AM (FILLERMARK) --> + // AY --> override var fillermark: Boolean = false - // <-- AM (FILLERMARK) + // <-- AY override var last_second_seen: Long = 0 diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt index fc092f0272..e05f1ff54d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/download/DownloadManager.kt @@ -147,10 +147,10 @@ class DownloadManager( video: Video? = null, // <-- AY ) { - // AM (FILLERMARK) --> + // AY --> val filteredEpisodes = getEpisodesToDownload(episodes) downloader.queueEpisodes(anime, filteredEpisodes, autoStart, alt, video) - // <-- AM (FILLERMARK) + // <-- AY } /** @@ -467,15 +467,15 @@ class DownloadManager( } } - // AM (FILLERMARK) --> + // AY --> private fun getEpisodesToDownload(episodes: List): List { - return if (!downloadPreferences.notDownloadFillermarkedItems().get()) { + return if (!downloadPreferences.downloadFillermarkedEpisodes().get()) { episodes.filterNot { it.fillermark } } else { episodes } } - // <-- AM (FILLERMARK) + // <-- AY fun statusFlow(): Flow = queueState .flatMapLatest { downloads -> 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..c7cc417f44 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,7 +23,9 @@ 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.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.connection.syncmiru.SyncDataJob import eu.kanade.tachiyomi.data.download.DownloadManager @@ -69,6 +71,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 @@ -95,6 +98,11 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet private val libraryPreferences: LibraryPreferences = Injekt.get() private val downloadManager: DownloadManager = Injekt.get() private val coverCache: CoverCache = Injekt.get() + + // AY --> + private val backgroundCache: BackgroundCache = Injekt.get() + + // <-- AY private val getLibraryAnime: GetLibraryAnime = Injekt.get() private val getAnime: GetAnime = Injekt.get() private val updateAnime: UpdateAnime = Injekt.get() @@ -102,6 +110,10 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet 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() private val trackerManager: TrackerManager = Injekt.get() @@ -235,14 +247,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 +294,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 && it.totalCount > 0L && !it.hasStarted -> { skippedUpdates.add( it.anime to context.stringResource(AMMR.strings.skipped_reason_not_started_anime), ) @@ -320,7 +353,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 } @@ -403,14 +438,18 @@ class LibraryUpdateJob(private val context: Context, workerParams: WorkerParamet // Update anime metadata if needed if (libraryPreferences.autoUpdateMetadata().get()) { val networkAnime = source.getAnimeDetails(anime.toSAnime()) - updateAnime.awaitUpdateFromSource(anime, networkAnime, manualFetch = false, coverCache) + // AY --> + updateAnime.awaitUpdateFromSource(anime, networkAnime, manualFetch = false, coverCache, backgroundCache) + // <-- AY } val episodes = source.getEpisodeList(anime.toSAnime()) // 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/data/library/MetadataUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateJob.kt index 9efaecf11b..91fa340a31 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/MetadataUpdateJob.kt @@ -13,8 +13,10 @@ import androidx.work.WorkerParameters import eu.kanade.domain.anime.interactor.UpdateAnime import eu.kanade.domain.anime.model.copyFrom import eu.kanade.domain.anime.model.toSAnime +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.prepUpdateBackground import eu.kanade.tachiyomi.util.prepUpdateCover import eu.kanade.tachiyomi.util.system.isRunning import eu.kanade.tachiyomi.util.system.setForegroundSafely @@ -47,6 +49,11 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame private val sourceManager: SourceManager = Injekt.get() private val coverCache: CoverCache = Injekt.get() + + // AY --> + private val backgroundCache: BackgroundCache = Injekt.get() + + // <-- AY private val getLibraryAnime: GetLibraryAnime = Injekt.get() private val updateAnime: UpdateAnime = Injekt.get() @@ -121,7 +128,11 @@ class MetadataUpdateJob(private val context: Context, workerParams: WorkerParame val source = sourceManager.get(anime.source) ?: return@withUpdateNotification try { val networkAnime = source.getAnimeDetails(anime.toSAnime()) - val updatedAnime = anime.prepUpdateCover(coverCache, networkAnime, true) + val updatedAnime = anime + .prepUpdateCover(coverCache, networkAnime, true) + // AY --> + .prepUpdateBackground(backgroundCache, networkAnime, true) + // <-- AY .copyFrom(networkAnime) try { updateAnime.await(updatedAnime.toAnimeUpdate()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt index ad21669741..3945b55ab1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/saver/ImageSaver.kt @@ -26,6 +26,10 @@ import java.io.File import java.io.InputStream import java.time.Instant +// AY --> +typealias ImageBackground = Image.Cover +// <-- AY + class ImageSaver( val context: Context, ) { 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..e994361601 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/di/AppModule.kt @@ -9,6 +9,7 @@ import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.android.AndroidSqliteDriver import eu.kanade.domain.track.store.DelayedTrackingStore import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.connection.ConnectionManager import eu.kanade.tachiyomi.data.connection.syncmiru.service.GoogleDriveService @@ -34,13 +35,17 @@ 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.LocalBackgroundManager import tachiyomi.source.local.image.LocalCoverManager +import tachiyomi.source.local.image.LocalEpisodeThumbnailManager import tachiyomi.source.local.io.LocalSourceFileSystem import uy.kohesive.injekt.api.InjektModule import uy.kohesive.injekt.api.InjektRegistrar @@ -88,6 +93,9 @@ class AppModule(val app: Application) : InjektModule { animesAdapter = Animes.Adapter( genreAdapter = StringListColumnAdapter, update_strategyAdapter = UpdateStrategyColumnAdapter, + // AY --> + fetch_typeAdapter = FetchTypeColumnAdapter, + // <-- AY ), ) } @@ -115,6 +123,9 @@ class AppModule(val app: Application) : InjektModule { } addSingletonFactory { CoverCache(app) } + // AY --> + addSingletonFactory { BackgroundCache(app) } + // <-- AY addSingletonFactory { NetworkHelper(app, get()) } addSingletonFactory { JavaScriptEngine(app) } @@ -138,6 +149,11 @@ class AppModule(val app: Application) : InjektModule { addSingletonFactory { AndroidStorageFolderProvider(app) } addSingletonFactory { LocalSourceFileSystem(get()) } addSingletonFactory { LocalCoverManager(app, get()) } + // AY --> + addSingletonFactory { LocalFetchTypeManager(app, get()) } + addSingletonFactory { LocalBackgroundManager(app, get()) } + addSingletonFactory { LocalEpisodeThumbnailManager(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..575ea4f8e2 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,11 @@ class AndroidSourceManager( context, Injekt.get(), Injekt.get(), + // AY --> + Injekt.get(), + Injekt.get(), + Injekt.get(), + // <-- AY ), ), ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeCoverScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeImageScreenModel.kt similarity index 54% rename from app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeCoverScreenModel.kt rename to app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeImageScreenModel.kt index 3bd5d2597c..e52ba2f3bc 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeCoverScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/anime/AnimeImageScreenModel.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.anime import android.content.Context import android.net.Uri +import androidx.compose.foundation.pager.PagerState import androidx.compose.material3.SnackbarHostState import cafe.adriel.voyager.core.model.StateScreenModel import cafe.adriel.voyager.core.model.screenModelScope @@ -10,10 +11,12 @@ import coil3.imageLoader import coil3.request.ImageRequest import coil3.size.Size import eu.kanade.domain.anime.interactor.UpdateAnime +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.saver.Image import eu.kanade.tachiyomi.data.saver.ImageSaver import eu.kanade.tachiyomi.data.saver.Location +import eu.kanade.tachiyomi.util.editBackground import eu.kanade.tachiyomi.util.editCover import eu.kanade.tachiyomi.util.system.getBitmapOrNull import eu.kanade.tachiyomi.util.system.toShareIntent @@ -28,19 +31,31 @@ import tachiyomi.core.common.util.system.logcat import tachiyomi.domain.anime.interactor.GetAnime import tachiyomi.domain.anime.model.Anime import tachiyomi.i18n.MR +import tachiyomi.i18n.aniyomi.AYMR import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get -class AnimeCoverScreenModel( +class AnimeImageScreenModel( private val animeId: Long, private val getAnime: GetAnime = Injekt.get(), private val imageSaver: ImageSaver = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), + // AY --> + private val backgroundCache: BackgroundCache = Injekt.get(), + // <-- AY private val updateAnime: UpdateAnime = Injekt.get(), val snackbarHostState: SnackbarHostState = SnackbarHostState(), + // AY --> + val pagerState: PagerState = PagerState(pageCount = { 2 }), + // <-- AY ) : StateScreenModel(null) { + // AY --> + private val isCover: Boolean + get() = pagerState.currentPage != 1 + // <-- AY + init { screenModelScope.launchIO { getAnime.subscribe(animeId) @@ -48,35 +63,54 @@ class AnimeCoverScreenModel( } } - fun saveCover(context: Context) { + fun saveImage(context: Context) { + // AY --> + val savedStringResource = if (isCover) { + MR.strings.cover_saved + } else { + AYMR.strings.background_saved + } + val errorSavingStringResource = if (isCover) { + MR.strings.error_saving_cover + } else { + AYMR.strings.error_saving_background + } + // <-- AY screenModelScope.launch { try { - saveCoverInternal(context, temp = false) + saveImageInternal(context, temp = false) snackbarHostState.showSnackbar( - context.stringResource(MR.strings.cover_saved), + context.stringResource(savedStringResource), withDismissAction = true, ) } catch (e: Throwable) { logcat(LogPriority.ERROR, e) snackbarHostState.showSnackbar( - context.stringResource(MR.strings.error_saving_cover), + context.stringResource(errorSavingStringResource), withDismissAction = true, ) } } } - fun shareCover(context: Context) { + fun shareImage(context: Context) { + // AY --> + val errorSharingStringResource = if (isCover) { + MR.strings.error_sharing_cover + } else { + AYMR.strings.error_sharing_background + } + // <-- AY screenModelScope.launch { try { - val uri = saveCoverInternal(context, temp = true) ?: return@launch + val uri = saveImageInternal(context, temp = true) ?: return@launch withUIContext { context.startActivity(uri.toShareIntent(context)) } } catch (e: Throwable) { logcat(LogPriority.ERROR, e) snackbarHostState.showSnackbar( - context.stringResource(MR.strings.error_sharing_cover), + context.stringResource(errorSharingStringResource), withDismissAction = true, ) } @@ -84,12 +118,12 @@ class AnimeCoverScreenModel( } /** - * Save anime cover Bitmap to picture or temporary share directory. + * Save anime image Bitmap to picture or temporary share directory. * * @param context The context for building and executing the ImageRequest * @return the uri to saved file */ - private suspend fun saveCoverInternal(context: Context, temp: Boolean): Uri? { + private suspend fun saveImageInternal(context: Context, temp: Boolean): Uri? { val anime = state.value ?: return null val req = ImageRequest.Builder(context) .data(anime) @@ -99,12 +133,14 @@ class AnimeCoverScreenModel( return withIOContext { val result = context.imageLoader.execute(req).image?.asDrawable(context.resources) - // TODO: Handle animated cover + // TODO: Handle animated image val bitmap = result?.getBitmapOrNull() ?: return@withIOContext null imageSaver.save( Image.Cover( bitmap = bitmap, - name = anime.title, + // AY --> + name = if (isCover) "${anime.title}-cover" else "${anime.title}-background", + // <-- AY location = if (temp) Location.Cache else Location.Pictures.create(), ), ) @@ -112,51 +148,76 @@ class AnimeCoverScreenModel( } /** - * Update cover with local file. + * Update image with local file. * * @param context Context. - * @param data uri of the cover resource. + * @param data uri of the image resource. */ - fun editCover(context: Context, data: Uri) { + fun editImage(context: Context, data: Uri) { val anime = state.value ?: return screenModelScope.launchIO { context.contentResolver.openInputStream(data)?.use { try { - anime.editCover(Injekt.get(), it, updateAnime, coverCache) - notifyCoverUpdated(context) + if (isCover) { + anime.editCover(Injekt.get(), it, updateAnime, coverCache) + } else { + // AY --> + anime.editBackground(Injekt.get(), it, updateAnime, backgroundCache) + // <-- AY + } + notifyImageUpdated(context) } catch (e: Exception) { - notifyFailedCoverUpdate(context, e) + notifyFailedImageUpdate(context, e) } } } } - fun deleteCustomCover(context: Context) { + fun deleteCustomImage(context: Context) { val animeId = state.value?.id ?: return screenModelScope.launchIO { try { - coverCache.deleteCustomCover(animeId) - updateAnime.awaitUpdateCoverLastModified(animeId) - notifyCoverUpdated(context) + if (isCover) { + coverCache.deleteCustomCover(animeId) + updateAnime.awaitUpdateCoverLastModified(animeId) + } else { + backgroundCache.deleteCustomBackground(animeId) + updateAnime.awaitUpdateBackgroundLastModified(animeId) + } + notifyImageUpdated(context) } catch (e: Exception) { - notifyFailedCoverUpdate(context, e) + notifyFailedImageUpdate(context, e) } } } - private fun notifyCoverUpdated(context: Context) { + private fun notifyImageUpdated(context: Context) { + // AY --> + val updatedStringResource = if (isCover) { + MR.strings.cover_updated + } else { + AYMR.strings.background_updated + } + // <-- AY screenModelScope.launch { snackbarHostState.showSnackbar( - context.stringResource(MR.strings.cover_updated), + context.stringResource(updatedStringResource), withDismissAction = true, ) } } - private fun notifyFailedCoverUpdate(context: Context, e: Throwable) { + private fun notifyFailedImageUpdate(context: Context, e: Throwable) { + // AY --> + val updateFailedStringResource = if (isCover) { + MR.strings.notification_cover_update_failed + } else { + AYMR.strings.notification_background_update_failed + } + // <-- AY screenModelScope.launch { snackbarHostState.showSnackbar( - context.stringResource(MR.strings.notification_cover_update_failed), + context.stringResource(updateFailedStringResource), withDismissAction = true, ) logcat(LogPriority.ERROR, e) 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..85e3b9287e 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 @@ -25,6 +25,7 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.core.util.ifSourcesLoaded +import eu.kanade.domain.anime.model.hasCustomBackground import eu.kanade.domain.anime.model.hasCustomCover import eu.kanade.domain.anime.model.toSAnime import eu.kanade.presentation.anime.AnimeScreen @@ -32,7 +33,8 @@ 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.components.AnimeCoverDialog +import eu.kanade.presentation.anime.SeasonSettingsDialog +import eu.kanade.presentation.anime.components.AnimeImagesDialog import eu.kanade.presentation.anime.components.DeleteEpisodesDialog import eu.kanade.presentation.anime.components.ScanlatorFilterDialog import eu.kanade.presentation.anime.components.SetIntervalDialog @@ -45,11 +47,13 @@ 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 import eu.kanade.tachiyomi.ui.anime.track.TrackInfoDialogHomeScreen import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen +import eu.kanade.tachiyomi.ui.browse.migration.season.MigrateSeasonSelectScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.GlobalSearchScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen @@ -148,7 +152,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 +181,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, @@ -186,9 +196,13 @@ class AnimeScreen( // <-- AY }, onSearch = { query, global -> scope.launch { performSearch(navigator, query, global) } }, - onCoverClicked = screenModel::showCoverDialog, + onCoverClicked = screenModel::showImagesDialog, 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,15 +214,19 @@ 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, // <-- AM (CUSTOM_INFORMATION) onMultiBookmarkClicked = screenModel::bookmarkEpisodes, - // AM (FILLERMARK) --> + // AY --> onMultiFillermarkClicked = screenModel::fillermarkEpisodes, - // <-- AM (FILLERMARK) + // <-- AY onEditNotesClicked = { navigator.push(AnimeNotesScreen(anime = successState.anime)) }, onMultiMarkAsSeenClicked = screenModel::markEpisodesSeen, onMarkPreviousAsSeenClicked = screenModel::markPreviousEpisodeSeen, @@ -217,6 +235,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) } @@ -224,7 +255,9 @@ class AnimeScreen( val onDismissRequest = { screenModel.dismissDialog() // AY --> - if (screenModel.autoOpenTrack && screenModel.isFromChangeCategory) { + if (screenModel.autoOpenTrack && screenModel.isFromChangeCategory && + successState.anime.fetchType == FetchType.Episodes + ) { screenModel.isFromChangeCategory = false screenModel.showTrackDialog() } @@ -268,25 +301,54 @@ class AnimeScreen( target = dialog.target, // Initiated from the context of [dialog.target] so we show [dialog.current]. onClickTitle = { navigator.push(AnimeScreen(dialog.current.id)) }, + // AY --> + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(dialog.current, dialog.target)) }, + // <-- AY onDismissRequest = onDismissRequest, ) } - AnimeScreenModel.Dialog.SettingsSheet -> EpisodeSettingsDialog( + AnimeScreenModel.Dialog.EpisodeSettingsSheet -> EpisodeSettingsDialog( onDismissRequest = onDismissRequest, anime = successState.anime, onDownloadFilterChanged = screenModel::setDownloadedFilter, onUnseenFilterChanged = screenModel::setUnseenFilter, onBookmarkedFilterChanged = screenModel::setBookmarkedFilter, - // AM (FILLERMARK) --> + // AY --> onFillermarkedFilterChanged = screenModel::setFillermarkedFilter, - // <-- AM (FILLERMARK) + // <-- AY onSortModeChanged = screenModel::setSorting, onDisplayModeChanged = screenModel::setDisplayMode, + // AY --> + onShowPreviewsEnabled = screenModel::showEpisodePreviews, + onShowSummariesEnabled = screenModel::showEpisodeSummaries, + // <-- AY onSetAsDefault = screenModel::setCurrentSettingsAsDefault, onResetToDefault = screenModel::resetToDefaultSettings, 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, + onCompletedFilterChanged = screenModel::setSeasonCompletedFilter, + onBookmarkedFilterChanged = screenModel::setSeasonBookmarkedFilter, + onFillermarkedFilterChanged = screenModel::setSeasonFillermarkedFilter, + 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( @@ -298,24 +360,30 @@ class AnimeScreen( onDismissRequest = onDismissRequest, ) } - AnimeScreenModel.Dialog.FullCover -> { - val sm = rememberScreenModel { AnimeCoverScreenModel(successState.anime.id) } + AnimeScreenModel.Dialog.FullImages -> { + val sm = rememberScreenModel { AnimeImageScreenModel(successState.anime.id) } val anime by sm.state.collectAsState() if (anime != null) { val getContent = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { if (it == null) return@rememberLauncherForActivityResult - sm.editCover(context, it) + sm.editImage(context, it) } - AnimeCoverDialog( + AnimeImagesDialog( anime = anime!!, snackbarHostState = sm.snackbarHostState, + // AY --> + pagerState = sm.pagerState, + // <-- AY isCustomCover = remember(anime) { anime!!.hasCustomCover() }, - onShareClick = { sm.shareCover(context) }, - onSaveClick = { sm.saveCover(context) }, + // AY --> + isCustomBackground = remember(anime) { anime!!.hasCustomBackground() }, + // <-- AY + onShareClick = { sm.shareImage(context) }, + onSaveClick = { sm.saveImage(context) }, onEditClick = { when (it) { EditCoverAction.EDIT -> getContent.launch("image/*") - EditCoverAction.DELETE -> sm.deleteCustomCover(context) + EditCoverAction.DELETE -> sm.deleteCustomImage(context) } }, onDismissRequest = onDismissRequest, 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..dccd7621a3 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,62 @@ 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 in + listOf("getSeasonList", "seasonListRequest", "seasonListParse") + } + ) { + 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 +346,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 +365,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) } @@ -473,7 +544,7 @@ class AnimeScreenModel( // Finally match with enhanced tracking when available addTracks.bindEnhancedTrackers(anime, state.source) // AY --> - if (autoOpenTrack) { + if (!isFromChangeCategory && autoOpenTrack && anime.fetchType == FetchType.Episodes) { showTrackDialog() } // <-- AY @@ -667,29 +738,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 } + } + + 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 newEpisodes = syncEpisodesWithSource.await( - episodes, + 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 +827,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] */ @@ -727,11 +896,11 @@ class AnimeScreenModel( LibraryPreferences.EpisodeSwipeAction.ToggleBookmark -> { bookmarkEpisodes(listOf(episode), !episode.bookmark) } - // AM (FILLERMARK) --> + // AY --> LibraryPreferences.EpisodeSwipeAction.ToggleFillermark -> { fillermarkEpisodes(listOf(episode), !episode.fillermark) } - // <-- AM (FILLERMARK) + // <-- AY LibraryPreferences.EpisodeSwipeAction.Download -> { val downloadAction: EpisodeDownloadAction = when (episodeItem.downloadState) { Download.State.ERROR, @@ -751,6 +920,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. */ @@ -962,7 +1137,7 @@ class AnimeScreenModel( toggleAllSelection(false) } - // AM (FILLERMARK) --> + // AY --> /** * Fillermarks the given list of episodes. @@ -977,7 +1152,7 @@ class AnimeScreenModel( } toggleAllSelection(false) } - // <-- AM (FILLERMARK) + // <-- AY /** * Deletes the given list of episode. @@ -1064,7 +1239,7 @@ class AnimeScreenModel( } } - // AM (FILLERMARK) --> + // AY --> /** * Sets the fillermark filter and requests an UI update. @@ -1083,7 +1258,7 @@ class AnimeScreenModel( setAnimeEpisodeFlags.awaitSetFillermarkFilter(anime, flag) } } - // <-- AM (FILLERMARK) + // <-- AY /** * Sets the active display mode. @@ -1109,6 +1284,33 @@ class AnimeScreenModel( } } + // AY --> + + /** + * Sets whether previews are to be shown or not. + * @param flag to show previews. + */ + fun showEpisodePreviews(flag: Long) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeEpisodeFlags.awaitShowEpisodePreviews(anime, flag) + } + } + + /** + * Sets whether summaries are to be shown or not. + * @param flag to show summaries. + */ + fun showEpisodeSummaries(flag: Long) { + val anime = successState?.anime ?: return + + screenModelScope.launchNonCancellable { + setAnimeEpisodeFlags.awaitShowEpisodeSummaries(anime, flag) + } + } + // <-- AY + fun setCurrentSettingsAsDefault(applyToExisting: Boolean) { val anime = successState?.anime ?: return screenModelScope.launchNonCancellable { @@ -1120,6 +1322,242 @@ 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) + } + } + + // AY --> + + /** + * Sets the season fillermarked filter and requests an UI update. + * @param state whether to display only fillermarked seasons or all seasons. + */ + fun setSeasonFillermarkedFilter(state: TriState) { + val anime = successState?.anime ?: return + + val flag = when (state) { + TriState.DISABLED -> Anime.SHOW_ALL + TriState.ENABLED_IS -> Anime.SEASON_SHOW_FILLERMARKED + TriState.ENABLED_NOT -> Anime.SEASON_SHOW_NOT_FILLERMARKED + } + + screenModelScope.launchNonCancellable { + setAnimeSeasonFlags.awaitSetFillermarkedFilter(anime, flag) + } + } + // <-- AY + + /** + * 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,9 +1733,14 @@ 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 + data object FullImages : Dialog } fun dismissDialog() { @@ -1309,15 +1752,22 @@ 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() { updateSuccessState { it.copy(dialog = Dialog.TrackSheet) } } - fun showCoverDialog() { - updateSuccessState { it.copy(dialog = Dialog.FullCover) } + fun showImagesDialog() { + updateSuccessState { it.copy(dialog = Dialog.FullImages) } } fun showMigrateDialog(duplicate: Anime) { @@ -1357,6 +1807,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 +1826,12 @@ class AnimeScreenModel( ), // <-- AY ) : State { + // AY --> + val processedSeasons by lazy { + seasons.applySeasonFilters(anime).toList() + } + // <-- AY + val processedEpisodes by lazy { episodes.applyFilters(anime).toList() } @@ -1423,13 +1882,23 @@ class AnimeScreenModel( get() = nextAiringEpisode.second.times(1000L).minus( Calendar.getInstance().timeInMillis, ) + val showPreviews: Boolean + get() = anime.showPreviews() + + val showSummaries: Boolean + get() = anime.showSummaries() // <-- AY val scanlatorFilterActive: Boolean 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. @@ -1440,18 +1909,58 @@ class AnimeScreenModel( val unseenFilter = anime.unseenFilter val downloadedFilter = anime.downloadedFilter val bookmarkedFilter = anime.bookmarkedFilter - // AM (FILLERMARK) --> + // AY --> val fillermarkedFilter = anime.fillermarkedFilter - // <-- AM (FILLERMARK) + // <-- AY return asSequence() .filter { (episode) -> applyFilter(unseenFilter) { !episode.seen } } .filter { (episode) -> applyFilter(bookmarkedFilter) { episode.bookmark } } - // AM (FILLERMARK) --> + // AY --> .filter { (episode) -> applyFilter(fillermarkedFilter) { episode.fillermark } } - // <-- AM (FILLERMARK) + // <-- AY .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 completedFilter = anime.seasonCompletedFilter + val bookmarkedFilter = anime.seasonBookmarkedFilter + val fillermarkedFilter = anime.seasonFillermarkedFilter + + 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 { (season) -> applyFilter(fillermarkedFilter) { season.hasFillermarks } } + .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/browse/migration/search/MigrateSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt index 3ea4f7a2e1..40d0758f07 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSearchScreen.kt @@ -8,10 +8,14 @@ import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import eu.kanade.presentation.browse.MigrateSearchScreen import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.animesource.model.FetchType import eu.kanade.tachiyomi.ui.anime.AnimeScreen +import eu.kanade.tachiyomi.ui.browse.migration.season.MigrateSeasonSelectScreen import eu.kanade.tachiyomi.ui.browse.source.globalsearch.SearchScreenModel import mihon.feature.migration.dialog.MigrateAnimeDialog +import mihon.feature.migration.dialog.SelectAnimeDialog import mihon.feature.migration.list.MigrationListScreen +import tachiyomi.domain.anime.model.Anime class MigrateSearchScreen(private val animeId: Long) : Screen() { @@ -22,6 +26,21 @@ class MigrateSearchScreen(private val animeId: Long) : Screen() { val screenModel = rememberScreenModel { MigrateSearchScreenModel(animeId = animeId) } val state by screenModel.state.collectAsState() + // AY --> + val onSelectAnime: (Anime) -> Unit = { + val migrateListScreen = navigator.items + .filterIsInstance() + .lastOrNull() + + if (migrateListScreen == null) { + screenModel.setMigrateDialog(animeId, it) + } else { + migrateListScreen.addMatchOverride(current = animeId, target = it.id) + navigator.popUntil { screen -> screen is MigrationListScreen } + } + } + // <-- AY + MigrateSearchScreen( state = state, fromSourceId = state.from?.source, @@ -33,15 +52,12 @@ class MigrateSearchScreen(private val animeId: Long) : Screen() { onToggleResults = screenModel::toggleFilterResults, onClickSource = { navigator.push(MigrateSourceSearchScreen(state.from!!, it.id, state.searchQuery)) }, onClickItem = { - val migrateListScreen = navigator.items - .filterIsInstance() - .lastOrNull() - - if (migrateListScreen == null) { - screenModel.setMigrateDialog(animeId, it) + if (it.fetchType == FetchType.Seasons) { + // AY --> + screenModel.setSelectDialog(it) + // <-- AY } else { - migrateListScreen.addMatchOverride(current = animeId, target = it.id) - navigator.popUntil { screen -> screen is MigrationListScreen } + onSelectAnime(it) } }, onLongClickItem = { navigator.push(AnimeScreen(it.id, true)) }, @@ -54,6 +70,9 @@ class MigrateSearchScreen(private val animeId: Long) : Screen() { target = dialog.target, // Initiated from the context of [dialog.current] so we show [dialog.target]. onClickTitle = { navigator.push(AnimeScreen(dialog.target.id, true)) }, + // AY --> + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(dialog.current, dialog.target)) }, + // <-- AY onDismissRequest = { screenModel.clearDialog() }, onComplete = { if (navigator.lastItem is AnimeScreen) { @@ -66,6 +85,18 @@ class MigrateSearchScreen(private val animeId: Long) : Screen() { }, ) } + is SearchScreenModel.Dialog.Select -> { + SelectAnimeDialog( + selected = dialog.anime, + onDismissRequest = { screenModel.clearDialog() }, + onClickTitle = { navigator.push(AnimeScreen(dialog.anime.id)) }, + onClickSeasons = { + val isFromList = navigator.items.any { it is MigrationListScreen } + navigator.push(MigrateSeasonSelectScreen(state.from!!, dialog.anime, isFromList)) + }, + onClickSelect = { onSelectAnime(dialog.anime) }, + ) + } else -> {} } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt index 9439578c29..8cc5d959b8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/search/MigrateSourceSearchScreen.kt @@ -23,6 +23,7 @@ import eu.kanade.presentation.components.SearchToolbar import eu.kanade.presentation.util.Screen import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.ui.anime.AnimeScreen +import eu.kanade.tachiyomi.ui.browse.migration.season.MigrateSeasonSelectScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel import eu.kanade.tachiyomi.ui.browse.source.browse.SourceFilterDialog import eu.kanade.tachiyomi.ui.home.HomeScreen @@ -137,6 +138,9 @@ data class MigrateSourceSearchScreen( // Initiated from the context of [currentAnime] so we show [dialog.target]. onClickTitle = { navigator.push(AnimeScreen(dialog.target.id)) }, onDismissRequest = onDismissRequest, + // AY --> + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(currentAnime, dialog.target)) }, + // <-- AY onComplete = { scope.launch { navigator.popUntilRoot() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreen.kt new file mode 100644 index 0000000000..72f5acc7ca --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreen.kt @@ -0,0 +1,133 @@ +// AY --> +package eu.kanade.tachiyomi.ui.browse.migration.season + +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalUriHandler +import cafe.adriel.voyager.core.model.rememberScreenModel +import cafe.adriel.voyager.navigator.LocalNavigator +import cafe.adriel.voyager.navigator.currentOrThrow +import eu.kanade.core.util.ifSourcesLoaded +import eu.kanade.presentation.browse.BrowseSourceContent +import eu.kanade.presentation.components.AppBar +import eu.kanade.presentation.util.Screen +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.ui.anime.AnimeScreen +import eu.kanade.tachiyomi.ui.webview.WebViewScreen +import mihon.feature.migration.dialog.MigrateAnimeDialog +import mihon.feature.migration.dialog.SelectAnimeDialog +import mihon.feature.migration.list.MigrationListScreen +import mihon.presentation.core.util.collectAsLazyPagingItems +import tachiyomi.core.common.Constants +import tachiyomi.domain.anime.model.Anime +import tachiyomi.presentation.core.components.material.Scaffold +import tachiyomi.presentation.core.screens.LoadingScreen +import tachiyomi.source.local.LocalSource + +data class MigrateSeasonSelectScreen( + private val oldAnime: Anime, + private val anime: Anime, + private val isFromList: Boolean = false, +) : Screen() { + @Composable + override fun Content() { + if (!ifSourcesLoaded()) { + LoadingScreen() + return + } + + val uriHandler = LocalUriHandler.current + val navigator = LocalNavigator.currentOrThrow + + val screenModel = rememberScreenModel { MigrateSeasonSelectScreenModel(anime) } + val state by screenModel.state.collectAsState() + + val snackbarHostState = remember { SnackbarHostState() } + + Scaffold( + topBar = { scrollBehavior -> + AppBar( + title = anime.title, + navigateUp = navigator::pop, + scrollBehavior = scrollBehavior, + ) + }, + ) { paddingValues -> + val openDialog: (Anime) -> Unit = { + val dialog = if (isFromList) { + MigrateSeasonSelectScreenModel.Dialog.Select(anime = it) + } else { + MigrateSeasonSelectScreenModel.Dialog.Migrate(newAnime = it, oldAnime = oldAnime) + } + screenModel.setDialog(dialog) + } + BrowseSourceContent( + source = screenModel.source, + animeList = screenModel.seasonPagerFlowFlow.collectAsLazyPagingItems(), + columns = screenModel.getColumnsPreference(LocalConfiguration.current.orientation), + displayMode = screenModel.displayMode, + snackbarHostState = snackbarHostState, + contentPadding = paddingValues, + onWebViewClick = { + val source = screenModel.source as? AnimeHttpSource ?: return@BrowseSourceContent + navigator.push( + WebViewScreen( + url = source.baseUrl, + initialTitle = source.name, + sourceId = source.id, + ), + ) + }, + onHelpClick = { uriHandler.openUri(Constants.URL_HELP) }, + onLocalSourceHelpClick = { uriHandler.openUri(LocalSource.HELP_URL) }, + onAnimeClick = openDialog, + onAnimeLongClick = { navigator.push(AnimeScreen(it.id, true)) }, + ) + } + + val onDismissRequest = { screenModel.setDialog(null) } + when (val dialog = state.dialog) { + is MigrateSeasonSelectScreenModel.Dialog.Migrate -> { + MigrateAnimeDialog( + current = dialog.oldAnime, + target = dialog.newAnime, + onDismissRequest = onDismissRequest, + onClickTitle = { navigator.push(AnimeScreen(dialog.newAnime.id)) }, + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(oldAnime, dialog.newAnime)) }, + onComplete = { + val animeScreen = navigator.items + .filterIsInstance() + .lastOrNull() + + if (animeScreen != null) { + navigator.popUntil { it is AnimeScreen } + navigator.push(AnimeScreen(dialog.newAnime.id)) + } + }, + ) + } + is MigrateSeasonSelectScreenModel.Dialog.Select -> { + SelectAnimeDialog( + selected = dialog.anime, + onDismissRequest = onDismissRequest, + onClickTitle = { navigator.push(AnimeScreen(dialog.anime.id)) }, + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(oldAnime, dialog.anime, true)) }, + onClickSelect = { + val migrateListScreen = navigator.items + .filterIsInstance() + .last() + + migrateListScreen.addMatchOverride(current = oldAnime.id, target = dialog.anime.id) + navigator.popUntil { screen -> screen is MigrationListScreen } + }, + ) + } + null -> {} + } + } +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreenModel.kt new file mode 100644 index 0000000000..7935cbefb6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/migration/season/MigrateSeasonSelectScreenModel.kt @@ -0,0 +1,121 @@ +// AY --> +package eu.kanade.tachiyomi.ui.browse.migration.season + +import android.content.res.Configuration +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import eu.kanade.core.preference.asState +import eu.kanade.domain.anime.model.toSAnime +import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.presentation.util.ioCoroutineScope +import eu.kanade.tachiyomi.animesource.model.SAnime +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import mihon.domain.anime.model.toDomainAnime +import tachiyomi.domain.anime.interactor.GetAnime +import tachiyomi.domain.anime.interactor.NetworkToLocalAnime +import tachiyomi.domain.anime.model.Anime +import tachiyomi.domain.library.service.LibraryPreferences +import tachiyomi.domain.source.service.SourceManager +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MigrateSeasonSelectScreenModel( + private val anime: Anime, + sourceManager: SourceManager = Injekt.get(), + sourcePreferences: SourcePreferences = Injekt.get(), + private val libraryPreferences: LibraryPreferences = Injekt.get(), + private val getAnime: GetAnime = Injekt.get(), + private val networkToLocalAnime: NetworkToLocalAnime = Injekt.get(), +) : StateScreenModel(State()) { + + var displayMode by sourcePreferences.sourceDisplayMode().asState(screenModelScope) + val source = sourceManager.getOrStub(anime.source) + + fun getColumnsPreference(orientation: Int): GridCells { + val isLandscape = orientation == Configuration.ORIENTATION_LANDSCAPE + val columns = if (isLandscape) { + libraryPreferences.landscapeColumns() + } else { + libraryPreferences.portraitColumns() + }.get() + return if (columns == 0) GridCells.Adaptive(128.dp) else GridCells.Fixed(columns) + } + + private val hideInLibraryItems = sourcePreferences.hideInLibraryItems().get() + val seasonPagerFlowFlow = flow { emit(anime) } + .map { anime -> + Pager( + config = PagingConfig(pageSize = 25), + pagingSourceFactory = { + SeasonListPagingSource { + source.getSeasonList(anime.toSAnime()) + } + }, + ).flow.map { pagingData -> + pagingData.map { + networkToLocalAnime.invoke(it.toDomainAnime(anime.source)) + .let { localAnime -> getAnime.subscribe(localAnime.url, localAnime.source) } + .filterNotNull() + .stateIn(ioCoroutineScope) + } + .filter { !hideInLibraryItems || !it.value.favorite } + } + .cachedIn(ioCoroutineScope) + } + .stateIn(ioCoroutineScope, SharingStarted.Lazily, emptyFlow()) + + private class SeasonListPagingSource( + private val loadSeasonList: suspend () -> List, + ) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val seasonList = loadSeasonList() + + LoadResult.Page( + data = seasonList, + prevKey = null, + nextKey = null, + ) + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return null + } + } + + fun setDialog(dialog: Dialog?) { + mutableState.update { it.copy(dialog = dialog) } + } + + sealed interface Dialog { + data class Select(val anime: Anime) : Dialog + data class Migrate(val newAnime: Anime, val oldAnime: Anime) : Dialog + } + + @Immutable + data class State( + val dialog: Dialog? = null, + ) +} +// <-- AY diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt index 427623a376..3d95ce499f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler @@ -51,6 +52,7 @@ import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource import eu.kanade.tachiyomi.ui.anime.AnimeScreen import eu.kanade.tachiyomi.ui.browse.extension.details.SourcePreferencesScreen +import eu.kanade.tachiyomi.ui.browse.migration.season.MigrateSeasonSelectScreen import eu.kanade.tachiyomi.ui.browse.source.browse.BrowseSourceScreenModel.Listing import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.webview.WebViewScreen @@ -135,9 +137,7 @@ data class BrowseSourceScreen( .background(MaterialTheme.colorScheme.surface) .pointerInput(Unit) {} // AY --> - .onGloballyPositioned { layoutCoordinates -> - topBarHeight = layoutCoordinates.size.height - }, + .onSizeChanged { topBarHeight = it.height }, // <-- AY ) { BrowseSourceToolbar( @@ -279,6 +279,9 @@ data class BrowseSourceScreen( target = dialog.target, // Initiated from the context of [dialog.target] so we show [dialog.current]. onClickTitle = { navigator.push(AnimeScreen(dialog.current.id)) }, + // AY --> + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(dialog.current, dialog.target)) }, + // <-- AY onDismissRequest = onDismissRequest, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt index fc0f17e42e..873c3e2df0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourceScreenModel.kt @@ -21,7 +21,9 @@ import eu.kanade.domain.track.interactor.AddTracks import eu.kanade.presentation.util.ioCoroutineScope import eu.kanade.tachiyomi.animesource.AnimeCatalogueSource import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache +import eu.kanade.tachiyomi.util.removeBackgrounds import eu.kanade.tachiyomi.util.removeCovers import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -60,6 +62,9 @@ class BrowseSourceScreenModel( sourcePreferences: SourcePreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), + // AY --> + private val backgroundCache: BackgroundCache = Injekt.get(), + // <-- AY private val getRemoteAnime: GetRemoteAnime = Injekt.get(), private val getDuplicateLibraryAnime: GetDuplicateLibraryAnime = Injekt.get(), private val getCategories: GetCategories = Injekt.get(), @@ -241,6 +246,9 @@ class BrowseSourceScreenModel( if (!new.favorite) { new = new.removeCovers(coverCache) + // AY --> + new = new.removeBackgrounds(backgroundCache) + // <-- AY } else { setAnimeDefaultEpisodeFlags.await(anime) addTracks.bindEnhancedTrackers(anime, source) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt index f9fe41a521..fec838ee75 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/globalsearch/SearchScreenModel.kt @@ -209,6 +209,12 @@ abstract class SearchScreenModel( } } + // AY --> + fun setSelectDialog(selected: Anime) { + mutableState.update { it.copy(dialog = Dialog.Select(selected)) } + } + // <-- AY + fun clearDialog() { mutableState.update { it.copy(dialog = null) } } @@ -228,6 +234,10 @@ abstract class SearchScreenModel( } sealed interface Dialog { + // AY --> + data class Select(val anime: Anime) : Dialog + + // <-- AY data class Migrate(val target: Anime, val current: Anime) : Dialog } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt index 6d7125781b..ac4551d994 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/history/HistoryTab.kt @@ -17,6 +17,7 @@ import eu.kanade.presentation.history.HistoryScreen import eu.kanade.presentation.history.components.HistoryDeleteAllDialog import eu.kanade.presentation.history.components.HistoryDeleteDialog import eu.kanade.tachiyomi.ui.anime.AnimeScreen +import eu.kanade.tachiyomi.ui.browse.migration.season.MigrateSeasonSelectScreen import eu.kanade.tachiyomi.ui.category.CategoryScreen import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.player.PlayerActivity @@ -99,6 +100,9 @@ fun Screen.HistoryHalfTab( target = dialog.target, // Initiated from the context of [dialog.target] so we show [dialog.current]. onClickTitle = { navigator.push(AnimeScreen(dialog.current.id)) }, + // AY --> + onClickSeasons = { navigator.push(MigrateSeasonSelectScreen(dialog.current, dialog.target)) }, + // <-- AY onDismissRequest = onDismissRequest, ) } 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..e515942451 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 @@ -17,12 +17,14 @@ import eu.kanade.presentation.components.SEARCH_DEBOUNCE_MILLIS import eu.kanade.presentation.library.components.LibraryToolbarTitle import eu.kanade.tachiyomi.animesource.model.SAnime import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.data.cache.BackgroundCache import eu.kanade.tachiyomi.data.cache.CoverCache import eu.kanade.tachiyomi.data.download.DownloadCache import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.track.TrackStatus import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.util.episode.getNextUnseen +import eu.kanade.tachiyomi.util.removeBackgrounds import eu.kanade.tachiyomi.util.removeCovers import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -90,6 +92,9 @@ class LibraryScreenModel( private val preferences: BasePreferences = Injekt.get(), private val libraryPreferences: LibraryPreferences = Injekt.get(), private val coverCache: CoverCache = Injekt.get(), + // AY --> + private val backgroundCache: BackgroundCache = Injekt.get(), + // <-- AY private val sourceManager: SourceManager = Injekt.get(), private val downloadManager: DownloadManager = Injekt.get(), private val downloadCache: DownloadCache = Injekt.get(), @@ -189,9 +194,6 @@ class LibraryScreenModel( prefs.filterUnseen, prefs.filterStarted, prefs.filterBookmarked, - // AM (FILLERMARK) --> - prefs.filterFillermarked, - // <-- AM (FILLERMARK) prefs.filterCompleted, prefs.filterIntervalCustom, *trackFilters.values.toTypedArray(), @@ -228,9 +230,6 @@ class LibraryScreenModel( val filterUnseen = preferences.filterUnseen val filterStarted = preferences.filterStarted val filterBookmarked = preferences.filterBookmarked - // AM (FILLERMARK) --> - val filterFillermarked = preferences.filterFillermarked - // <-- AM (FILLERMARK) val filterCompleted = preferences.filterCompleted val filterIntervalCustom = preferences.filterIntervalCustom @@ -260,12 +259,6 @@ class LibraryScreenModel( applyFilter(filterBookmarked) { it.libraryAnime.hasBookmarks } } - // AM (FILLERMARK) --> - val filterFnFillermarked: (LibraryItem) -> Boolean = { - applyFilter(filterFillermarked) { it.libraryAnime.hasFillermarks } - } - // <-- AM (FILLERMARK) - val filterFnCompleted: (LibraryItem) -> Boolean = { applyFilter(filterCompleted) { it.libraryAnime.anime.status.toInt() == SAnime.COMPLETED } } @@ -296,9 +289,6 @@ class LibraryScreenModel( filterFnUnseen(it) && filterFnStarted(it) && filterFnBookmarked(it) && - // AM (FILLERMARK) --> - filterFnFillermarked(it) && - // <-- AM (FILLERMARK) filterFnCompleted(it) && filterFnIntervalCustom(it) && filterFnTracking(it) @@ -396,7 +386,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) @@ -459,9 +451,6 @@ class LibraryScreenModel( libraryPreferences.filterUnseen().changes(), libraryPreferences.filterStarted().changes(), libraryPreferences.filterBookmarked().changes(), - // AM (FILLERMARK) --> - libraryPreferences.filterFillermarked().changes(), - // <-- AM (FILLERMARK) libraryPreferences.filterCompleted().changes(), libraryPreferences.filterIntervalCustom().changes(), ) { @@ -476,11 +465,8 @@ class LibraryScreenModel( filterUnseen = it[7] as TriState, filterStarted = it[8] as TriState, filterBookmarked = it[9] as TriState, - // AM (FILLERMARK) --> - filterFillermarked = it[10] as TriState, - filterCompleted = it[11] as TriState, - filterIntervalCustom = it[12] as TriState, - // <-- AM (FILLERMARK) + filterCompleted = it[10] as TriState, + filterIntervalCustom = it[11] as TriState, ) } } @@ -632,6 +618,9 @@ class LibraryScreenModel( if (deleteFromLibrary) { val toDelete = animes.map { it.removeCovers(coverCache) + // AY --> + it.removeBackgrounds(backgroundCache) + // <-- AY AnimeUpdate( favorite = false, id = it.id, @@ -930,9 +919,6 @@ class LibraryScreenModel( val filterUnseen: TriState, val filterStarted: TriState, val filterBookmarked: TriState, - // AM (FILLERMARK) --> - val filterFillermarked: TriState, - // <-- AM (FILLERMARK) val filterCompleted: TriState, val filterIntervalCustom: TriState, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/ExternalIntents.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/ExternalIntents.kt index 2217fa84e7..18e7b610e1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/ExternalIntents.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/ExternalIntents.kt @@ -489,9 +489,9 @@ class ExternalIntents { id = currEp.id, seen = seen, bookmark = currEp.bookmark, - // AM (FILLERMARK) --> + // AY --> fillermark = currEp.fillermark, - // <-- AM (FILLERMARK) + // <-- AY lastSecondSeen = lastSecondSeen, totalSeconds = totalSeconds, ), diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt index 09a5111d92..c4b1b571a0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerActivity.kt @@ -253,8 +253,8 @@ class PlayerActivity : BaseActivity() { is PlayerViewModel.Event.ShareImage -> { onShareImageResult(event.uri, event.seconds) } - is PlayerViewModel.Event.SetCoverResult -> { - onSetAsCoverResult(event.result) + is PlayerViewModel.Event.SetArtResult -> { + onSetAsArtResult(event.result, event.artType) } } } @@ -1162,15 +1162,20 @@ class PlayerActivity : BaseActivity() { } /** - * Called from the presenter when a screenshot is set as cover or fails. + * Called from the presenter when a screenshot is set as art or fails. * It shows a different message depending on the [result]. */ - private fun onSetAsCoverResult(result: SetAsCover) { + private fun onSetAsArtResult(result: SetAsArt, artType: ArtType) { toast( when (result) { - SetAsCover.Success -> MR.strings.cover_updated - SetAsCover.AddToLibraryFirst -> MR.strings.notification_first_add_to_library - SetAsCover.Error -> MR.strings.notification_cover_update_failed + SetAsArt.Success -> + when (artType) { + ArtType.Cover -> MR.strings.cover_updated + ArtType.Background -> AYMR.strings.background_updated + ArtType.Thumbnail -> AYMR.strings.thumbnail_updated + } + SetAsArt.AddToLibraryFirst -> MR.strings.notification_first_add_to_library + SetAsArt.Error -> MR.strings.notification_cover_update_failed }, ) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt index 6315344e8f..40f87fc965 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/PlayerEnums.kt @@ -24,14 +24,20 @@ import tachiyomi.i18n.MR import tachiyomi.i18n.aniyomi.AYMR /** - * Results of the set as cover feature. + * Results of the set as art feature. */ -enum class SetAsCover { +enum class SetAsArt { Success, AddToLibraryFirst, Error, } +enum class ArtType { + Cover, + Background, + Thumbnail, +} + enum class PlayerOrientation(val titleRes: StringResource) { Free(MR.strings.rotation_free), Video(AYMR.strings.rotation_video), 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..2daa36cff0 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 @@ -78,7 +78,9 @@ import eu.kanade.tachiyomi.ui.player.settings.PlayerPreferences import eu.kanade.tachiyomi.ui.player.utils.AniSkipApi import eu.kanade.tachiyomi.ui.player.utils.ChapterUtils.Companion.getStringRes import eu.kanade.tachiyomi.ui.player.utils.TrackSelect +import eu.kanade.tachiyomi.util.editBackground import eu.kanade.tachiyomi.util.editCover +import eu.kanade.tachiyomi.util.editThumbnail import eu.kanade.tachiyomi.util.episode.filterDownloaded import eu.kanade.tachiyomi.util.lang.byteSize import eu.kanade.tachiyomi.util.lang.takeBytes @@ -1098,11 +1100,11 @@ class PlayerViewModel @JvmOverloads constructor( anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_BOOKMARKED && !it.bookmark || anime.bookmarkedFilterRaw == Anime.EPISODE_SHOW_NOT_BOOKMARKED && - it.bookmark - // AM (FILLERMARK) --> - anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_FILLERMARKED && !it.fillermark || - anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_NOT_FILLERMARKED && it.fillermark - // <-- AM (FILLERMARK) + it.bookmark || + anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_FILLERMARKED && + !it.fillermark || + anime.fillermarkedFilterRaw == Anime.EPISODE_SHOW_NOT_FILLERMARKED && + it.fillermark }.toMutableList() if (episodesForPlayer.all { it.id != episodeId }) { @@ -1303,7 +1305,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 +1482,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) } } @@ -1668,6 +1676,7 @@ class PlayerViewModel @JvmOverloads constructor( id = episode.id!!, seen = episode.seen, bookmark = episode.bookmark, + fillermark = episode.fillermark, lastSecondSeen = episode.last_second_seen, totalSeconds = episode.total_seconds, ), @@ -1709,7 +1718,7 @@ class PlayerViewModel @JvmOverloads constructor( } } - // AM (FILLERMARK) --> + // AY --> /** * Fillermarks the currently active episode. @@ -1724,7 +1733,7 @@ class PlayerViewModel @JvmOverloads constructor( ) } } - // <-- AM (FILLERMARK) + // <-- AY fun takeScreenshot(cachePath: String, showSubtitles: Boolean): InputStream? { val filename = cachePath + "/${System.currentTimeMillis()}_mpv_screenshot_tmp.png" @@ -1809,23 +1818,29 @@ class PlayerViewModel @JvmOverloads constructor( } /** - * Sets the screenshot as cover and notifies the UI of the result. + * Sets the screenshot as art and notifies the UI of the result. */ - fun setAsCover(imageStream: () -> InputStream) { + fun setAsArt(artType: ArtType, imageStream: () -> InputStream) { val anime = currentAnime.value ?: return + val episode = currentEpisode.value ?: return viewModelScope.launchNonCancellable { val result = try { - anime.editCover(Injekt.get(), imageStream()) + when (artType) { + ArtType.Cover -> anime.editCover(Injekt.get(), imageStream()) + ArtType.Background -> anime.editBackground(Injekt.get(), imageStream()) + ArtType.Thumbnail -> episode.editThumbnail(anime, Injekt.get(), imageStream()) + } + if (anime.isLocal() || anime.favorite) { - SetAsCover.Success + SetAsArt.Success } else { - SetAsCover.AddToLibraryFirst + SetAsArt.AddToLibraryFirst } } catch (e: Exception) { - SetAsCover.Error + SetAsArt.Error } - eventChannel.send(Event.SetCoverResult(result)) + eventChannel.send(Event.SetArtResult(result, artType)) } } @@ -2050,7 +2065,7 @@ class PlayerViewModel @JvmOverloads constructor( } sealed class Event { - data class SetCoverResult(val result: SetAsCover) : Event() + data class SetArtResult(val result: SetAsArt, val artType: ArtType) : Event() data class SavedImage(val result: SaveImageResult) : Event() data class ShareImage(val uri: Uri, val seconds: String) : Event() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt index 44a1c2cd2f..a26739f742 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerControls.kt @@ -82,6 +82,7 @@ import kotlinx.coroutines.flow.update import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.collectAsState +import tachiyomi.source.local.isLocal import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -554,6 +555,7 @@ fun PlayerControls( val speed by viewModel.playbackSpeed.collectAsState() val sleepTimerTimeRemaining by viewModel.remainingTime.collectAsState() val showSubtitles by subtitlePreferences.screenshotSubtitles().collectAsState() + val currentSource by viewModel.currentSource.collectAsState() val showFailedHosters by playerPreferences.showFailedHosters().collectAsState() val emptyHosters by playerPreferences.showEmptyHosters().collectAsState() @@ -592,10 +594,11 @@ fun PlayerControls( onStartSleepTimer = viewModel::startTimer, buttons = customButtons.getButtons().toImmutableList(), + isLocalSource = currentSource?.isLocal() == true, showSubtitles = showSubtitles, onToggleShowSubtitles = { subtitlePreferences.screenshotSubtitles().set(it) }, cachePath = viewModel.cachePath, - onSetAsCover = viewModel::setAsCover, + onSetAsArt = viewModel::setAsArt, onShare = { viewModel.shareImage(it, viewModel.pos.value.toInt()) }, onSave = { viewModel.saveImage(it, viewModel.pos.value.toInt()) }, takeScreenshot = viewModel::takeScreenshot, @@ -626,6 +629,7 @@ fun PlayerControls( dateRelativeTime = viewModel.relativeTime, dateFormat = viewModel.dateFormat, onBookmarkClicked = viewModel::bookmarkEpisode, + onFillermarkClicked = viewModel::fillermarkEpisode, onEpisodeClicked = { viewModel.showDialog(Dialogs.None) activity.changeEpisode(it) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt index a7bee0ba0b..9e21182eb4 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerDialogs.kt @@ -18,6 +18,7 @@ fun PlayerDialogs( dateRelativeTime: Boolean, dateFormat: DateTimeFormatter, onBookmarkClicked: (Long?, Boolean) -> Unit, + onFillermarkClicked: (Long?, Boolean) -> Unit, onEpisodeClicked: (Long?) -> Unit, onDismissRequest: () -> Unit, @@ -32,6 +33,7 @@ fun PlayerDialogs( dateRelativeTime = dateRelativeTime, dateFormat = dateFormat, onBookmarkClicked = onBookmarkClicked, + onFillermarkClicked = onFillermarkClicked, onEpisodeClicked = onEpisodeClicked, onDismissRequest = onDismissRequest, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt index 5386c3b671..2f0d9fe1ad 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/PlayerSheets.kt @@ -22,6 +22,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import dev.vivvvek.seeker.Segment +import eu.kanade.tachiyomi.ui.player.ArtType import eu.kanade.tachiyomi.ui.player.Decoder import eu.kanade.tachiyomi.ui.player.Panels import eu.kanade.tachiyomi.ui.player.PlayerViewModel.VideoTrack @@ -83,10 +84,11 @@ fun PlayerSheets( buttons: ImmutableList, // Screenshot sheet + isLocalSource: Boolean, showSubtitles: Boolean, onToggleShowSubtitles: (Boolean) -> Unit, cachePath: String, - onSetAsCover: (() -> InputStream) -> Unit, + onSetAsArt: (ArtType, (() -> InputStream)) -> Unit, onShare: (() -> InputStream) -> Unit, onSave: (() -> InputStream) -> Unit, takeScreenshot: (String, Boolean) -> InputStream?, @@ -180,11 +182,12 @@ fun PlayerSheets( Sheets.Screenshot -> { ScreenshotSheet( + isLocalSource = isLocalSource, hasSubTracks = subtitles.isNotEmpty(), showSubtitles = showSubtitles, onToggleShowSubtitles = onToggleShowSubtitles, cachePath = cachePath, - onSetAsCover = onSetAsCover, + onSetAsArt = onSetAsArt, onShare = onShare, onSave = onSave, takeScreenshot = takeScreenshot, diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt index bbc1699c7b..9e66ef441f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/dialogs/EpisodeListDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.outlined.Bookmark import androidx.compose.material3.Icon @@ -56,6 +57,7 @@ fun EpisodeListDialog( dateRelativeTime: Boolean, dateFormat: DateTimeFormatter, onBookmarkClicked: (Long?, Boolean) -> Unit, + onFillermarkClicked: (Long?, Boolean) -> Unit, onEpisodeClicked: (Long?) -> Unit, onDismissRequest: () -> Unit, ) { @@ -111,6 +113,7 @@ fun EpisodeListDialog( title = title, date = date, onBookmarkClicked = onBookmarkClicked, + onFillermarkClicked = onFillermarkClicked, onEpisodeClicked = onEpisodeClicked, ) } @@ -126,14 +129,25 @@ private fun EpisodeListItem( title: String, date: String?, onBookmarkClicked: (Long?, Boolean) -> Unit, + onFillermarkClicked: (Long?, Boolean) -> Unit, onEpisodeClicked: (Long?) -> Unit, ) { var isBookmarked by remember { mutableStateOf(episode.bookmark) } + var isFillermarked by remember { mutableStateOf(episode.fillermark) } var textHeight by remember { mutableStateOf(0) } - val bookmarkIcon = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.Bookmark + val defaultColor = MaterialTheme.colorScheme.onSurface val bookmarkAlpha = if (isBookmarked) 1f else DISABLED_ALPHA - val episodeColor = if (isBookmarked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + val bookmarkColor = if (isBookmarked) MaterialTheme.colorScheme.primary else defaultColor + val fillermarkAlpha = if (isFillermarked) 1f else DISABLED_ALPHA + val fillermarkColor = if (isFillermarked) MaterialTheme.colorScheme.tertiary else defaultColor + val episodeColor = if (isBookmarked) { + bookmarkColor + } else if (isFillermarked) { + fillermarkColor + } else { + defaultColor + } val textAlpha = if (episode.seen) DISABLED_ALPHA else 1f val textWeight = if (isCurrentEpisode) FontWeight.Bold else FontWeight.Normal val textStyle = if (isCurrentEpisode) FontStyle.Italic else FontStyle.Normal @@ -144,23 +158,40 @@ private fun EpisodeListItem( onBookmarkClicked(episode.id, bookmarked) } + val clickFillermark: (Boolean) -> Unit = { fillermarked -> + episode.fillermark = fillermarked + isFillermarked = fillermarked + onFillermarkClicked(episode.id, fillermarked) + } + Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = { onEpisodeClicked(episode.id) }) - .padding(vertical = MaterialTheme.padding.small), + .padding(vertical = MaterialTheme.padding.extraSmall), ) { IconButton(onClick = { clickBookmark(!isBookmarked) }) { Icon( - imageVector = bookmarkIcon, + imageVector = Icons.Filled.Bookmark, contentDescription = null, - tint = episodeColor, + tint = bookmarkColor, modifier = Modifier .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }) .alpha(bookmarkAlpha), ) } + IconButton(onClick = { clickFillermark(!isFillermarked) }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = null, + tint = fillermarkColor, + modifier = Modifier + .sizeIn(maxHeight = with(LocalDensity.current) { textHeight.toDp() - 2.dp }) + .alpha(fillermarkAlpha), + ) + } + Spacer(modifier = Modifier.width(2.dp)) Column { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt index 0bfbc3af72..8eb903450d 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/panels/SubtitleSettingsMiscellaneousCard.kt @@ -82,9 +82,7 @@ fun SubtitlesMiscellaneousCard(modifier: Modifier = Modifier) { MPVLib.setPropertyString("sub-ass-override", if (it) "force" else "scale") }, content = { Text(stringResource(AYMR.strings.player_sheets_sub_override_ass)) }, - modifier = Modifier - .padding(MaterialTheme.padding.medium) - .fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), ) var subScale by remember { mutableStateOf(MPVLib.getPropertyDouble("sub-scale").toFloat()) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt index 4ba68f645a..b7d210d1e3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/PlaybackSpeedSheet.kt @@ -150,8 +150,6 @@ fun PlaybackSpeedSheet( ) } }, - modifier = Modifier - .padding(MaterialTheme.padding.medium), ) Row( modifier = Modifier diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt index 5a0bd45023..e616741ae3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/player/controls/components/sheets/ScreenshotSheet.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import eu.kanade.presentation.player.components.PlayerSheet import eu.kanade.presentation.player.components.SwitchPreference +import eu.kanade.tachiyomi.ui.player.ArtType import eu.kanade.tachiyomi.ui.player.controls.components.dialogs.PlayerDialog import tachiyomi.i18n.MR import tachiyomi.i18n.aniyomi.AYMR @@ -29,12 +30,13 @@ import java.io.InputStream @Composable fun ScreenshotSheet( + isLocalSource: Boolean, hasSubTracks: Boolean, showSubtitles: Boolean, onToggleShowSubtitles: (Boolean) -> Unit, cachePath: String, - onSetAsCover: (() -> InputStream) -> Unit, + onSetAsArt: (ArtType, (() -> InputStream)) -> Unit, onShare: (() -> InputStream) -> Unit, onSave: (() -> InputStream) -> Unit, takeScreenshot: (String, Boolean) -> InputStream?, @@ -42,23 +44,37 @@ fun ScreenshotSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { - var showSetCoverDialog by remember { mutableStateOf(false) } + var setArtTypeAs: ArtType? by remember { mutableStateOf(null) } PlayerSheet( - onDismissRequest, - modifier, + onDismissRequest = onDismissRequest, + modifier = modifier, ) { Column { Row( - modifier = Modifier.padding(vertical = MaterialTheme.padding.medium), + modifier = Modifier.padding(top = MaterialTheme.padding.medium), horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.small), ) { ActionButton( modifier = Modifier.weight(1f), title = stringResource(MR.strings.set_as_cover), icon = Icons.Outlined.Photo, - onClick = { showSetCoverDialog = true }, + onClick = { setArtTypeAs = ArtType.Cover }, ) + ActionButton( + modifier = Modifier.weight(1f), + title = stringResource(AYMR.strings.set_as_background), + icon = Icons.Outlined.Photo, + onClick = { setArtTypeAs = ArtType.Background }, + ) + if (isLocalSource) { + ActionButton( + modifier = Modifier.weight(1f), + title = stringResource(AYMR.strings.set_as_thumbnail), + icon = Icons.Outlined.Photo, + onClick = { setArtTypeAs = ArtType.Thumbnail }, + ) + } ActionButton( modifier = Modifier.weight(1f), title = stringResource(MR.strings.action_share), @@ -81,9 +97,7 @@ fun ScreenshotSheet( SwitchPreference( value = showSubtitles, onValueChange = onToggleShowSubtitles, - modifier = Modifier.padding( - MaterialTheme.padding.medium, - ), + modifier = Modifier.padding(bottom = MaterialTheme.padding.medium), content = { Text( text = stringResource(AYMR.strings.screenshot_show_subs), @@ -96,19 +110,19 @@ fun ScreenshotSheet( } } - if (showSetCoverDialog) { + if (setArtTypeAs != null) { PlayerDialog( title = stringResource(MR.strings.confirm_set_image_as_cover), modifier = Modifier.fillMaxWidth(fraction = 0.6F).padding(MaterialTheme.padding.medium), onConfirmRequest = { - onSetAsCover { + onSetAsArt(setArtTypeAs!!) { takeScreenshot( cachePath, showSubtitles, )!! } }, - onDismissRequest = { showSetCoverDialog = false }, + onDismissRequest = { setArtTypeAs = null }, ) } } 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